feat(exif): extract capture date and surface as sortable taken_at field

Adds EXIF date extraction during indexing so photos can be sorted by when
they were actually taken, not just when the file was modified on disk.

- Add `taken_at` (nullable TEXT, ISO 8601) column to the images table via
  ensure_column migration and a new idx_images_taken_at index
- Populate taken_at in build_record via extract_exif_date, which tries
  DateTimeOriginal → DateTimeDigitized → DateTime tags and rejects all-zero
  sentinel dates written by uninitialised cameras
- upsert_image uses taken_at = excluded.taken_at so a re-indexed file gets
  fresh EXIF; stale dates from replaced files are not preserved
- Add taken_asc / taken_desc sort using COALESCE(taken_at, created_at) so
  images without EXIF gracefully fall back to their creation date
- Surface as "Taken: newest / oldest" in the Toolbar sort dropdown
- Shift map_image_row column indices after the new taken_at (index 10)
This commit is contained in:
2026-06-08 00:26:36 +01:00
parent 0ca4d142d8
commit 9ee5b08c93
6 changed files with 105 additions and 27 deletions
+44
View File
@@ -425,6 +425,49 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
Ok(())
}
/// Extract the capture date from EXIF metadata, returned as an ISO 8601 string
/// (`"YYYY-MM-DDTHH:MM:SS"`). Tries `DateTimeOriginal` first, then
/// `DateTimeDigitized`, then `DateTime`. Returns `None` for video files or
/// images with no readable EXIF date.
fn extract_exif_date(path: &Path) -> Option<String> {
use exif::{In, Tag, Value};
use std::io::BufReader;
let file = std::fs::File::open(path).ok()?;
let mut reader = BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
for tag in [Tag::DateTimeOriginal, Tag::DateTimeDigitized, Tag::DateTime] {
if let Some(field) = exif.get_field(tag, In::PRIMARY) {
if let Value::Ascii(ref parts) = field.value {
if let Some(bytes) = parts.first() {
// EXIF datetime format: "YYYY:MM:DD HH:MM:SS" (19 bytes)
if bytes.len() >= 19 {
let s = String::from_utf8_lossy(bytes);
// Reject all-zero sentinel dates written by some cameras
// for uninitialised EXIF fields ("0000:00:00 00:00:00").
let year: u32 = s[0..4].parse().unwrap_or(0);
if year == 0 {
continue;
}
let iso = format!(
"{}-{}-{}T{}:{}:{}",
&s[0..4],
&s[5..7],
&s[8..10],
&s[11..13],
&s[14..16],
&s[17..19]
);
return Some(iso);
}
}
}
}
}
None
}
fn build_record(
path: &Path,
folder_id: i64,
@@ -465,6 +508,7 @@ fn build_record(
file_size,
created_at: None,
modified_at,
taken_at: extract_exif_date(path),
mime_type: mime_for_ext(ext).to_string(),
media_kind: media_kind.clone(),
duration_ms: None,