Skip to main content

falcon_library/
export.rs

1//! Export to Computer: copying originals out of the library into a folder
2//! the user picked.
3//!
4//! A [`Plan`] is worked out first (where each original goes, which
5//! directories to make), so the app can warn about files it would replace
6//! before anything is written, and run the copy on another thread. Names
7//! follow Eagle's export: an item keeps `<name>.<ext>`; names that clash
8//! within one directory (ignoring case, as the Mac's file system does) are
9//! numbered `<name>-1.<ext>`, `<name>-2.<ext>`, with the number padded to
10//! the width of the largest one.
11
12use 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/// What to do when a file of the same name is already in the destination.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Collision {
27    /// Overwrite it.
28    Replace,
29    /// Leave it and give the exported file a numbered name.
30    KeepBoth,
31}
32
33/// One original to copy.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Step {
36    pub id: String,
37    pub from: PathBuf,
38    pub to: PathBuf,
39}
40
41/// Everything an export will write.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Plan {
44    /// Directories to create, parents first.
45    pub dirs: Vec<PathBuf>,
46    pub steps: Vec<Step>,
47    /// Where the export lands: the chosen folder, or the top of the
48    /// exported folder tree.
49    pub top: PathBuf,
50    /// Whether steps may overwrite files (see [`Collision`]).
51    pub overwrite: bool,
52}
53
54impl Plan {
55    /// Steps whose destination already exists, which a `Replace` plan
56    /// overwrites.
57    pub fn clashes(&self) -> usize {
58        self.steps.iter().filter(|s| s.to.exists()).count()
59    }
60}
61
62/// An item's file name, `<name>.<ext>`, with any `/` (which no file name
63/// can hold) turned into a space.
64pub 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
69/// Splits a file name at its last dot (none for a leading dot).
70fn 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
77/// Chooses the names for `files` copied into one directory that already
78/// holds `existing` (lower-cased). Names are compared ignoring case. A name
79/// used once, and free, is kept. Otherwise every file of that name is
80/// numbered, as Eagle does: `a.png` twice becomes `a-1.png` and `a-2.png`;
81/// with `KeepBoth`, an `a.png` already there counts as the first, so a new
82/// one becomes `a-2.png`. A numbered name that is taken is skipped.
83pub 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    // Names that stay as they are, reserved before any numbering so that
91    // `a-1.png` in the batch is not handed to a second `a.png`.
92    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
120/// Lower-cased names of the entries in `dir` (none if it doesn't exist).
121fn 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
125/// Plans copying the originals of `ids` (in order, each once) straight
126/// into `dest`. Unknown ids are skipped.
127pub 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
135/// The directory name for a folder, cleaned up as Eagle does on the Mac:
136/// tabs and runs of spaces become one space, `:` `/` and `\\` become
137/// spaces, the ends are trimmed and a trailing dot is dropped. A name left
138/// empty becomes "Untitled".
139pub 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
149/// Plans exporting the folder `id` as a directory tree under `dest`: a
150/// directory per folder and subfolder, each holding the originals of the
151/// live items filed directly in it (an item in two folders is copied into
152/// both). Items in any folder of `hidden` (locked ones) are left out, as
153/// are hidden subfolders. Sibling folders whose names clean up to the same
154/// directory share it. `None` if there is no such folder.
155pub 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
186/// Plans exporting the smart folder `id` as one flat directory under
187/// `dest` holding the originals of its items (Eagle exports a smart folder
188/// without its subfolders). Items in `hidden` folders are left out. `None`
189/// if there is no such smart folder.
190pub 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
214/// Copies one original, keeping its modification time, as Eagle does. The
215/// copy is written beside its destination and renamed into place, so a
216/// failed copy never leaves half a file under the real name.
217pub 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
232/// Runs `plan`, calling `on_step` after each copy with whether it worked,
233/// and returns the paths written. A step that fails is skipped; the rest
234/// still run. Once `cancel` is set the run stops before the next file;
235/// the files already written stay (each is written whole or not at all).
236pub 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// ---------------------------------------------------------------------------
255// Export in a chosen format and size
256
257/// An image format to export to.
258#[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    /// Whether the format takes a quality setting.
285    pub fn lossy(self) -> bool {
286        self != Format::Png
287    }
288}
289
290/// How to convert each image.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct Convert {
293    pub format: Format,
294    /// 1 to 100, for JPG and WebP. WebP at 100 is lossless.
295    pub quality: u8,
296    /// Scale the image down so its longer side is at most this; never up.
297    pub longest: Option<u32>,
298}
299
300/// Whether an item's original can be converted: raster images falcon
301/// decodes, and SVG.
302pub 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
306/// Plans exporting `ids` converted to `format` into `dest`: each file is
307/// named `<name>.<format ext>`, clashes handled as in [`plan_items`].
308/// Items that can't be converted are left out; see [`convertible`].
309pub 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
318/// Decodes an original (SVG rendered at its natural size), scales it down
319/// to `longest` if needed, and encodes it as `convert` asks.
320pub 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            // JPEG has no alpha: transparent parts go on white.
338            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
360/// Runs a [`plan_converted`] plan: each step's original is converted and
361/// written beside its destination, then renamed into place. `cancel`
362/// stops it between files, as in [`run`].
363pub 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
381/// Writes `bytes` to `to` through a temporary file beside it.
382fn 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
394// ---------------------------------------------------------------------------
395// CSV
396
397/// The CSV columns, in Eagle's order: id, name, extension, width, height,
398/// duration, source URL, notes, comments, tags, folders, size, rating,
399/// import time, modification time and the original's path. The header
400/// text is falcon's own.
401pub 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
420/// One CSV field: quoted, with quotes doubled, when it holds a comma, a
421/// quote or a line break; as it is otherwise.
422pub fn csv_field(s: &str) -> String {
423    if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
424}
425
426/// A time in ms since the epoch as Eagle's CSV writes it, `YYYY-MM-DD
427/// HH:MM:SS` in UTC; empty for 0.
428fn 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
435/// A number field: empty when missing or zero, as Eagle writes it.
436fn 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
443/// The CSV row for one item.
444pub 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
468/// The CSV text for `ids` (unknown ids skipped): a header and one row per
469/// item, lines joined with `\n`, preceded by a UTF-8 byte order mark so
470/// spreadsheet apps read it as UTF-8, as Eagle writes it.
471pub 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
481/// Writes [`csv`] to `dest` (through a temporary file) and returns the
482/// number of rows.
483pub 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
492// ---------------------------------------------------------------------------
493// Copy variants
494
495/// Lines for Copy File Path: each item's original, one per line, as Eagle
496/// copies them.
497pub 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
501/// Lines for Copy Folder Path: each item's `<id>.info` folder with a
502/// trailing slash, as Eagle copies them.
503pub 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
507/// The MIME subtype Eagle uses for an original it copies as Base64 itself;
508/// other kinds are copied as their thumbnail.
509fn 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
519/// Copy Base64: a `data:` URL of the item's original when it is a JPEG,
520/// PNG, WebP or GIF, else of its thumbnail (whose real format is sniffed:
521/// Eagle's thumbnails are WebP inside a `.png` name). `None` when there is
522/// neither.
523pub 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
540/// Copy Thumbnail: the item's thumbnail decoded, or, for an item without
541/// one, its original if that is an image.
542pub 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        // Padded to the widest number.
570        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        // Taken numbered names are skipped.
580        assert_eq!(names(&["a.png"], &["a.png", "a-2.png"], Collision::KeepBoth), ["a-3.png"]);
581        // A batch file already called a-1.png keeps that name.
582        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    /// A library with three items, two of them named `pic`.
590    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        // No temp files are left behind.
624        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        // The two "Inner" folders share a directory, so their pics are numbered.
699        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        // Unlocked, the locked folder comes too.
704        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        // A PNG is copied as itself.
784        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        // Another kind falls back to its thumbnail, or to nothing.
791        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                // The transparent half turns white, not black.
826                let p = img.to_rgb8().get_pixel(0, 2).0;
827                assert!(p.iter().all(|c| *c > 200), "{p:?}");
828            }
829        }
830        // Never enlarged; lossless WebP at 100.
831        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        // pic.jpg itself is still free.
854        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}