From 9ee5b08c938f2d8525e62fe806df5ae71a2628ef Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 00:26:36 +0100 Subject: [PATCH] feat(exif): extract capture date and surface as sortable taken_at field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/Cargo.lock | 16 ++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/db.rs | 60 +++++++++++++++++++++----------------- src-tauri/src/indexer.rs | 44 ++++++++++++++++++++++++++++ src/components/Toolbar.tsx | 2 ++ src/store.ts | 9 +++++- 6 files changed, 105 insertions(+), 27 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index edd9078..bc2e9f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3113,6 +3113,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kamadak-exif" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" +dependencies = [ + "mutate_once", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3510,6 +3519,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "native-tls" version = "0.2.18" @@ -4290,6 +4305,7 @@ dependencies = [ "hf-hub", "hnsw_rs", "image", + "kamadak-exif", "log", "memmap2", "ort", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2bd2dde..e79d1a3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,7 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] } zip = { version = "4.6.1", default-features = false, features = ["deflate"] } csv = "1" +kamadak-exif = "0.5" tauri-plugin-notification = "2" # ── Dev-mode performance ──────────────────────────────────────────────────── diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 9b5caf8..26f408d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -45,6 +45,7 @@ pub struct ImageRecord { pub file_size: i64, pub created_at: Option, pub modified_at: Option, + pub taken_at: Option, pub mime_type: String, pub media_kind: String, pub duration_ms: Option, @@ -272,6 +273,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id); CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at); + CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at); CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status); CREATE INDEX IF NOT EXISTS idx_thumbnail_jobs_status ON thumbnail_jobs(status); CREATE INDEX IF NOT EXISTS idx_metadata_jobs_status ON metadata_jobs(status); @@ -313,6 +315,7 @@ pub fn migrate(conn: &Connection) -> Result<()> { ensure_column(conn, "images", "ai_tagger_model", "TEXT")?; ensure_column(conn, "images", "ai_tagged_at", "TEXT")?; ensure_column(conn, "images", "ai_tagger_error", "TEXT")?; + ensure_column(conn, "images", "taken_at", "TEXT")?; ensure_column(conn, "folders", "scan_error", "TEXT")?; vector::migrate(conn)?; @@ -334,8 +337,8 @@ pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result { pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result { let id = conn.query_row( - "INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30) + "INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31) ON CONFLICT(path) DO UPDATE SET folder_id = excluded.folder_id, filename = excluded.filename, @@ -345,6 +348,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result { file_size = excluded.file_size, created_at = excluded.created_at, modified_at = excluded.modified_at, + taken_at = excluded.taken_at, mime_type = excluded.mime_type, media_kind = excluded.media_kind, duration_ms = excluded.duration_ms, @@ -375,6 +379,7 @@ pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result { img.file_size, img.created_at, img.modified_at, + img.taken_at, img.mime_type, img.media_kind, img.duration_ms, @@ -1334,7 +1339,7 @@ pub fn update_image_details( )?; conn.query_row( - "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, + "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, @@ -1349,7 +1354,7 @@ pub fn update_image_details( pub fn get_image_by_id(conn: &Connection, image_id: i64) -> Result { conn.query_row( - "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, + "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, @@ -1457,6 +1462,8 @@ pub fn get_images( "rating_desc" => "rating DESC, modified_at DESC NULLS LAST", "duration_asc" => "duration_ms ASC NULLS LAST", "duration_desc" => "duration_ms DESC NULLS LAST", + "taken_asc" => "COALESCE(taken_at, created_at) ASC NULLS LAST", + "taken_desc" => "COALESCE(taken_at, created_at) DESC NULLS LAST", _ => "modified_at DESC NULLS LAST", }; @@ -1464,7 +1471,7 @@ pub fn get_images( let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); let sql = format!( - "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, + "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error, generated_caption, caption_model, caption_updated_at, caption_error, @@ -2128,27 +2135,28 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result { file_size: row.get(7)?, created_at: row.get(8)?, modified_at: row.get(9)?, - mime_type: row.get(10)?, - media_kind: row.get(11)?, - duration_ms: row.get(12)?, - video_codec: row.get(13)?, - audio_codec: row.get(14)?, - metadata_updated_at: row.get(15)?, - metadata_error: row.get(16)?, - favorite: row.get::<_, i64>(17)? != 0, - rating: row.get(18)?, - embedding_status: row.get(19)?, - embedding_model: row.get(20)?, - embedding_updated_at: row.get(21)?, - embedding_error: row.get(22)?, - generated_caption: row.get(23)?, - caption_model: row.get(24)?, - caption_updated_at: row.get(25)?, - caption_error: row.get(26)?, - ai_rating: row.get(27)?, - ai_tagger_model: row.get(28)?, - ai_tagged_at: row.get(29)?, - ai_tagger_error: row.get(30)?, + taken_at: row.get(10)?, + mime_type: row.get(11)?, + media_kind: row.get(12)?, + duration_ms: row.get(13)?, + video_codec: row.get(14)?, + audio_codec: row.get(15)?, + metadata_updated_at: row.get(16)?, + metadata_error: row.get(17)?, + favorite: row.get::<_, i64>(18)? != 0, + rating: row.get(19)?, + embedding_status: row.get(20)?, + embedding_model: row.get(21)?, + embedding_updated_at: row.get(22)?, + embedding_error: row.get(23)?, + generated_caption: row.get(24)?, + caption_model: row.get(25)?, + caption_updated_at: row.get(26)?, + caption_error: row.get(27)?, + ai_rating: row.get(28)?, + ai_tagger_model: row.get(29)?, + ai_tagged_at: row.get(30)?, + ai_tagger_error: row.get(31)?, }) } diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 7b1426d..eda6d06 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -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 { + 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, diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index b0514f4..02f4041 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -5,6 +5,8 @@ import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, { value: "date_asc", label: "Oldest first" }, + { value: "taken_desc", label: "Taken: newest" }, + { value: "taken_asc", label: "Taken: oldest" }, { value: "name_asc", label: "Name A–Z" }, { value: "name_desc", label: "Name Z–A" }, { value: "rating_desc", label: "Highest rated" }, diff --git a/src/store.ts b/src/store.ts index 82d456d..af0f2dc 100644 --- a/src/store.ts +++ b/src/store.ts @@ -37,6 +37,7 @@ export interface ImageRecord { file_size: number; created_at: string | null; modified_at: string | null; + taken_at: string | null; mime_type: string; media_kind: MediaKind; duration_ms: number | null; @@ -214,7 +215,9 @@ export type SortOrder = | "rating_desc" | "rating_asc" | "duration_desc" - | "duration_asc"; + | "duration_asc" + | "taken_desc" + | "taken_asc"; interface GalleryState { folders: Folder[]; @@ -494,6 +497,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number return compareNullableNumber(a.duration_ms, b.duration_ms); case "duration_desc": return compareNullableNumber(b.duration_ms, a.duration_ms); + case "taken_asc": + return compareNullableDate(a.taken_at ?? a.created_at, b.taken_at ?? b.created_at); + case "taken_desc": + return compareNullableDate(b.taken_at ?? b.created_at, a.taken_at ?? a.created_at); default: return compareNullableDate(b.modified_at, a.modified_at); }