Skip to main content

falcon_library/
import.rs

1//! Importing a local file into a library, following Eagle's `processImage`.
2
3use std::fs;
4use std::io::Read;
5use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use filetime::FileTime;
9use image::DynamicImage;
10use serde_json::{Value, json};
11use unicode_normalization::UnicodeNormalization;
12
13use crate::error::{Error, Result};
14use crate::library::Library;
15use crate::model::Item;
16use crate::{exif, fontmeta, fontpreview, ids, keywords, media, palette, tempo, thumbnail, waveform, weblink};
17
18/// Longest file name Eagle allows inside an `.info` folder, in bytes.
19const MAX_FILE_NAME: usize = 255;
20/// `_thumbnail.png`, which is appended to the name.
21const THUMB_SUFFIX: &str = "_thumbnail.png";
22
23/// Settings for [`Library::import_file`].
24#[derive(Debug, Clone)]
25pub struct ImportOptions {
26    /// Ids of folders to put the item in; each must exist.
27    pub folders: Vec<String>,
28    /// Tags to attach.
29    pub tags: Vec<String>,
30    /// Whether to extract a palette (default true).
31    pub palette: bool,
32    /// Annotation text.
33    pub annotation: String,
34    /// Source URL.
35    pub url: String,
36    /// Add a font's weight (`fontMetas.weight`) as a tag, like Eagle's
37    /// "font auto tag" preference (default true, as in Eagle).
38    pub font_weight_tag: bool,
39    /// Also store `rawMetas` for JPEG, TIFF, HEIC, PNG and WebP photos.
40    /// Eagle only does it for RAW files (default false).
41    pub camera_metadata_all_photos: bool,
42    /// Add embedded IPTC keywords and XMP `dc:subject` entries as tags, like
43    /// Eagle's IPTC preference (default false, as in Eagle).
44    pub keywords_as_tags: bool,
45    /// Add the auto-tags of the folders in `folders` (and their parents'),
46    /// as Eagle does for items imported or pasted into a folder in its
47    /// window (default false: Eagle's API leaves them off). See
48    /// [`crate::autotag`].
49    pub folder_auto_tags: bool,
50    /// A name for the item, in place of the file's own. The file's
51    /// extension is kept. Meant for a single file (default None).
52    pub name: Option<String>,
53    /// Import the item straight into the trash (Inboard's trashed items).
54    pub trashed: bool,
55    /// The item's date added (`modificationTime`, ms) in place of the
56    /// import time, for items brought over from another app.
57    pub added_at: Option<u64>,
58}
59
60impl Default for ImportOptions {
61    fn default() -> Self {
62        ImportOptions {
63            folders: Vec::new(),
64            tags: Vec::new(),
65            palette: true,
66            annotation: String::new(),
67            url: String::new(),
68            font_weight_tag: true,
69            camera_metadata_all_photos: false,
70            keywords_as_tags: false,
71            folder_auto_tags: false,
72            name: None,
73            trashed: false,
74            added_at: None,
75        }
76    }
77}
78
79/// Refusal reasons [`stage`] gives, which [`Failure::new`] recognises.
80const NOT_A_FILE: &str = "not a regular file";
81const EMPTY_FILE: &str = "file is empty";
82const UNRECOGNIZED: &str = "file format is not recognised";
83
84/// Browser download-in-progress extensions (Chrome, Firefox, Safari),
85/// which Eagle's `getExt` treats as having no format.
86const PARTIAL_DOWNLOADS: [&str; 3] = ["crdownload", "part", "download"];
87
88/// Whether Eagle would import a file with this (lower-cased) extension:
89/// any extension at all, except unfinished browser downloads. A file with
90/// no extension is refused as "not recognized"; every other format is
91/// imported, with a thumbnail when one can be made and as an icon
92/// otherwise.
93pub fn is_recognized(ext: &str) -> bool {
94    !ext.is_empty() && !PARTIAL_DOWNLOADS.contains(&ext)
95}
96
97/// Why a file could not be imported, for the app's list of failed imports.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum FailKind {
100    /// The file is gone.
101    Missing,
102    /// A folder or package, not a regular file.
103    NotAFile,
104    /// Empty (only `.txt` files may be).
105    Empty,
106    /// Its format can't be told.
107    Unrecognized,
108    /// It can't be read (permissions, a disk error).
109    Unreadable,
110    /// The disk is full.
111    NoSpace,
112    /// Copying it into the staging folder failed.
113    CopyFailed,
114    /// Writing it into the library failed.
115    LibraryWrite,
116    /// A folder it should go in no longer exists.
117    UnknownFolder,
118    /// Anything else.
119    Other,
120}
121
122impl FailKind {
123    /// A short reason for the user.
124    pub fn describe(self) -> &'static str {
125        match self {
126            FailKind::Missing => "The file no longer exists",
127            FailKind::NotAFile => "Folders and packages can't be imported as items",
128            FailKind::Empty => "The file is empty",
129            FailKind::Unrecognized => "The file format isn't recognised",
130            FailKind::Unreadable => "The file can't be read",
131            FailKind::NoSpace => "Not enough disk space",
132            FailKind::CopyFailed => "Copying the file failed",
133            FailKind::LibraryWrite => "Writing to the library failed",
134            FailKind::UnknownFolder => "The folder to import into no longer exists",
135            FailKind::Other => "The file couldn't be imported",
136        }
137    }
138}
139
140/// One file that failed to import.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Failure {
143    /// The file.
144    pub path: std::path::PathBuf,
145    /// Why, in general.
146    pub kind: FailKind,
147    /// The underlying error, for copying out.
148    pub detail: String,
149}
150
151impl Failure {
152    /// Classifies an error from [`stage`], [`Library::check_folders`] or
153    /// [`Library::commit_import`] for the file at `path`.
154    pub fn new(path: &Path, err: &Error) -> Failure {
155        use std::io::ErrorKind as K;
156        let kind = match err {
157            Error::Import { reason, .. } if reason == NOT_A_FILE => FailKind::NotAFile,
158            Error::Import { reason, .. } if reason == EMPTY_FILE => FailKind::Empty,
159            Error::Import { reason, .. } if reason == UNRECOGNIZED => FailKind::Unrecognized,
160            Error::Import { reason, .. } if reason.starts_with("unknown folder") => FailKind::UnknownFolder,
161            Error::Io { source, .. } if source.kind() == K::StorageFull => FailKind::NoSpace,
162            Error::Io { path: at, source } if at == path => match source.kind() {
163                K::NotFound => FailKind::Missing,
164                _ => FailKind::Unreadable,
165            },
166            // Reading the source fails inside the staging copy too.
167            Error::Io { path: at, source } if is_staging(at) => match source.kind() {
168                K::NotFound if !path.exists() => FailKind::Missing,
169                K::PermissionDenied => FailKind::Unreadable,
170                // Windows: another program holds the file (sharing and lock violations).
171                _ if cfg!(windows) && matches!(source.raw_os_error(), Some(32 | 33)) => FailKind::Unreadable,
172                _ => FailKind::CopyFailed,
173            },
174            Error::Io { .. } | Error::Json { .. } => FailKind::LibraryWrite,
175            _ => FailKind::Other,
176        };
177        Failure { path: path.to_path_buf(), kind, detail: err.to_string() }
178    }
179}
180
181/// Whether `path` is inside one of [`stage`]'s temporary folders.
182fn is_staging(path: &Path) -> bool {
183    path.components().any(|c| c.as_os_str().to_string_lossy().starts_with(STAGING_PREFIX))
184}
185
186const STAGING_PREFIX: &str = "falcon-import";
187
188/// A file copied, thumbnailed and analysed in a temporary folder, ready to
189/// be moved into a library with [`Library::commit_import`]. Staging needs no
190/// access to the library, so it can run on another thread.
191#[derive(Debug)]
192pub struct Staged {
193    item: Item,
194    dir: tempfile::TempDir,
195    /// [`ImportOptions::folder_auto_tags`], applied at commit.
196    auto_tags: bool,
197    /// [`ImportOptions::added_at`].
198    added_at: Option<u64>,
199}
200
201impl Staged {
202    /// The item as it will be stored (its id is assigned at commit).
203    pub fn item(&self) -> &Item {
204        &self.item
205    }
206
207    /// The staged copy of the file.
208    pub fn original(&self) -> std::path::PathBuf {
209        let item = &self.item;
210        self.dir.path().join(if item.ext.is_empty() { item.name.clone() } else { format!("{}.{}", item.name, item.ext) })
211    }
212}
213
214/// Copies `path` into a staging folder with its mtime kept, then makes the
215/// thumbnail and palette, following Eagle's import steps.
216pub fn stage(path: impl AsRef<Path>, opts: &ImportOptions) -> Result<Staged> {
217    let src = path.as_ref();
218    let refuse = |reason: &str| Error::Import { path: src.to_path_buf(), reason: reason.into() };
219
220    let meta = fs::metadata(src).map_err(|e| Error::io(src, e))?;
221    if !meta.is_file() {
222        return Err(refuse(NOT_A_FILE));
223    }
224    let ext = src.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
225    if !is_recognized(&ext) {
226        return Err(refuse(UNRECOGNIZED));
227    }
228    if meta.len() == 0 && ext != "txt" {
229        return Err(refuse(EMPTY_FILE));
230    }
231
232    let mtime_ms = to_ms(meta.modified().ok());
233    // Not every filesystem records a birth time; fall back to the mtime.
234    let btime_ms = meta.created().ok().map_or(mtime_ms, |t| to_ms(Some(t)));
235    let stem = src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
236    let name = match opts.name.as_deref().map(|n| sanitize_name(n, &ext)) {
237        Some(n) if !n.is_empty() => n,
238        _ => sanitize_name(&stem, &ext),
239    };
240
241    let mut item = Item {
242        id: String::new(),
243        name,
244        size: meta.len(),
245        btime: btime_ms,
246        mtime: mtime_ms,
247        ext,
248        tags: opts.tags.clone(),
249        folders: opts.folders.clone(),
250        is_deleted: opts.trashed,
251        url: opts.url.clone(),
252        annotation: opts.annotation.clone(),
253        modification_time: 0,
254        remove_thumbnail: None,
255        no_thumbnail: None,
256        height: 0,
257        width: 0,
258        last_modified: 0,
259        palettes: Vec::new(),
260        key_order: Default::default(),
261        extra: Default::default(),
262    };
263
264    if opts.keywords_as_tags {
265        add_keyword_tags(&mut item.tags, keywords::read(src, &item.ext));
266    }
267
268    let dir = tempfile::Builder::new().prefix(STAGING_PREFIX).tempdir().map_err(|e| Error::io(src, e))?;
269    let file = if item.ext.is_empty() { item.name.clone() } else { format!("{}.{}", item.name, item.ext) };
270    let raw = dir.path().join(file);
271    fs::copy(src, &raw).map_err(|e| Error::io(&raw, e))?;
272    crate::disk_name::make_writable(&raw).map_err(|e| Error::io(&raw, e))?;
273    filetime::set_file_times(&raw, FileTime::from_last_access_time(&meta), FileTime::from_last_modification_time(&meta))
274        .map_err(|e| Error::io(&raw, e))?;
275
276    let thumb = dir.path().join(format!("{}{THUMB_SUFFIX}", item.name));
277    let analysis = generate(&mut item, &raw, &thumb);
278    file_metadata(&mut item, &raw, opts);
279    if opts.palette
280        && let Some(img) = analysis.and_then(|a| match a {
281            Analysis::Original => thumbnail::decode(&raw).ok(),
282            Analysis::Thumbnail => thumbnail::decode(&thumb).ok(),
283        })
284    {
285        item.palettes = palette::extract(&img);
286    }
287    if item.remove_thumbnail == Some(true) {
288        let _ = fs::remove_file(&thumb);
289    }
290    if item.ext == "txt" {
291        set_text(&mut item, &raw);
292        item.key_order = crate::model::KeyOrder(crate::text::RECORD_KEYS.iter().map(|k| k.to_string()).collect());
293    }
294    Ok(Staged { item, dir, auto_tags: opts.folder_auto_tags, added_at: opts.added_at })
295}
296
297impl Library {
298    /// Imports one file and returns the new item's id.
299    ///
300    /// The original is copied into `images/<ID>.info/` with its mtime kept, a
301    /// thumbnail and palette are produced where the format is supported, and
302    /// the item's `metadata.json` and the library's `mtime.json` are written.
303    /// Formats falcon cannot decode import with no thumbnail and no palette.
304    pub fn import_file(&mut self, path: impl AsRef<Path>, opts: &ImportOptions) -> Result<String> {
305        self.check_folders(path.as_ref(), opts)?;
306        let staged = stage(path, opts)?;
307        self.commit_import(staged)
308    }
309
310    /// Fails if `opts` names folders this library doesn't have.
311    pub fn check_folders(&self, path: &Path, opts: &ImportOptions) -> Result<()> {
312        let known = self.folder_ids();
313        match opts.folders.iter().find(|f| !known.contains(f.as_str())) {
314            Some(bad) => Err(Error::Import { path: path.to_path_buf(), reason: format!("unknown folder {bad}") }),
315            None => Ok(()),
316        }
317    }
318
319    /// Moves a staged import into `images/<ID>.info/`, stamps its times and
320    /// writes its `metadata.json` and `mtime.json`.
321    pub fn commit_import(&mut self, staged: Staged) -> Result<String> {
322        let Staged { mut item, dir, auto_tags, added_at } = staged;
323        let known = self.folder_ids();
324        item.folders.retain(|f| known.contains(f.as_str()));
325        if auto_tags {
326            crate::autotag::append_new(&mut item.tags, &crate::autotag::auto_tags_for(&self.metadata.folders, &item.folders));
327        }
328        let mut id = ids::new_id();
329        while self.root().join("images").join(format!("{id}.info")).exists() {
330            id = ids::new_id();
331        }
332        item.id = id;
333        let dest = item.info_dir(self.root());
334        fs::create_dir_all(&dest).map_err(|e| Error::io(&dest, e))?;
335        let moved = (|| -> Result<()> {
336            for entry in fs::read_dir(dir.path()).map_err(|e| Error::io(dir.path(), e))? {
337                let entry = entry.map_err(|e| Error::io(dir.path(), e))?;
338                let to = dest.join(entry.file_name());
339                // A rename can't cross volumes; copy then.
340                if fs::rename(entry.path(), &to).is_err() {
341                    fs::copy(entry.path(), &to).map_err(|e| Error::io(&to, e))?;
342                    if let Ok(m) = fs::metadata(entry.path()) {
343                        let _ = filetime::set_file_times(&to, FileTime::from_last_access_time(&m), FileTime::from_last_modification_time(&m));
344                    }
345                }
346            }
347            item.modification_time = match added_at {
348                Some(t) => t,
349                None => self.next_import_time(now_ms()),
350            };
351            item.last_modified = now_ms().max(item.modification_time);
352            self.register_item(item.clone())
353        })();
354        match moved {
355            Ok(()) => Ok(item.id),
356            Err(e) => {
357                let _ = fs::remove_dir_all(&dest);
358                Err(e)
359            }
360        }
361    }
362}
363
364/// Redraws the thumbnail of an item already in a library and recomputes its
365/// palette, as Eagle's Refresh Thumbnail does: width, height and the
366/// thumbnail flags are measured again from `raw`, and the thumbnail at
367/// `thumb` is replaced only once the new one has been drawn. When the item no
368/// longer needs a thumbnail, the old file is deleted so it can't be shown in
369/// place of the original. Audio keeps its palette, since Eagle's analyser
370/// skips audio. Returns false, with `item` unchanged, when nothing could be
371/// drawn.
372pub(crate) fn redraw(item: &mut Item, raw: &Path, thumb: &Path) -> bool {
373    // A text item has no thumbnail; its card is drawn from its text.
374    if item.ext == "txt" {
375        return set_text(item, raw);
376    }
377    let tmp = thumb.with_file_name(".falcon-redraw_thumbnail.png");
378    let _ = fs::remove_file(&tmp);
379    let mut next = item.clone();
380    let Some(analysis) = generate(&mut next, raw, &tmp) else {
381        let _ = fs::remove_file(&tmp);
382        return false;
383    };
384    if media::is_audio(&next.ext) {
385        // Eagle's refresh takes the tempo again (`generated.bpm`) and drops
386        // the key when no beat is found. An existing key keeps its place.
387        match tempo::bpm(raw, &next.ext) {
388            Some(bpm) => {
389                next.extra.insert("bpm".into(), tempo::json_value(bpm));
390            }
391            None => {
392                next.extra.shift_remove("bpm");
393            }
394        }
395    } else {
396        let img = match analysis {
397            Analysis::Original => thumbnail::decode(raw).ok(),
398            Analysis::Thumbnail => thumbnail::decode(&tmp).ok(),
399        };
400        next.palettes = img.map(|i| palette::extract(&i)).unwrap_or_default();
401    }
402    let keep = tmp.is_file() && next.remove_thumbnail != Some(true);
403    if keep {
404        if fs::rename(&tmp, thumb).is_err() {
405            let _ = fs::remove_file(&tmp);
406            return false;
407        }
408    } else {
409        let _ = fs::remove_file(&tmp);
410        let _ = fs::remove_file(thumb);
411    }
412    *item = next;
413    true
414}
415
416/// Reads the start of a text item's file into its record (see
417/// [`crate::text`]). False, with `item` unchanged, when the file can't be
418/// read.
419fn set_text(item: &mut Item, raw: &Path) -> bool {
420    match crate::text::preview(raw) {
421        Ok(text) => {
422            item.extra.insert(crate::text::TEXT_KEY.into(), text.into());
423            true
424        }
425        Err(_) => false,
426    }
427}
428
429/// Where the palette should be computed from.
430enum Analysis {
431    Original,
432    Thumbnail,
433}
434
435/// Sets width, height and the thumbnail flags on `item` and writes the
436/// thumbnail. A decode or render failure leaves the item icon-only: no flags,
437/// no thumbnail, no palette (the `None` result).
438fn generate(item: &mut Item, raw: &Path, thumb: &Path) -> Option<Analysis> {
439    let max = thumbnail::max_side(&item.ext);
440    let apply = |item: &mut Item, plan: thumbnail::Plan| {
441        item.no_thumbnail = plan.no_thumbnail;
442        item.remove_thumbnail = plan.remove_thumbnail;
443    };
444    if item.ext == "svg" {
445        let data = fs::read(raw).ok()?;
446        let rendered = thumbnail::svg_size(&data).and_then(|(w, h)| {
447            let plan = thumbnail::plan_svg(item.size);
448            // A small SVG's thumbnail is temporary, so it is kept at full size.
449            let limit = (plan.remove_thumbnail != Some(true)).then_some(max);
450            let img = thumbnail::render_svg(&data, limit)?;
451            thumbnail::write_encoded(&img, thumb)?;
452            Ok((w, h, plan))
453        });
454        return match rendered {
455            Ok((w, h, plan)) => {
456                item.width = w.into();
457                item.height = h.into();
458                apply(item, plan);
459                Some(Analysis::Thumbnail)
460            }
461            Err(_) => {
462                apply(item, thumbnail::plan_svg_failed());
463                None
464            }
465        };
466    }
467    if let Some((img, w, h)) = drawn_thumbnail(&item.ext, raw) {
468        thumbnail::write_thumbnail(img, max, thumb).ok()?;
469        item.width = w.into();
470        item.height = h.into();
471        return Some(Analysis::Thumbnail);
472    }
473    if item.ext == "mhtml" {
474        return mhtml_thumbnail(item, raw, thumb, max);
475    }
476    if !thumbnail::is_supported(&item.ext) {
477        // Video macOS can't read goes to FFmpeg first; other video only
478        // when QuickLook fails, as Eagle tries its own player before mpv.
479        if media::is_video(&item.ext) && falcon_ffmpeg::handles(&item.ext) {
480            return ffmpeg_thumbnail(item, raw, thumb).or_else(|| quicklook_thumbnail(item, raw, thumb, max));
481        }
482        if media::is_video(&item.ext) {
483            return quicklook_thumbnail(item, raw, thumb, max).or_else(|| ffmpeg_thumbnail(item, raw, thumb));
484        }
485        return quicklook_thumbnail(item, raw, thumb, max);
486    }
487    let img = thumbnail::decode_for_display(raw).ok()?;
488    item.width = img.width().into();
489    item.height = img.height().into();
490    let plan = thumbnail::plan_raster(&item.ext, img.width(), img.height(), item.size);
491    if !plan.thumbnail {
492        apply(item, plan);
493        return Some(Analysis::Original);
494    }
495    if thumbnail::write_thumbnail(img, max, thumb).is_err() {
496        let _ = fs::remove_file(thumb);
497        return None;
498    }
499    apply(item, plan);
500    Some(Analysis::Thumbnail)
501}
502
503/// Thumbnails falcon draws itself, with the size Eagle records for them:
504/// a 280×140 waveform for audio and a 600×600 specimen for fonts. `None`
505/// for other formats, or when drawing fails (QuickLook is tried next).
506fn drawn_thumbnail(ext: &str, raw: &Path) -> Option<(DynamicImage, u32, u32)> {
507    if media::is_audio(ext) {
508        let img = waveform::thumbnail(raw, ext)?;
509        return Some((DynamicImage::ImageRgba8(img), waveform::WIDTH, waveform::HEIGHT));
510    }
511    if FONT_EXTENSIONS.contains(&ext) {
512        let data = fs::read(raw).ok()?;
513        let metas = fontmeta::font_metas(&data, "")?;
514        let img = fontpreview::render(&data, &metas)?;
515        return Some((img, fontpreview::SIZE, fontpreview::SIZE));
516    }
517    if crate::model3d::is_supported(ext) {
518        // Eagle draws models itself; on failure QuickLook gets its turn.
519        let img = crate::model3d::raster::thumbnail(raw, ext)?;
520        let (w, h) = img.dimensions();
521        return Some((DynamicImage::ImageRgba8(img), w, h));
522    }
523    None
524}
525
526/// Formats falcon can't decode itself (PDF, video, PSD, AI, HEIC, RAW, ...)
527/// get a QuickLook render as their thumbnail, as Eagle renders them with its
528/// own viewers. Sizes come from Spotlight when it knows them, else from the
529/// render (Eagle likewise reports a PDF's render size); videos also get a
530/// `duration` in seconds.
531fn quicklook_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
532    // Eagle shows text files as text and never thumbnails them. A bookmark
533    // has no QuickLook generator, which would wait out its whole timeout on
534    // the import thread; its picture comes from its page (falcon-app).
535    if matches!(item.ext.as_str(), "txt" | "url") {
536        return None;
537    }
538    // QuickLook can hang for its whole timeout on a format with no generator,
539    // so a preview the file itself carries is tried first for these.
540    if embedded_first(&item.ext)
541        && let Some(done) = embedded_thumbnail(item, raw, thumb, max)
542    {
543        return Some(done);
544    }
545    let Ok(img) = thumbnail::quicklook(raw, thumbnail::QUICKLOOK_SIZE) else {
546        return (!embedded_first(&item.ext)).then(|| embedded_thumbnail(item, raw, thumb, max)).flatten();
547    };
548    let meta = thumbnail::spotlight(raw);
549    // A PSD's header is the file's own word on its size.
550    let psd = crate::psd::handles(&item.ext).then(|| crate::psd::dimensions(raw)).flatten();
551    // A RAW photo's size is the camera's stated size, as Eagle records it
552    // (its `rawMetas`), not the size of QuickLook's render.
553    let camera = crate::exif::is_raw(&item.ext).then(|| fs::read(raw).ok()).flatten().map(|data| raw_dimensions(&data, raw, (img.width(), img.height())));
554    let (w, h) = psd.or(camera).or(meta.size).unwrap_or((img.width(), img.height()));
555    item.width = w.into();
556    item.height = h.into();
557    thumbnail::write_thumbnail(img, max, thumb).ok()?;
558    Some(Analysis::Thumbnail)
559}
560
561/// Formats whose own preview is tried before QuickLook, which rarely has a
562/// generator for them and can then wait out its whole timeout.
563pub fn embedded_first(ext: &str) -> bool {
564    matches!(ext, "blend" | "dwg" | "skp" | "indd" | "indt" | "idml" | "sketch" | "xmind")
565}
566
567/// The thumbnail of a file QuickLook can't render, from what the file
568/// carries: a RAW photo's embedded preview (turned upright), the preview in
569/// a Sketch, XMind, Keynote or IDML zip, the first page of an Illustrator
570/// file's PDF, or the preview in an EPS, InDesign, Blender, SketchUp or
571/// AutoCAD file. `None` for other formats, and for files with no preview.
572fn embedded_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
573    let (img, size) = if crate::exif::is_raw(&item.ext) {
574        let data = fs::read(raw).ok()?;
575        let img = thumbnail::raw_preview(&data).ok()?;
576        let size = raw_dimensions(&data, raw, (img.width(), img.height()));
577        (img, size)
578    } else if matches!(item.ext.as_str(), "ai" | "ait") && let Some(page) = pdf_page(raw) {
579        page
580    } else if crate::binary_preview::handles(&item.ext) {
581        let img = crate::binary_preview::preview(raw, &item.ext)?;
582        let size = (img.width(), img.height());
583        (img, size)
584    } else if crate::zip_preview::handles(&item.ext) {
585        let img = crate::zip_preview::preview(raw, &item.ext)?;
586        let size = (img.width(), img.height());
587        (img, size)
588    } else {
589        return None;
590    };
591    thumbnail::write_thumbnail(img, max, thumb).ok()?;
592    item.width = size.0.into();
593    item.height = size.1.into();
594    Some(Analysis::Thumbnail)
595}
596
597/// Page 1 of an Illustrator file saved PDF-compatible, drawn by PDFKit, with
598/// the page's size in points. `None` when the file is not a PDF inside (older
599/// files are PostScript) or PDFKit can't draw it.
600fn pdf_page(raw: &Path) -> Option<(DynamicImage, (u32, u32))> {
601    let mut head = Vec::new();
602    fs::File::open(raw).ok()?.take(1024).read_to_end(&mut head).ok()?;
603    if !head.windows(5).any(|w| w == b"%PDF-") {
604        return None;
605    }
606    let doc = falcon_pdf::Document::open(raw).ok()?;
607    let bitmap = doc.thumbnail(0, thumbnail::QUICKLOOK_SIZE as usize).ok()?;
608    let (pw, ph) = doc.page_size(0).unwrap_or((bitmap.width as f64, bitmap.height as f64));
609    let img = image::RgbaImage::from_raw(u32::try_from(bitmap.width).ok()?, u32::try_from(bitmap.height).ok()?, bitmap.rgba)?;
610    Some((DynamicImage::ImageRgba8(img), (pw.round().clamp(1.0, 1e6) as u32, ph.round().clamp(1.0, 1e6) as u32)))
611}
612
613/// The displayed size of a RAW photo: the camera's own pixel size, turned by
614/// the orientation. It is the largest size the file states (EXIF or TIFF
615/// tags), when that is at least the preview's; else Spotlight's; else the
616/// preview's `fallback`.
617fn raw_dimensions(data: &[u8], raw: &Path, fallback: (u32, u32)) -> (u32, u32) {
618    let area = |s: (u32, u32)| u64::from(s.0) * u64::from(s.1);
619    let preview = area(fallback);
620    // The preview is turned already, so compare its long side only.
621    let long = fallback.0.max(fallback.1);
622    let stated = crate::exif::stated_sizes(data).into_iter().filter(|&(w, h)| w.max(h) >= long && area((w, h)) >= preview).max_by_key(|&s| area(s));
623    if let Some((w, h)) = stated {
624        let turned = crate::exif::orientation(data).is_some_and(|o| (5..=8).contains(&o));
625        return if turned { (h, w) } else { (w, h) };
626    }
627    thumbnail::spotlight(raw).size.filter(|&s| s.0.max(s.1) >= long).unwrap_or(fallback)
628}
629
630#[cfg(test)]
631mod raw_size_tests {
632    use super::raw_dimensions;
633    use std::path::Path;
634
635    /// A little-endian TIFF whose first IFD states ImageWidth and ImageLength.
636    fn tiff_stating(w: u16, h: u16) -> Vec<u8> {
637        let mut t = vec![b'I', b'I', 42, 0, 8, 0, 0, 0, 2, 0];
638        for (tag, v) in [(0x0100u16, w), (0x0101, h)] {
639            t.extend_from_slice(&tag.to_le_bytes());
640            t.extend_from_slice(&3u16.to_le_bytes());
641            t.extend_from_slice(&1u32.to_le_bytes());
642            t.extend_from_slice(&v.to_le_bytes());
643            t.extend_from_slice(&[0, 0]);
644        }
645        t.extend_from_slice(&0u32.to_le_bytes());
646        t
647    }
648
649    #[test]
650    fn a_quicklook_render_does_not_decide_a_raw_photos_size() {
651        let data = tiff_stating(6000, 4000);
652        assert_eq!(raw_dimensions(&data, Path::new("/nonexistent/x.nef"), (1024, 683)), (6000, 4000));
653        // Nothing stated: the render stays the fallback.
654        assert_eq!(raw_dimensions(&[0u8; 16], Path::new("/nonexistent/x.nef"), (1024, 683)), (1024, 683));
655    }
656}
657
658/// The size Eagle records for a web archive: the 1440 × 900 window its
659/// thumbnailer captures the page in.
660pub const MHTML_SIZE: (u32, u32) = (1440, 900);
661
662/// An MHTML archive's thumbnail. QuickLook has no renderer for MHTML, so
663/// the archive is unpacked into a temporary folder (removed afterwards)
664/// and QuickLook renders the page there as it renders an HTML file; the
665/// top of the render, cut to Eagle's 16:10, is the thumbnail, and the item
666/// gets Eagle's 1440 × 900.
667fn mhtml_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
668    let dir = tempfile::Builder::new().prefix("falcon-mhtml-").tempdir().ok()?;
669    let unpacked = crate::mhtml::unpack_file(raw, dir.path(), crate::mhtml::Limits::default(), crate::mhtml::Policy::None).ok()?;
670    let img = thumbnail::quicklook(&unpacked.root, thumbnail::QUICKLOOK_SIZE).ok()?;
671    let (w, h) = (img.width(), img.height());
672    let (ew, eh) = MHTML_SIZE;
673    let cut = (u64::from(w) * u64::from(eh) / u64::from(ew)) as u32;
674    let img = if cut > 0 && cut < h { img.crop_imm(0, 0, w, cut) } else { img };
675    thumbnail::write_thumbnail(img, max, thumb).ok()?;
676    item.width = ew.into();
677    item.height = eh.into();
678    Some(Analysis::Thumbnail)
679}
680
681/// A video's thumbnail through the FFmpeg fallback, as Eagle's
682/// thumbnailer makes one through its bundled player: the picture ten
683/// seconds in (a third of the way through a shorter clip), or a later one
684/// when that is black, written per [`thumbnail::video_thumbnail_jpeg`]
685/// (a 480-wide JPEG). `None` without FFmpeg or when it can't decode the file.
686fn ffmpeg_thumbnail(item: &mut Item, raw: &Path, thumb: &Path) -> Option<Analysis> {
687    if !falcon_ffmpeg::available() {
688        return None;
689    }
690    let (info, frame, _) = falcon_ffmpeg::thumbnail(raw).ok()?;
691    let img = image::RgbaImage::from_raw(frame.width as u32, frame.height as u32, frame.rgba)?;
692    let bytes = thumbnail::video_thumbnail_jpeg(&img).ok()?;
693    fs::write(thumb, bytes).ok()?;
694    item.width = info.width.into();
695    item.height = info.height.into();
696    Some(Analysis::Thumbnail)
697}
698
699/// Format-specific keys Eagle's thumbnail modules add, appended to the item
700/// in the order Eagle assigns them: `rawMetas` for RAW photos,
701/// `resolutionWidth`, `resolutionHeight` and `duration` for video,
702/// `duration` then `bpm` (when a beat is found) for audio, and `fontMetas`
703/// for fonts. A `.url` file's address becomes the item's URL so
704/// the viewer can open it; a URL given in the options wins.
705fn file_metadata(item: &mut Item, raw: &Path, opts: &ImportOptions) {
706    let ext = item.ext.clone();
707    if item.url.is_empty() && weblink::is_link_file(&ext) {
708        item.url = weblink::read(raw).unwrap_or_default();
709    }
710    if (exif::is_raw(&ext) || (opts.camera_metadata_all_photos && exif::EXIF_EXTENSIONS.contains(&ext.as_str())))
711        && let Some(meta) = exif::read(raw)
712    {
713        item.extra.insert("rawMetas".into(), Value::Object(meta.to_raw_metas()));
714    }
715    if media::is_video(&ext) {
716        item.extra.insert("resolutionWidth".into(), json!(item.width));
717        item.extra.insert("resolutionHeight".into(), json!(item.height));
718    }
719    if (media::is_video(&ext) || media::is_audio(&ext))
720        && let Some(d) = media::duration(raw, &ext).or_else(|| thumbnail::spotlight(raw).duration)
721    {
722        item.extra.insert("duration".into(), json!(d));
723    }
724    if media::is_audio(&ext)
725        && let Some(bpm) = tempo::bpm(raw, &ext)
726    {
727        item.extra.insert("bpm".into(), tempo::json_value(bpm));
728    }
729    if FONT_EXTENSIONS.contains(&ext.as_str())
730        && let Some(metas) = fs::read(raw).ok().and_then(|data| fontmeta::font_metas(&data, &ids::new_id()))
731    {
732        if opts.font_weight_tag
733            && let Some(w) = metas.get("weight").and_then(|w| w.as_str())
734        {
735            item.tags.push(w.to_string());
736        }
737        item.extra.insert("fontMetas".into(), Value::Object(metas));
738    }
739}
740
741/// Appends keywords to the tags. As in Eagle, when there are keywords the
742/// whole list is then de-duplicated, keeping first occurrences.
743fn add_keyword_tags(tags: &mut Vec<String>, keywords: Vec<String>) {
744    if keywords.is_empty() {
745        return;
746    }
747    let mut seen = std::collections::HashSet::new();
748    let all: Vec<String> = tags.drain(..).chain(keywords).filter(|t| seen.insert(t.clone())).collect();
749    *tags = all;
750}
751
752/// Font formats whose metadata falcon reads.
753pub const FONT_EXTENSIONS: [&str; 5] = ["ttf", "otf", "ttc", "woff", "woff2"];
754
755/// Turns a file stem into an item name: forbidden characters removed, trimmed,
756/// NFC-normalised, shortened to fit the file system, `_` if nothing is left.
757pub(crate) fn sanitize_name(stem: &str, ext: &str) -> String {
758    let cleaned: String = stem
759        .chars()
760        .filter(|c| !c.is_control() && !"/?<>\\:*|\"%".contains(*c))
761        .collect();
762    let cleaned: String = cleaned.nfc().collect();
763    let cleaned = cleaned.trim().trim_end_matches('.').trim();
764    let room = MAX_FILE_NAME.saturating_sub(THUMB_SUFFIX.len().max(ext.len() + 1));
765    let mut out = String::new();
766    for c in cleaned.chars() {
767        if out.len() + c.len_utf8() > room {
768            break;
769        }
770        out.push(c);
771    }
772    let out = out.trim().to_string();
773    #[cfg(windows)]
774    if crate::disk_name::is_device(out.split('.').next().unwrap_or(&out)) {
775        return format!("{out}_");
776    }
777    if out.is_empty() || out == "." || out == ".." { "_".into() } else { out }
778}
779
780/// Copies a file's size, birth time and mtime (ms) onto `item`, as Eagle
781/// re-reads them before refreshing a thumbnail.
782pub(crate) fn restat(item: &mut Item, path: &Path) -> Result<()> {
783    let meta = fs::metadata(path).map_err(|e| Error::io(path, e))?;
784    item.size = meta.len();
785    item.mtime = to_ms(meta.modified().ok());
786    item.btime = meta.created().ok().map_or(item.mtime, |t| to_ms(Some(t)));
787    Ok(())
788}
789
790fn to_ms(t: Option<SystemTime>) -> u64 {
791    t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as u64)
792}
793
794fn now_ms() -> u64 {
795    to_ms(Some(SystemTime::now()))
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    fn import(file: &str, bytes: &[u8], opts: &ImportOptions) -> Item {
803        import_with_thumb(file, bytes, opts).0
804    }
805
806    #[test]
807    fn a_given_name_replaces_the_file_name_and_keeps_the_extension() {
808        let named = ImportOptions { name: Some(" My: note ".into()), ..Default::default() };
809        let item = import("raw.txt", b"hi", &named);
810        assert_eq!((item.name.as_str(), item.ext.as_str()), ("My note", "txt"));
811        assert_eq!(import("raw.txt", b"hi", &ImportOptions::default()).name, "raw");
812    }
813
814    #[test]
815    fn items_from_another_app_keep_their_trash_state_and_date() {
816        let opts = ImportOptions { trashed: true, added_at: Some(1_520_139_967_000), ..Default::default() };
817        let item = import("old.txt", b"hi", &opts);
818        assert!(item.is_deleted);
819        assert_eq!(item.modification_time, 1_520_139_967_000);
820        assert!(!import("new.txt", b"hi", &ImportOptions::default()).is_deleted);
821    }
822
823    /// Imports into a fresh library; also returns the thumbnail's size.
824    fn import_with_thumb(file: &str, bytes: &[u8], opts: &ImportOptions) -> (Item, Option<(u32, u32)>) {
825        let tmp = tempfile::tempdir().unwrap();
826        let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
827        let src = tmp.path().join(file);
828        fs::write(&src, bytes).unwrap();
829        let id = lib.import_file(&src, opts).unwrap();
830        let item = lib.item(&id).unwrap().clone();
831        let thumb = item.thumbnail_path(lib.root()).and_then(|p| thumbnail::decode(&p).ok()).map(|i| (i.width(), i.height()));
832        (item, thumb)
833    }
834
835    fn keys(item: &Item) -> Vec<&str> {
836        item.extra.keys().map(|k| k.as_str()).collect()
837    }
838
839    /// Runs only the metadata step: QuickLook can stall on the tiny made-up
840    /// TIFF, and the thumbnail doesn't matter here.
841    fn metadata_only(file: &str, bytes: &[u8], opts: &ImportOptions) -> Item {
842        let tmp = tempfile::tempdir().unwrap();
843        let src = tmp.path().join(file);
844        fs::write(&src, bytes).unwrap();
845        let mut item: Item = serde_json::from_value(json!({
846            "id": "", "name": "shot", "size": bytes.len(), "btime": 0, "mtime": 0, "ext": src.extension().unwrap().to_str(),
847            "tags": [], "folders": [], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0,
848            "height": 0, "width": 0, "lastModified": 0, "palettes": []
849        }))
850        .unwrap();
851        file_metadata(&mut item, &src, opts);
852        item
853    }
854
855    #[test]
856    fn link_files_set_the_item_url() {
857        let none = ImportOptions::default();
858        let url = metadata_only("go.url", b"[InternetShortcut]\r\nURL=https://example.com/a\r\n", &none);
859        assert_eq!(url.url, "https://example.com/a");
860        let xml = b"<plist><dict><key>URL</key><string>https://example.com/w</string></dict></plist>";
861        assert_eq!(metadata_only("go.webloc", xml, &none).url, "", "a .webloc is a plain item, as in Eagle");
862        assert_eq!(metadata_only("empty.url", b"[InternetShortcut]\n", &none).url, "");
863        assert_eq!(metadata_only("note.txt", b"[InternetShortcut]\nURL=https://example.com/\n", &none).url, "");
864
865        // An item that already has a URL (from `opts.url`) keeps it.
866        let tmp = tempfile::tempdir().unwrap();
867        let src = tmp.path().join("go.url");
868        fs::write(&src, "[InternetShortcut]\nURL=https://example.com/a\n").unwrap();
869        let mut item = metadata_only("x.txt", b"hi", &none);
870        item.ext = "url".into();
871        item.url = "https://given.example/".into();
872        let given = ImportOptions { url: item.url.clone(), ..Default::default() };
873        file_metadata(&mut item, &src, &given);
874        assert_eq!(item.url, "https://given.example/");
875    }
876
877    #[test]
878    fn failures_are_classified() {
879        let tmp = tempfile::tempdir().unwrap();
880        let opts = ImportOptions { palette: false, ..Default::default() };
881        let fail = |p: &Path| Failure::new(p, &stage(p, &opts).unwrap_err());
882
883        let missing = tmp.path().join("gone.png");
884        assert_eq!(fail(&missing).kind, FailKind::Missing);
885        assert_eq!(fail(tmp.path()).kind, FailKind::NotAFile);
886        let empty = tmp.path().join("empty.png");
887        fs::write(&empty, b"").unwrap();
888        let f = fail(&empty);
889        assert_eq!((f.kind, f.path.as_path()), (FailKind::Empty, empty.as_path()));
890        assert!(f.detail.contains("empty"), "{}", f.detail);
891
892        for name in ["README", "movie.mp4.crdownload", "song.part", "page.download"] {
893            let p = tmp.path().join(name);
894            fs::write(&p, b"data").unwrap();
895            assert_eq!(fail(&p).kind, FailKind::Unrecognized, "{name}");
896        }
897        // Unknown but present extensions import, as icon-only items.
898        let odd = tmp.path().join("notes.xyz123");
899        fs::write(&odd, b"data").unwrap();
900        assert!(stage(&odd, &opts).is_ok());
901
902        let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
903        let ok = tmp.path().join("a.txt");
904        fs::write(&ok, b"hi").unwrap();
905        let bad = ImportOptions { folders: vec!["NOPE".into()], ..opts.clone() };
906        let err = lib.import_file(&ok, &bad).unwrap_err();
907        assert_eq!(Failure::new(&ok, &err).kind, FailKind::UnknownFolder);
908
909        let full = Error::io(tmp.path().join("falcon-importXYZ/a.png"), std::io::Error::from(std::io::ErrorKind::StorageFull));
910        assert_eq!(Failure::new(&ok, &full).kind, FailKind::NoSpace);
911        let copy = Error::io(tmp.path().join("falcon-importXYZ/a.png"), std::io::Error::other("boom"));
912        assert_eq!(Failure::new(&ok, &copy).kind, FailKind::CopyFailed);
913        let write = Error::io(tmp.path().join("T.library/images/X.info"), std::io::Error::other("boom"));
914        assert_eq!(Failure::new(&ok, &write).kind, FailKind::LibraryWrite);
915        #[cfg(unix)]
916        {
917            use std::os::unix::fs::PermissionsExt;
918            let locked = tmp.path().join("locked.png");
919            fs::write(&locked, b"x").unwrap();
920            fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
921            // Root can read anything; only check when the file is unreadable.
922            if fs::read(&locked).is_err() {
923                assert_eq!(fail(&locked).kind, FailKind::Unreadable);
924            }
925        }
926        // Windows' way of a file that can't be read: another program has it
927        // open and shares it with nobody.
928        #[cfg(windows)]
929        {
930            use std::os::windows::fs::OpenOptionsExt;
931            let locked = tmp.path().join("locked.png");
932            fs::write(&locked, b"x").unwrap();
933            let held = fs::OpenOptions::new().read(true).share_mode(0).open(&locked).unwrap();
934            assert_eq!(fail(&locked).kind, FailKind::Unreadable);
935            drop(held);
936        }
937    }
938
939    #[test]
940    fn raw_photo_gets_raw_metas() {
941        let opts = ImportOptions { palette: false, ..Default::default() };
942        let item = metadata_only("shot.nef", &crate::exif::tests::tiff_with_exif(), &opts);
943        let m = &item.extra["rawMetas"];
944        assert_eq!(m["camera"], "Nikon D850");
945        assert_eq!(m["isoSpeed"], 400);
946        assert_eq!(m["shutter"], "1/250.0 sec");
947        assert_eq!(m["aperture"], "f/2.8");
948        assert_eq!(m["focalLength"], "50.0 mm");
949        // A TIFF only gets them when asked.
950        let item = metadata_only("shot.tif", &crate::exif::tests::tiff_with_exif(), &opts);
951        assert!(!item.extra.contains_key("rawMetas"));
952        let all = ImportOptions { camera_metadata_all_photos: true, ..opts };
953        let item = metadata_only("shot.tif", &crate::exif::tests::tiff_with_exif(), &all);
954        assert!(item.extra.contains_key("rawMetas"));
955    }
956
957    #[test]
958    fn keywords_become_tags_when_asked() {
959        let png = crate::keywords::tests::png_with_xmp(true);
960        let opts = ImportOptions { palette: false, tags: vec!["beach".into(), "mine".into()], ..Default::default() };
961        assert_eq!(import("k.png", &png, &opts).tags, ["beach", "mine"]);
962        let on = ImportOptions { keywords_as_tags: true, ..opts };
963        assert_eq!(import("k.png", &png, &on).tags, ["beach", "mine", "sunset", "sea & sky"]);
964    }
965
966    #[test]
967    fn audio_gets_duration() {
968        let opts = ImportOptions { palette: false, ..Default::default() };
969        let (item, thumb) = import_with_thumb("tone.wav", &crate::media::tests::wav(8000, 1.25, |t| (t * 300.0).sin()), &opts);
970        assert_eq!(item.extra["duration"], 1.25);
971        assert_eq!((item.width, item.height), (280, 140));
972        assert_eq!(thumb, Some((280, 140)), "the waveform is the thumbnail");
973        assert!(!item.extra.contains_key("bpm"), "a steady tone has no beat");
974    }
975
976    #[test]
977    fn video_macos_cannot_read_is_thumbnailed_through_ffmpeg() {
978        if !falcon_ffmpeg::available() {
979            eprintln!("skipped: FFmpeg is not installed (scripts/build-ffmpeg.sh)");
980            return;
981        }
982        // Two seconds of 64×48 frames, black for the first one: Eagle's
983        // spot (a third of the way in) is black, so a later frame is used.
984        let clip = falcon_ffmpeg::testing::Clip { width: 64, height: 48, frames: 50, fps: 25, audio_rate: Some(8000) };
985        let avi = clip.avi(|i, _| if i < 25 { [0, 0, 0] } else { [200, 40, 40] });
986        let (item, thumb) = import_with_thumb("clip.avi", &avi, &ImportOptions::default());
987        assert_eq!((item.width, item.height), (64, 48));
988        assert_eq!(thumb, Some((480, 360)), "a 480-wide thumbnail as Eagle draws");
989        assert!((item.extra["duration"].as_f64().unwrap() - 2.0).abs() < 0.05, "{}", item.extra["duration"]);
990        assert_eq!((item.extra["resolutionWidth"].clone(), item.extra["resolutionHeight"].clone()), (json!(64), json!(48)));
991        let reddish = item.palettes.first().is_some_and(|p| p.color[0] > 150 && p.color[1] < 90);
992        assert!(reddish, "the thumbnail is the first non-black frame: {:?}", item.palettes.first().map(|p| p.color));
993    }
994
995    #[test]
996    fn av1_video_is_thumbnailed_through_dav1d() {
997        if !falcon_ffmpeg::ffi::api().is_ok_and(|api| falcon_ffmpeg::ffi::has_decoder(api, "libdav1d")) {
998            eprintln!("skipped: FFmpeg with dav1d is not installed (scripts/build-ffmpeg.sh)");
999            return;
1000        }
1001        // falcon-ffmpeg's AV1 fixture (a second of flat 64×48 frames, frame
1002        // `i` coloured (10 + 7i, 100, 200)) rewrapped in each container.
1003        // WebM and Matroska go to FFmpeg first; MP4 to QuickLook first and
1004        // FFmpeg when QuickLook can't read AV1.
1005        let ivf = include_bytes!("../../falcon-ffmpeg/testdata/av1.ivf");
1006        let clip = falcon_ffmpeg::testing::av1::Av1Clip::from_ivf(ivf).unwrap();
1007        for (name, bytes) in [("clip.webm", clip.matroska(true)), ("clip.mkv", clip.matroska(false)), ("clip.mp4", clip.mp4())] {
1008            let (item, thumb) = import_with_thumb(name, &bytes, &ImportOptions::default());
1009            assert_eq!((item.width, item.height), (64, 48), "{name}");
1010            assert!((item.extra["duration"].as_f64().unwrap() - 1.0).abs() < 0.05, "{name}: {}", item.extra["duration"]);
1011            let c = item.palettes.first().map(|p| p.color).unwrap();
1012            assert!(c[1].abs_diff(100) < 16 && c[2].abs_diff(200) < 16, "{name}: a frame of the clip, not black or an icon: {c:?}");
1013            if name.ends_with(".mp4") && thumb != Some((480, 360)) {
1014                // QuickLook read it (macOS 14+ decodes AV1 on Apple silicon
1015                // with an AV1 decoder) and chose its own frame and size.
1016                assert!(thumb.is_some(), "{name}");
1017                continue;
1018            }
1019            assert_eq!(thumb, Some((480, 360)), "{name}: a 480-wide thumbnail as Eagle draws");
1020            // Eagle's spot, a third of the way in, is frame 8: (66, 100, 200).
1021            assert!(c[0].abs_diff(66) < 16, "{name}: {c:?}");
1022        }
1023    }
1024
1025    #[test]
1026    fn audio_with_a_beat_gets_bpm_after_duration() {
1027        let opts = ImportOptions { palette: false, ..Default::default() };
1028        let click = |t: f64| { let s = t % 0.5; if s < 0.004 { 0.8 * (s * 9000.0).sin() } else { 0.0 } };
1029        let item = metadata_only("beat.wav", &crate::media::tests::wav(22050, 12.0, click), &opts);
1030        let bpm = item.extra["bpm"].as_f64().unwrap();
1031        assert!((bpm - 120.0).abs() <= 1.0, "{bpm}");
1032        let keys: Vec<&str> = item.extra.keys().map(String::as_str).collect();
1033        assert_eq!(keys, ["duration", "bpm"]);
1034    }
1035
1036    #[test]
1037    fn text_files_keep_their_text_in_eagles_record_layout() {
1038        let tmp = tempfile::tempdir().unwrap();
1039        let root = tmp.path().join("T.library");
1040        let mut lib = Library::create(&root).unwrap();
1041        let src = tmp.path().join("Notes.txt");
1042        fs::write(&src, "Line one\nLine two\n").unwrap();
1043        let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
1044        let item = lib.item(&id).unwrap().clone();
1045        assert_eq!(item.extra["text"], "Line one\nLine two\n");
1046        assert!(item.thumbnail_path(lib.root()).is_none_or(|p| !p.exists()), "no thumbnail file");
1047        let json: Value = serde_json::from_slice(&fs::read(item.info_dir(lib.root()).join("metadata.json")).unwrap()).unwrap();
1048        let written: Vec<&str> = json.as_object().unwrap().keys().map(|k| k.as_str()).collect();
1049        assert_eq!(written, crate::text::RECORD_KEYS);
1050
1051        // An empty file (File ▸ New ▸ Text File) gets an empty text.
1052        let empty = tmp.path().join("Untitled.txt");
1053        fs::write(&empty, "").unwrap();
1054        let id2 = lib.import_file(&empty, &ImportOptions::default()).unwrap();
1055        assert_eq!(lib.item(&id2).unwrap().extra["text"], "");
1056
1057        // Refresh Thumbnail reads the text again, also for an item that
1058        // had none (imported by an older falcon).
1059        let raw = item.original_path(lib.root());
1060        fs::write(&raw, "Rewritten").unwrap();
1061        let mut stale = lib.item(&id).unwrap().clone();
1062        stale.extra.shift_remove("text");
1063        lib.update_item(stale).unwrap();
1064        assert!(lib.refresh_thumbnail(&id).unwrap());
1065        assert_eq!(Library::open(&root).unwrap().item(&id).unwrap().extra["text"], "Rewritten");
1066    }
1067
1068    #[test]
1069    fn refresh_thumbnail_takes_the_tempo_again() {
1070        let tmp = tempfile::tempdir().unwrap();
1071        let root = tmp.path().join("T.library");
1072        let mut lib = Library::create(&root).unwrap();
1073        let src = tmp.path().join("beat.wav");
1074        let click = |t: f64| { let s = t % 0.5; if s < 0.004 { 0.8 * (s * 9000.0).sin() } else { 0.0 } };
1075        fs::write(&src, crate::media::tests::wav(22050, 12.0, click)).unwrap();
1076        let opts = ImportOptions { palette: false, ..Default::default() };
1077        let id = lib.import_file(&src, &opts).unwrap();
1078        // A stale tempo, with a key after it whose place must not change.
1079        let mut item = lib.item(&id).unwrap().clone();
1080        item.extra.insert("bpm".into(), json!(90.5));
1081        item.extra.insert("star".into(), json!(3));
1082        lib.update_item(item).unwrap();
1083        let mut lib = Library::open(&root).unwrap();
1084        assert!(lib.refresh_thumbnail(&id).unwrap());
1085        let lib = Library::open(&root).unwrap();
1086        let item = lib.item(&id).unwrap();
1087        let bpm = item.extra["bpm"].as_f64().unwrap();
1088        assert!((bpm - 120.0).abs() <= 1.0, "{bpm}");
1089        assert_eq!(keys(item), ["duration", "bpm", "star"]);
1090
1091        // Without a beat the key goes, as Eagle sets it to undefined.
1092        let raw = item.original_path(lib.root());
1093        fs::write(&raw, crate::media::tests::wav(8000, 1.25, |t| (t * 300.0).sin())).unwrap();
1094        let mut lib = Library::open(&root).unwrap();
1095        assert!(lib.refresh_thumbnail(&id).unwrap());
1096        assert_eq!(keys(Library::open(&root).unwrap().item(&id).unwrap()), ["duration", "star"]);
1097    }
1098
1099    #[test]
1100    fn font_gets_font_metas_and_weight_tag() {
1101        let Ok(data) = fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
1102        let opts = ImportOptions { palette: false, tags: vec!["mine".into()], ..Default::default() };
1103        let (item, thumb) = import_with_thumb("Arial.ttf", &data, &opts);
1104        assert_eq!((item.width, item.height), (600, 600));
1105        assert_eq!(thumb, Some((320, 320)), "the specimen is scaled to the thumbnail limit");
1106        assert_eq!(item.extra["fontMetas"]["postScriptName"]["en"], "ArialMT");
1107        assert_eq!(item.tags, ["mine", "Regular"]);
1108        assert!(keys(&item).contains(&"fontMetas"));
1109        let item = import("Arial.ttf", &data, &ImportOptions { font_weight_tag: false, ..opts });
1110        assert_eq!(item.tags, ["mine"]);
1111    }
1112
1113    fn jpeg_bytes(w: u32, h: u32) -> Vec<u8> {
1114        let mut out = Vec::new();
1115        DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([180, 40, 20])))
1116            .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Jpeg)
1117            .unwrap();
1118        out
1119    }
1120
1121    /// A little-endian TIFF-based RAW stand-in: IFD0 states `size` and an
1122    /// orientation, and a JPEG preview follows the IFD.
1123    fn tiff_raw(size: (u32, u32), orientation: u16, preview: &[u8]) -> Vec<u8> {
1124        let entry = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
1125        let mut out = b"II*\0\x08\0\0\0".to_vec();
1126        out.extend(3u16.to_le_bytes());
1127        out.extend(entry(0x100, size.0));
1128        out.extend(entry(0x101, size.1));
1129        out.extend(entry(0x112, orientation.into()));
1130        out.extend(0u32.to_le_bytes());
1131        out.extend(preview);
1132        out
1133    }
1134
1135    /// Runs only the embedded-picture step, as for a file QuickLook can't
1136    /// render, and returns the item's size and the thumbnail's.
1137    fn embedded_only(file: &str, bytes: &[u8]) -> Option<((u64, u64), (u32, u32))> {
1138        let tmp = tempfile::tempdir().unwrap();
1139        let src = tmp.path().join(file);
1140        fs::write(&src, bytes).unwrap();
1141        let mut item: Item = serde_json::from_value(json!({
1142            "id": "", "name": "x", "size": bytes.len(), "btime": 0, "mtime": 0, "ext": src.extension().unwrap().to_str(),
1143            "tags": [], "folders": [], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0,
1144            "height": 0, "width": 0, "lastModified": 0, "palettes": []
1145        }))
1146        .unwrap();
1147        let thumb = tmp.path().join("t.png");
1148        embedded_thumbnail(&mut item, &src, &thumb, 320)?;
1149        let img = thumbnail::decode(&thumb).unwrap();
1150        Some(((item.width, item.height), (img.width(), img.height())))
1151    }
1152
1153    /// A one-page PDF of `w` x `h` points with a correct cross-reference table.
1154    fn pdf_bytes(w: u32, h: u32) -> Vec<u8> {
1155        let content = "1 0 0 rg 0 0 50 50 re f";
1156        let objs = [
1157            "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
1158            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
1159            format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R >>"),
1160            format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
1161        ];
1162        let mut out = b"%PDF-1.4\n".to_vec();
1163        let mut offsets = Vec::new();
1164        for (k, body) in objs.iter().enumerate() {
1165            offsets.push(out.len());
1166            out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", k + 1).as_bytes());
1167        }
1168        let xref = out.len();
1169        out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
1170        for o in offsets {
1171            out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
1172        }
1173        out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
1174        out
1175    }
1176
1177    #[cfg(target_os = "macos")]
1178    #[test]
1179    fn pdf_compatible_illustrator_files_draw_page_one() {
1180        for name in ["a.ai", "a.ait"] {
1181            let (dims, thumb) = embedded_only(name, &pdf_bytes(200, 100)).expect(name);
1182            assert_eq!(dims, (200, 100), "{name}: the page's size");
1183            assert!(thumb.0 > thumb.1, "{name}: landscape, {thumb:?}");
1184        }
1185    }
1186
1187    #[test]
1188    fn illustrator_files_with_no_page_fall_back_to_their_postscript_preview() {
1189        let ps = b"%!PS-Adobe-3.0\n%%BeginPreview: 2 2 8 2\n%00ff\n%8040\n%%EndPreview\n";
1190        assert_eq!(embedded_only("old.ai", ps).unwrap().0, (2, 2));
1191        // PostScript with no preview, and a broken PDF: nothing, no panic.
1192        assert!(embedded_only("stub.ai", b"%!PS-Adobe-3.0\n%%Creator: x\n").is_none());
1193        assert!(embedded_only("bad.ai", b"%PDF-1.4\nnot really").is_none());
1194        assert!(embedded_only("empty.ait", b"").is_none());
1195    }
1196
1197    #[test]
1198    fn binary_and_zip_previews_reach_the_thumbnail() {
1199        // A Blender file with one 3 x 2 thumbnail block.
1200        let mut blend = b"BLENDER-v305".to_vec();
1201        let mut body = Vec::new();
1202        body.extend(3u32.to_le_bytes());
1203        body.extend(2u32.to_le_bytes());
1204        body.extend([9u8; 3 * 2 * 4]);
1205        blend.extend(b"TEST");
1206        blend.extend((body.len() as u32).to_le_bytes());
1207        blend.extend([0u8; 16]);
1208        blend.extend(&body);
1209        assert_eq!(embedded_only("a.blend", &blend).unwrap().0, (3, 2));
1210        // Files of the other formats with nothing in them give no thumbnail.
1211        for name in ["a.eps", "a.indd", "a.indt", "a.skp", "a.dwg", "a.idml", "a.key"] {
1212            assert!(embedded_only(name, b"nothing here").is_none(), "{name}");
1213        }
1214    }
1215
1216    #[test]
1217    fn raw_without_a_quicklook_render_uses_its_embedded_preview() {
1218        let preview = jpeg_bytes(96, 64);
1219        // The camera's size is the one the file states, turned by the orientation.
1220        let raw = tiff_raw((6000, 4000), 1, &preview);
1221        let (dims, thumb) = embedded_only("a.nef", &raw).unwrap();
1222        assert_eq!(dims, (6000, 4000));
1223        assert_eq!(thumb, (96, 64));
1224        let (dims, thumb) = embedded_only("a.orf", &tiff_raw((6000, 4000), 6, &preview)).unwrap();
1225        assert_eq!(dims, (4000, 6000));
1226        assert_eq!(thumb, (64, 96), "the thumbnail is upright too");
1227        // A stated size smaller than the preview is a thumbnail's, not the camera's.
1228        let (dims, _) = embedded_only("a.nef", &tiff_raw((32, 21), 1, &preview)).unwrap();
1229        assert_eq!(dims, (96, 64));
1230        // Formats with no stated size use the preview's.
1231        let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec();
1232        raf.resize(100, 0);
1233        raf[84..88].copy_from_slice(&100u32.to_be_bytes());
1234        raf[88..92].copy_from_slice(&(preview.len() as u32).to_be_bytes());
1235        raf.extend_from_slice(&preview);
1236        assert_eq!(embedded_only("a.raf", &raf).unwrap().0, (96, 64));
1237    }
1238
1239    #[test]
1240    fn raw_without_a_preview_or_malformed_gets_no_thumbnail() {
1241        for ext in ["crw", "mrw", "orf", "raf", "raw", "srw", "x3f"] {
1242            let file = format!("bad.{ext}");
1243            assert!(embedded_only(&file, b"").is_none(), "{ext} empty");
1244            assert!(embedded_only(&file, &[0xFF; 300]).is_none(), "{ext} noise");
1245            assert!(embedded_only(&file, &tiff_raw((100, 100), 1, b"no picture here")).is_none(), "{ext} no jpeg");
1246            let jpeg = jpeg_bytes(64, 64);
1247            // A preview cut short, with a header that points past the end.
1248            assert!(embedded_only(&file, &tiff_raw((100, 100), 1, &jpeg[..jpeg.len() / 3])).is_none(), "{ext} cut");
1249        }
1250        let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec();
1251        raf.resize(100, 0);
1252        raf[84..88].copy_from_slice(&u32::MAX.to_be_bytes());
1253        raf[88..92].copy_from_slice(&u32::MAX.to_be_bytes());
1254        assert!(embedded_only("a.raf", &raf).is_none());
1255        assert!(embedded_only("a.x3f", b"FOVb\0\0\0\0\xFF\xFF\xFF\xFF").is_none());
1256        assert!(embedded_only("a.mrw", b"\0MRM\xFF\xFF\xFF\xFF\0TTW\xFF\xFF\xFF\xFF").is_none());
1257    }
1258
1259    #[test]
1260    fn document_previews_become_thumbnails() {
1261        use std::io::Write;
1262        let zipped = |entries: &[(&str, &[u8])]| {
1263            let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1264            for (name, data) in entries {
1265                z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
1266                z.write_all(data).unwrap();
1267            }
1268            z.finish().unwrap().into_inner()
1269        };
1270        let mut png = Vec::new();
1271        DynamicImage::ImageRgb8(image::RgbImage::new(40, 30)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
1272        let sketch = zipped(&[("previews/preview.png", &png)]);
1273        assert_eq!(embedded_only("a.sketch", &sketch), Some(((40, 30), (40, 30))));
1274        assert_eq!(embedded_only("a.xmind", &zipped(&[("Thumbnails/thumbnail.png", &png)])), Some(((40, 30), (40, 30))));
1275        assert_eq!(embedded_only("a.key", &zipped(&[("preview.jpg", &jpeg_bytes(20, 10))])), Some(((20, 10), (20, 10))));
1276        assert_eq!(embedded_only("a.key", &zipped(&[("QuickLook/Thumbnail.jpg", &jpeg_bytes(10, 20))])), Some(((10, 20), (10, 20))));
1277        // Missing, malformed or not a zip: no thumbnail.
1278        assert!(embedded_only("a.sketch", &zipped(&[("document.json", b"{}")])).is_none());
1279        assert!(embedded_only("a.sketch", &zipped(&[("previews/preview.png", b"junk")])).is_none());
1280        assert!(embedded_only("a.xmind", b"not a zip").is_none());
1281        assert!(embedded_only("a.key", &sketch[..sketch.len() - 5]).is_none());
1282        // Other formats are left alone.
1283        assert!(embedded_only("a.pages", &sketch).is_none());
1284    }
1285}