From 9ee5b08c938f2d8525e62fe806df5ae71a2628ef Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 00:26:36 +0100 Subject: [PATCH 1/6] 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); } From ae9e806e6188a51eae39580120e18846c16cc958 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 06:50:01 +0100 Subject: [PATCH 2/6] feat(watcher): adaptive filesystem watchdog with zero CPU when idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monitors all registered folders using OS-native events (ReadDirectoryChangesW on Windows) so new, modified, and deleted files are reflected in the gallery automatically without manual reindexing. Key design points: - Adaptive blocking: recv() when no events pending (zero CPU), switches to recv_timeout(earliest_deadline) only when debounce timers are running — wakes exactly when the soonest event is ready, no busy-polling - 500 ms per-path debounce coalesces rapid OS event bursts into one action - Change detection preserved: build_record skips upsert if file_size + mtime unchanged, preventing spurious thumbnail/embedding re-queues and avoiding clobbering of existing metadata like thumbnail_path - Access events (reads) filtered out; only Create/Modify/Remove trigger work - Deletion path: emits watcher-deleted event with image IDs; frontend removes those images from state and clears selectedImage if it was deleted WatcherHandle stored in app state; add_folder / remove_folder / update_folder_path commands keep the watched path set in sync with the DB. --- src-tauri/Cargo.lock | 92 +++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands.rs | 29 ++++- src-tauri/src/db.rs | 38 +++++++ src-tauri/src/indexer.rs | 221 +++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 3 + src/store.ts | 19 ++++ 7 files changed, 396 insertions(+), 7 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bc2e9f4..5e8dbe0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1750,6 +1750,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2932,6 +2941,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3133,6 +3162,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3438,6 +3487,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -3621,6 +3682,25 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "notify-rust" version = "4.17.0" @@ -4308,6 +4388,7 @@ dependencies = [ "kamadak-exif", "log", "memmap2", + "notify", "ort", "r2d2", "r2d2_sqlite", @@ -6392,7 +6473,7 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -7519,6 +7600,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e79d1a3..6c201c7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ 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" +notify = "6" tauri-plugin-notification = "2" # ── Dev-mode performance ──────────────────────────────────────────────────── diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fbeef49..1eabb3b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,7 +5,7 @@ use crate::captioner::{ use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag}; use crate::embedder; use crate::hnsw_index; -use crate::indexer; +use crate::indexer::{self, WatcherHandle}; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::vector; use serde::{Deserialize, Serialize}; @@ -202,6 +202,7 @@ pub struct GetImagesByIdsParams { pub async fn add_folder( app: AppHandle, db: State<'_, DbState>, + watcher: State<'_, WatcherHandle>, path: String, ) -> Result { let folder_path = PathBuf::from(&path); @@ -230,6 +231,7 @@ pub async fn add_folder( .find(|f| f.id == folder_id) .ok_or("Folder not found after insert")?; + watcher.add_folder(folder_path.clone(), folder_id); indexer::index_folder(app, db.inner().clone(), folder_id, folder_path); Ok(folder) @@ -250,9 +252,23 @@ pub async fn get_background_job_progress( } #[tauri::command] -pub async fn remove_folder(db: State<'_, DbState>, folder_id: i64) -> Result<(), String> { +pub async fn remove_folder( + db: State<'_, DbState>, + watcher: State<'_, WatcherHandle>, + folder_id: i64, +) -> Result<(), String> { let conn = db.get().map_err(|e| e.to_string())?; - db::delete_folder(&conn, folder_id).map_err(|e| e.to_string()) + // Capture the path before deletion so we can unregister the watcher. + let folder_path = db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .find(|f| f.id == folder_id) + .map(|f| PathBuf::from(f.path)); + db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())?; + if let Some(path) = folder_path { + watcher.remove_folder(&path); + } + Ok(()) } #[tauri::command] @@ -352,6 +368,7 @@ pub async fn rename_folder( pub async fn update_folder_path( app: AppHandle, db: State<'_, DbState>, + watcher: State<'_, WatcherHandle>, folder_id: i64, new_path: String, ) -> Result<(), String> { @@ -363,7 +380,7 @@ pub async fn update_folder_path( .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| new_path.clone()); - { + let old_path = { let conn = db.get().map_err(|e| e.to_string())?; // Fetch the old path before updating so image paths can be rewritten. let old_path = db::get_folders(&conn) @@ -374,7 +391,9 @@ pub async fn update_folder_path( .ok_or("Folder not found")?; db::update_folder_path(&conn, folder_id, &old_path, &new_path, &new_name) .map_err(|e| e.to_string())?; - } + old_path + }; + watcher.update_folder(&PathBuf::from(old_path), new_path_buf.clone(), folder_id); indexer::index_folder(app, db.inner().clone(), folder_id, new_path_buf); Ok(()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 26f408d..e5454ea 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1352,6 +1352,44 @@ pub fn update_image_details( .map_err(Into::into) } +/// Look up the lightweight indexed-media entry for a single path. +/// Used by the filesystem watcher to run change-detection before upserting. +pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result> { + let result = conn.query_row( + "SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1", + [path], + |row| { + Ok(IndexedMediaEntry { + id: row.get(0)?, + path: row.get(1)?, + modified_at: row.get(2)?, + file_size: row.get(3)?, + media_kind: row.get(4)?, + }) + }, + ); + match result { + Ok(entry) => Ok(Some(entry)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// Look up just the image id for a path. Used by the filesystem watcher +/// to find the DB row to delete when a file is removed from disk. +pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result> { + let result = conn.query_row( + "SELECT id FROM images WHERE path = ?1", + [path], + |row| row.get(0), + ); + match result { + Ok(id) => Ok(Some(id)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + 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, taken_at, mime_type, diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index eda6d06..2ac7ed4 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -9,9 +9,10 @@ use crate::vector; use anyhow::Result; use rayon::prelude::*; use serde::Serialize; +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; use walkdir::WalkDir; @@ -1282,3 +1283,221 @@ fn mime_for_ext(ext: &str) -> &'static str { _ => "image/jpeg", } } + +// ── Filesystem watcher ──────────────────────────────────────────────────────── + +/// How long to wait after the last event for a path before processing it. +/// Absorbs bursts of OS events that accompany a single logical file write. +const WATCHER_DEBOUNCE: Duration = Duration::from_millis(500); + +struct WatcherInner { + watcher: Mutex, + /// Maps each watched folder root → its folder_id in the DB. + folder_map: Arc>>, +} + +/// Shared handle that lets command handlers register and deregister watched +/// directories without touching the debounce thread directly. +#[derive(Clone)] +pub struct WatcherHandle { + inner: Arc, +} + +impl WatcherHandle { + pub fn add_folder(&self, path: PathBuf, folder_id: i64) { + { + let mut w = self.inner.watcher.lock().unwrap(); + if path.is_dir() { + if let Err(e) = w.watch(&path, RecursiveMode::Recursive) { + eprintln!("Watcher: failed to watch {:?}: {}", path, e); + } + } + } + self.inner.folder_map.lock().unwrap().insert(path, folder_id); + } + + pub fn remove_folder(&self, path: &Path) { + { + let mut w = self.inner.watcher.lock().unwrap(); + let _ = w.unwatch(path); + } + self.inner.folder_map.lock().unwrap().remove(path); + } + + pub fn update_folder(&self, old_path: &Path, new_path: PathBuf, folder_id: i64) { + { + let mut w = self.inner.watcher.lock().unwrap(); + let _ = w.unwatch(old_path); + if new_path.is_dir() { + if let Err(e) = w.watch(&new_path, RecursiveMode::Recursive) { + eprintln!("Watcher: failed to watch {:?}: {}", new_path, e); + } + } + } + let mut map = self.inner.folder_map.lock().unwrap(); + map.remove(old_path); + map.insert(new_path, folder_id); + } +} + +/// Start the filesystem watcher. Watches all folders currently in the DB and +/// returns a handle that command handlers can use to add/remove watched paths. +/// +/// The debounce loop uses adaptive blocking: +/// - `recv()` when no events are pending — zero CPU +/// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly +/// when the soonest debounce window expires, no busy-polling +pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle { + let (tx, rx) = std::sync::mpsc::channel::>(); + + let raw_watcher = notify::recommended_watcher(move |result| { + let _ = tx.send(result); + }) + .expect("Failed to create filesystem watcher"); + + // Seed the folder map from the DB so existing folders are watched on startup. + let folder_map: Arc>> = Arc::new(Mutex::new(HashMap::new())); + { + let conn = pool.get().expect("Watcher: failed to get DB connection for init"); + let folders = db::get_folders(&conn).unwrap_or_default(); + let mut map = folder_map.lock().unwrap(); + for f in folders { + map.insert(PathBuf::from(f.path), f.id); + } + } + + // Register each known folder with the OS watcher. + let raw_watcher = { + let mut w = raw_watcher; + let map = folder_map.lock().unwrap(); + for path in map.keys() { + if path.is_dir() { + if let Err(e) = w.watch(path, RecursiveMode::Recursive) { + eprintln!("Watcher: failed to watch {:?}: {}", path, e); + } + } + } + w + }; + + let handle = WatcherHandle { + inner: Arc::new(WatcherInner { + watcher: Mutex::new(raw_watcher), + folder_map: Arc::clone(&folder_map), + }), + }; + + // Spawn the debounce loop on its own thread. + let folder_map_thread = Arc::clone(&folder_map); + std::thread::spawn(move || { + // path → deadline: the earliest instant at which this path should be processed. + let mut pending: HashMap = HashMap::new(); + + loop { + // Adaptive blocking: block forever when idle, wake at the earliest + // deadline when events are queued. + let received = if pending.is_empty() { + match rx.recv() { + Ok(e) => Some(e), + Err(_) => break, // sender dropped — app is shutting down + } + } else { + let earliest = pending.values().copied().min().unwrap(); // safe: non-empty + let timeout = earliest.saturating_duration_since(Instant::now()); + match rx.recv_timeout(timeout) { + Ok(e) => Some(e), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + }; + + let now = Instant::now(); + + // Absorb incoming event — coalesces rapid writes into one deadline. + if let Some(Ok(event)) = received { + use notify::EventKind; + // Skip pure access events (reads); they never change file content. + if !matches!(event.kind, EventKind::Access(_)) { + for path in event.paths { + if is_supported_media(&path) { + pending.insert(path, now + WATCHER_DEBOUNCE); + } + } + } + } + + // Process all paths whose debounce window has expired. + let ready: Vec = pending + .iter() + .filter(|(_, &deadline)| deadline <= now) + .map(|(p, _)| p.clone()) + .collect(); + + for path in ready { + pending.remove(&path); + process_watcher_path(&app, &pool, &folder_map_thread, &path); + } + } + }); + + handle +} + +/// Decide what to do with a path whose debounce window just expired. +/// If the file exists → upsert (with change-detection to avoid clobbering +/// metadata like thumbnail_path). If the file is gone → delete from DB. +fn process_watcher_path( + app: &AppHandle, + pool: &DbPool, + folder_map: &Arc>>, + path: &Path, +) { + // Determine which registered folder owns this file. + let folder_id = { + let map = folder_map.lock().unwrap(); + map.iter() + .find(|(folder_path, _)| path.starts_with(folder_path.as_path())) + .map(|(_, &id)| id) + }; + let Some(folder_id) = folder_id else { return }; + + let conn = match pool.get() { + Ok(c) => c, + Err(e) => { + eprintln!("Watcher: DB pool error: {}", e); + return; + } + }; + + if path.exists() { + // File still on disk — upsert if content changed. + let path_str = path.to_string_lossy(); + let existing = db::get_indexed_entry_by_path(&conn, &path_str).unwrap_or(None); + let Some(record) = build_record(path, folder_id, existing.as_ref()) else { + return; // file unchanged (same size + mtime) — nothing to do + }; + drop(conn); // commit_batch acquires its own connection from the pool + + match commit_batch(pool, &[record]) { + Ok(committed) if !committed.is_empty() => { + emit_images(app, &IndexedImagesBatch { folder_id, images: committed }); + emit_folder_job_progress(app, pool, &[folder_id], false); + } + Ok(_) => {} + Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e), + } + } else { + // File removed from disk — clean up DB row. + let path_str = path.to_string_lossy(); + match db::get_image_id_by_path(&conn, &path_str) { + Ok(Some(image_id)) => { + if db::delete_images_by_ids(&conn, &[image_id]).is_ok() { + db::update_folder_count(&conn, folder_id).ok(); + let _ = app.emit("watcher-deleted", vec![image_id]); + } + } + Ok(None) => {} // never indexed or already removed + Err(e) => eprintln!("Watcher: lookup error for {:?}: {}", path, e), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 009851f..bb8a736 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -74,8 +74,11 @@ pub fn run() { // indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone()); indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone()); + let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone()); + app.manage(pool); app.manage(media_tools); + app.manage(watcher_handle); Ok(()) }) diff --git a/src/store.ts b/src/store.ts index af0f2dc..96fdd27 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1747,6 +1747,24 @@ export const useGalleryStore = create((set, get) => ({ }); }); + const unlistenWatcherDeleted = await listen("watcher-deleted", (event) => { + const deletedIds = new Set(event.payload); + set((state) => { + const removed = state.images.filter((img) => deletedIds.has(img.id)).length; + const images = state.images.filter((img) => !deletedIds.has(img.id)); + const selectedImage = + state.selectedImage && deletedIds.has(state.selectedImage.id) + ? null + : state.selectedImage; + return { + images, + totalImages: Math.max(0, state.totalImages - removed), + loadedCount: Math.max(0, state.loadedCount - removed), + selectedImage, + }; + }); + }); + return () => { unlistenProgress(); unlistenMediaJobs(); @@ -1754,6 +1772,7 @@ export const useGalleryStore = create((set, get) => ({ unlistenTaggerModelProgress(); unlistenImages(); unlistenThumbnails(); + unlistenWatcherDeleted(); }; }, })); From ec6be96c6a428438c88f30ac5f11c1852bfec4e6 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 18:19:01 +0100 Subject: [PATCH 3/6] feat(timeline): add virtualised month-grouped timeline view - New Timeline view groups all media by YYYY-MM using taken_at ?? created_at - @tanstack/react-virtual (group-level virtualiser) keeps scroll smooth on large libraries; estimateSize is exact so no measureElement needed - ResizeObserver tracks container width; virtualizer.measure() is called on cols change to prevent stale position cache after window resize - setView("timeline") in store auto-sets sort:"taken_asc", resets images, and triggers a fresh load - Calendar nav item added to Sidebar between Explore and Duplicates - ContextMenu and ImageTile exported from Gallery for reuse in Timeline --- PLAN_watchdog_exif_timeline.md | 146 +++++++++++++++++++ src/App.tsx | 9 +- src/components/Gallery.tsx | 4 +- src/components/Sidebar.tsx | 17 +++ src/components/Timeline.tsx | 247 +++++++++++++++++++++++++++++++++ src/store.ts | 7 +- 6 files changed, 426 insertions(+), 4 deletions(-) create mode 100644 PLAN_watchdog_exif_timeline.md create mode 100644 src/components/Timeline.tsx diff --git a/PLAN_watchdog_exif_timeline.md b/PLAN_watchdog_exif_timeline.md new file mode 100644 index 0000000..a3f8519 --- /dev/null +++ b/PLAN_watchdog_exif_timeline.md @@ -0,0 +1,146 @@ +# Feature Plan: Watchdog + EXIF + Timeline + +Branch: `feat/watchdog-exif-timeline` +Base: merged PR #8 (discovery features) into `main` + +--- + +## Overview + +Three related features being implemented together: + +1. **Phase 1 — EXIF date extraction** ✅ DONE (commit `9ee5b08`) +2. **Phase 2 — Filesystem watchdog** ✅ DONE (commit `ae9e806`) +3. **Phase 3 — Timeline view** ✅ DONE + +--- + +## Phase 1: EXIF Date Extraction ✅ + +### What it does +Extracts the capture date from EXIF metadata during indexing and stores it as +`taken_at` (nullable TEXT, ISO 8601) on each image. Exposes "Taken: newest / +oldest" sort options. + +### Key files changed +- `src-tauri/Cargo.toml` — added `kamadak-exif = "0.5"` +- `src-tauri/src/db.rs`: + - Added `taken_at: Option` to `ImageRecord` struct (after `modified_at`) + - `ensure_column` migration + `idx_images_taken_at` index + - Updated `upsert_image`, `map_image_row` (all indices shifted +1 after taken_at), all 3 SELECT statements + - Added `taken_asc` / `taken_desc` sort cases using `COALESCE(taken_at, created_at)` +- `src-tauri/src/indexer.rs`: + - Added `extract_exif_date(path) -> Option` — tries DateTimeOriginal → DateTimeDigitized → DateTime; rejects all-zero sentinel dates ("0000:00:00 00:00:00") + - `build_record` now populates `taken_at: extract_exif_date(path)` +- `src/store.ts` — added `taken_at: string | null` to `ImageRecord`, `"taken_desc" | "taken_asc"` to `SortOrder`, sort cases in `compareImages` using `a.taken_at ?? a.created_at` +- `src/components/Toolbar.tsx` — added "Taken: newest" / "Taken: oldest" to sort dropdown + +### Notes +- Existing images won't have `taken_at` until re-indexed (file_size or mtime must change to trigger re-extraction) +- `upsert_image` uses `taken_at = excluded.taken_at` (not COALESCE) — if file content changes, fresh EXIF is used + +--- + +## Phase 2: Filesystem Watchdog ✅ + +### What it does +Watches all registered folders using OS-native events (`ReadDirectoryChangesW` +on Windows). New, modified, and deleted files are reflected automatically +without manual reindexing. Zero CPU when idle. + +### Key design +- **Adaptive blocking**: `recv()` when no events pending (truly idle), switches + to `recv_timeout(earliest_deadline)` only when debounce timers are running +- **500 ms per-path debounce** coalesces rapid OS event bursts +- **Change detection**: `build_record(path, folder_id, existing.as_ref())` skips + upsert if file_size + mtime unchanged → no thumbnail/embedding re-queues, no + metadata clobber +- `Access` events filtered out (reads don't change content) + +### Key files changed +- `src-tauri/Cargo.toml` — added `notify = "6"` +- `src-tauri/src/db.rs` — added `get_indexed_entry_by_path` and `get_image_id_by_path` +- `src-tauri/src/indexer.rs` — added `WatcherHandle`, `WatcherInner`, `start_watcher`, `process_watcher_path` +- `src-tauri/src/lib.rs` — starts watcher after other workers, manages `WatcherHandle` in app state +- `src-tauri/src/commands.rs` — `add_folder`, `remove_folder`, `update_folder_path` now accept `State<'_, WatcherHandle>` and call `watcher.add_folder / remove_folder / update_folder` +- `src/store.ts` — `subscribeToProgress` listens for `"watcher-deleted"` event (`number[]` payload), removes images from state, clears `selectedImage` if deleted + +--- + +## Phase 3: Timeline View ✅ DONE + +### What it should do +A new gallery view that groups images by date (year → month → day), virtualised +for performance, using the `taken_at` / `created_at` data from Phase 1. + +### Suggested approach + +**Backend** +- No new Tauri commands needed — images are already sorted by `taken_asc` / + `taken_desc` and carry `taken_at` / `created_at` +- Optionally: a `get_timeline_buckets` command that returns counts per + year/month for a navigation sidebar (nice-to-have, not essential for MVP) + +**Frontend — grouping logic** +- New view type: add `"timeline"` to `ActiveView` type in `store.ts` +- Grouping function: takes `ImageRecord[]`, returns `TimelineGroup[]`: + ```ts + interface TimelineGroup { + label: string; // e.g. "June 2023" + dateKey: string; // e.g. "2023-06" for keying + images: ImageRecord[]; + } + ``` + Use `taken_at ?? created_at` for the date. Group by `YYYY-MM` (month granularity works well). + +**Frontend — virtualised rendering** +- Use `@tanstack/react-virtual` (already a dependency) +- Virtualise at the *row* level (each row = one group header + a row of tiles, + or a row of tiles within a group) +- Simplest pattern: flatten groups into a mixed list of `{ type: 'header', label }` and `{ type: 'image', image }` items, then use `useVirtualizer` on that flat list +- Tile size uses the existing `tileSizeForZoom` / `zoomPreset` from the store + +**Frontend — navigation** +- Sidebar can list the groups (year/month) as anchor links — clicking jumps + `virtualizer.scrollToIndex(groupStartIndex)` +- Or keep it simple for MVP: just scroll, the headers are visible as you go + +**Frontend — state** +- `sort` should auto-switch to `"taken_desc"` when entering timeline view + (can be a `useEffect` in the Timeline component) +- When leaving timeline view, restore the previous sort + +**Suggested component structure** +``` +src/components/Timeline.tsx + - TimelineView (main component, uses useVirtualizer) + - TimelineGroupHeader (date label row) + - reuses existing Gallery tile/card components +``` + +**Toolbar** +- Add "Timeline" to the view switcher alongside the existing gallery/explore/duplicates tabs +- Or add it to the `Sidebar` nav + +### Existing hooks to reuse +- `tileSizeForZoom(zoomPreset)` for tile size +- `matchesFilters` for respecting active folder/media/favorites filters +- The existing `Gallery` grid tile component for rendering individual images +- `openImage` store action for lightbox + +--- + +## General notes for a new session + +- **Always run `feature-dev:code-reviewer` sweep before every commit** — see `MEMORY.md` +- **Use pnpm**, never npm +- **No `any` types** in TypeScript +- Hot reload is active during `pnpm dev:app` — don't restart for frontend changes +- CUDA `cargo check` failure is **pre-existing** (broken nvcc environment) and + unrelated to this feature work — filter it out with `grep -v candle` when checking +- The `map_image_row` in `db.rs` uses **positional column indices** — any new + column added to the SELECT must have its index maintained exactly +- `sqlite-vec` virtual table DML is unreliable inside transactions — vector + operations must happen outside `unchecked_transaction()` +- `ImageRecord` is mirrored in `db.rs` (Rust) and `store.ts` (TypeScript) — + both must stay in sync diff --git a/src/App.tsx b/src/App.tsx index ce2a339..84595a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Gallery } from "./components/Gallery"; import { Lightbox } from "./components/Lightbox"; import { TagCloud } from "./components/TagCloud"; import { DuplicateFinder } from "./components/DuplicateFinder"; +import { Timeline } from "./components/Timeline"; import { TitleBar } from "./components/TitleBar"; import { SettingsModal } from "./components/SettingsModal"; import { initializeNotifications } from "./notifications"; @@ -46,7 +47,13 @@ export default function App() {
- {activeView === "explore" ? ( + {activeView === "timeline" ? ( + <> + + + + + ) : activeView === "explore" ? ( <> diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 0f820d4..bbfefac 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } -function ContextMenu({ +export function ContextMenu({ x, y, image, @@ -104,7 +104,7 @@ function ContextMenu({ ); } -function ImageTile({ +export function ImageTile({ image, onClick, onContextMenu, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 18f2fce..da84180 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -329,6 +329,23 @@ export function Sidebar() {
+
setView("timeline")} + > + + + + + Timeline + +
+
(); + for (const img of images) { + const ds = img.taken_at ?? img.created_at; + const key = ds ? ds.substring(0, 7) : "unknown"; + let bucket = map.get(key); + if (bucket === undefined) { + bucket = []; + map.set(key, bucket); + } + bucket.push(img); + } + return Array.from(map.entries()) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); +} + +export function Timeline() { + const images = useGalleryStore((s) => s.images); + const loadMoreImages = useGalleryStore((s) => s.loadMoreImages); + const openImage = useGalleryStore((s) => s.openImage); + const totalImages = useGalleryStore((s) => s.totalImages); + const loadingImages = useGalleryStore((s) => s.loadingImages); + const imageLoadError = useGalleryStore((s) => s.imageLoadError); + const zoomPreset = useGalleryStore((s) => s.zoomPreset); + + const parentRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + image: ImageRecord; + } | null>(null); + + // Measure container width before first paint to avoid a single-column flash. + useLayoutEffect(() => { + const el = parentRef.current; + if (!el) return; + setContainerWidth(el.clientWidth); + const ro = new ResizeObserver((entries) => { + setContainerWidth(entries[0].contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const tileSize = tileSizeForZoom(zoomPreset); + const cols = useMemo( + () => Math.max(1, Math.floor((containerWidth + GAP) / (tileSize + GAP))), + [containerWidth, tileSize], + ); + + const groups = useMemo(() => groupImages(images), [images]); + + // estimateSize must be exact so virtualizer positions groups correctly. + // Each group height = header + rowCount * (tileSize + GAP) where the last row's + // GAP acts as spacing between this group and the next header. + const estimateSize = useCallback( + (index: number): number => { + const group = groups[index]; + if (!group) return HEADER_HEIGHT; + const rowCount = Math.ceil(group.images.length / cols); + return HEADER_HEIGHT + rowCount * (tileSize + GAP); + }, + [groups, cols, tileSize], + ); + + const virtualizer = useVirtualizer({ + count: groups.length, + getScrollElement: () => parentRef.current, + estimateSize, + overscan: 2, + }); + + // Re-measure all items when cols changes so virtualizer positions stay accurate + // after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own). + useEffect(() => { + virtualizer.measure(); + }, [cols, virtualizer]); + + const handleScroll = useCallback(() => { + const el = parentRef.current; + if (!el) return; + if (el.scrollTop < 24) return; + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; + if (nearBottom && !loadingImages && images.length < totalImages) { + void loadMoreImages(); + } + }, [images.length, loadMoreImages, loadingImages, totalImages]); + + useEffect(() => { + const el = parentRef.current; + if (!el) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + useEffect(() => { + const close = (e: PointerEvent) => { + if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; + setContextMenu(null); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setContextMenu(null); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("keydown", onKey); + }; + }, []); + + return ( +
+ {images.length === 0 && loadingImages ? ( +
+
+
+

Loading timeline

+

Fetching results

+
+
+ ) : images.length === 0 ? ( +
+
+ + + +

+ {imageLoadError ? "Could not load timeline" : "No media found"} +

+

+ {imageLoadError ?? "Add a folder to see your timeline"} +

+
+
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const group = groups[virtualItem.index]; + if (!group) return null; + return ( +
+ {/* Group header */} +
+ + {group.label} + + + {group.images.length} + +
+
+ + {/* Image grid — paddingBottom:GAP gives the gap below the last row, + matching the row-to-row gap and making estimateSize exact. */} +
+ {group.images.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault(); + setContextMenu({ x: event.clientX, y: event.clientY, image }); + }} + /> + ))} +
+
+ ); + })} +
+ )} + + {images.length > 0 && loadingImages ? ( +
+
+
+ ) : null} + + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} +
+ ); +} diff --git a/src/store.ts b/src/store.ts index 96fdd27..129ac4f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -122,7 +122,7 @@ export interface ThumbnailBatch { images: ImageRecord[]; } -export type ActiveView = "gallery" | "explore" | "duplicates"; +export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline"; export interface TagCloudEntry { count: number; @@ -905,6 +905,11 @@ export const useGalleryStore = create((set, get) => ({ closeImage: () => set({ selectedImage: null }), setView: (activeView) => { + if (activeView === "timeline") { + set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + return; + } if (activeView === "duplicates") { const { selectedFolderId, duplicateScanFolderId } = get(); if (duplicateScanFolderId !== selectedFolderId) { From 9ace1f6778a1bc05abe09c2a389bb0f8d394f59d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 20:06:17 +0100 Subject: [PATCH 4/6] chore: remove plan handoff document --- PLAN_watchdog_exif_timeline.md | 146 --------------------------------- 1 file changed, 146 deletions(-) delete mode 100644 PLAN_watchdog_exif_timeline.md diff --git a/PLAN_watchdog_exif_timeline.md b/PLAN_watchdog_exif_timeline.md deleted file mode 100644 index a3f8519..0000000 --- a/PLAN_watchdog_exif_timeline.md +++ /dev/null @@ -1,146 +0,0 @@ -# Feature Plan: Watchdog + EXIF + Timeline - -Branch: `feat/watchdog-exif-timeline` -Base: merged PR #8 (discovery features) into `main` - ---- - -## Overview - -Three related features being implemented together: - -1. **Phase 1 — EXIF date extraction** ✅ DONE (commit `9ee5b08`) -2. **Phase 2 — Filesystem watchdog** ✅ DONE (commit `ae9e806`) -3. **Phase 3 — Timeline view** ✅ DONE - ---- - -## Phase 1: EXIF Date Extraction ✅ - -### What it does -Extracts the capture date from EXIF metadata during indexing and stores it as -`taken_at` (nullable TEXT, ISO 8601) on each image. Exposes "Taken: newest / -oldest" sort options. - -### Key files changed -- `src-tauri/Cargo.toml` — added `kamadak-exif = "0.5"` -- `src-tauri/src/db.rs`: - - Added `taken_at: Option` to `ImageRecord` struct (after `modified_at`) - - `ensure_column` migration + `idx_images_taken_at` index - - Updated `upsert_image`, `map_image_row` (all indices shifted +1 after taken_at), all 3 SELECT statements - - Added `taken_asc` / `taken_desc` sort cases using `COALESCE(taken_at, created_at)` -- `src-tauri/src/indexer.rs`: - - Added `extract_exif_date(path) -> Option` — tries DateTimeOriginal → DateTimeDigitized → DateTime; rejects all-zero sentinel dates ("0000:00:00 00:00:00") - - `build_record` now populates `taken_at: extract_exif_date(path)` -- `src/store.ts` — added `taken_at: string | null` to `ImageRecord`, `"taken_desc" | "taken_asc"` to `SortOrder`, sort cases in `compareImages` using `a.taken_at ?? a.created_at` -- `src/components/Toolbar.tsx` — added "Taken: newest" / "Taken: oldest" to sort dropdown - -### Notes -- Existing images won't have `taken_at` until re-indexed (file_size or mtime must change to trigger re-extraction) -- `upsert_image` uses `taken_at = excluded.taken_at` (not COALESCE) — if file content changes, fresh EXIF is used - ---- - -## Phase 2: Filesystem Watchdog ✅ - -### What it does -Watches all registered folders using OS-native events (`ReadDirectoryChangesW` -on Windows). New, modified, and deleted files are reflected automatically -without manual reindexing. Zero CPU when idle. - -### Key design -- **Adaptive blocking**: `recv()` when no events pending (truly idle), switches - to `recv_timeout(earliest_deadline)` only when debounce timers are running -- **500 ms per-path debounce** coalesces rapid OS event bursts -- **Change detection**: `build_record(path, folder_id, existing.as_ref())` skips - upsert if file_size + mtime unchanged → no thumbnail/embedding re-queues, no - metadata clobber -- `Access` events filtered out (reads don't change content) - -### Key files changed -- `src-tauri/Cargo.toml` — added `notify = "6"` -- `src-tauri/src/db.rs` — added `get_indexed_entry_by_path` and `get_image_id_by_path` -- `src-tauri/src/indexer.rs` — added `WatcherHandle`, `WatcherInner`, `start_watcher`, `process_watcher_path` -- `src-tauri/src/lib.rs` — starts watcher after other workers, manages `WatcherHandle` in app state -- `src-tauri/src/commands.rs` — `add_folder`, `remove_folder`, `update_folder_path` now accept `State<'_, WatcherHandle>` and call `watcher.add_folder / remove_folder / update_folder` -- `src/store.ts` — `subscribeToProgress` listens for `"watcher-deleted"` event (`number[]` payload), removes images from state, clears `selectedImage` if deleted - ---- - -## Phase 3: Timeline View ✅ DONE - -### What it should do -A new gallery view that groups images by date (year → month → day), virtualised -for performance, using the `taken_at` / `created_at` data from Phase 1. - -### Suggested approach - -**Backend** -- No new Tauri commands needed — images are already sorted by `taken_asc` / - `taken_desc` and carry `taken_at` / `created_at` -- Optionally: a `get_timeline_buckets` command that returns counts per - year/month for a navigation sidebar (nice-to-have, not essential for MVP) - -**Frontend — grouping logic** -- New view type: add `"timeline"` to `ActiveView` type in `store.ts` -- Grouping function: takes `ImageRecord[]`, returns `TimelineGroup[]`: - ```ts - interface TimelineGroup { - label: string; // e.g. "June 2023" - dateKey: string; // e.g. "2023-06" for keying - images: ImageRecord[]; - } - ``` - Use `taken_at ?? created_at` for the date. Group by `YYYY-MM` (month granularity works well). - -**Frontend — virtualised rendering** -- Use `@tanstack/react-virtual` (already a dependency) -- Virtualise at the *row* level (each row = one group header + a row of tiles, - or a row of tiles within a group) -- Simplest pattern: flatten groups into a mixed list of `{ type: 'header', label }` and `{ type: 'image', image }` items, then use `useVirtualizer` on that flat list -- Tile size uses the existing `tileSizeForZoom` / `zoomPreset` from the store - -**Frontend — navigation** -- Sidebar can list the groups (year/month) as anchor links — clicking jumps - `virtualizer.scrollToIndex(groupStartIndex)` -- Or keep it simple for MVP: just scroll, the headers are visible as you go - -**Frontend — state** -- `sort` should auto-switch to `"taken_desc"` when entering timeline view - (can be a `useEffect` in the Timeline component) -- When leaving timeline view, restore the previous sort - -**Suggested component structure** -``` -src/components/Timeline.tsx - - TimelineView (main component, uses useVirtualizer) - - TimelineGroupHeader (date label row) - - reuses existing Gallery tile/card components -``` - -**Toolbar** -- Add "Timeline" to the view switcher alongside the existing gallery/explore/duplicates tabs -- Or add it to the `Sidebar` nav - -### Existing hooks to reuse -- `tileSizeForZoom(zoomPreset)` for tile size -- `matchesFilters` for respecting active folder/media/favorites filters -- The existing `Gallery` grid tile component for rendering individual images -- `openImage` store action for lightbox - ---- - -## General notes for a new session - -- **Always run `feature-dev:code-reviewer` sweep before every commit** — see `MEMORY.md` -- **Use pnpm**, never npm -- **No `any` types** in TypeScript -- Hot reload is active during `pnpm dev:app` — don't restart for frontend changes -- CUDA `cargo check` failure is **pre-existing** (broken nvcc environment) and - unrelated to this feature work — filter it out with `grep -v candle` when checking -- The `map_image_row` in `db.rs` uses **positional column indices** — any new - column added to the SELECT must have its index maintained exactly -- `sqlite-vec` virtual table DML is unreliable inside transactions — vector - operations must happen outside `unchecked_transaction()` -- `ImageRecord` is mirrored in `db.rs` (Rust) and `store.ts` (TypeScript) — - both must stay in sync From 0ab156d2d917bcc57377d61d79107cb08d03059b Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 22:09:50 +0100 Subject: [PATCH 5/6] fix: startup crash, watcher count, timeline date fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.rs: - Move idx_images_taken_at creation to after ensure_column so it never runs against a schema where taken_at doesn't exist yet; this was causing a startup panic on any DB that predates Phase 1 - COALESCE(taken_at, created_at) → COALESCE(taken_at, modified_at) since created_at is never populated (modified_at is always set) - Remove dead is_tagging_job_cancelled function (superseded by is_tagging_job_processing) and unused thumbnail_path from ImagePathRecord indexer.rs: - Watcher create path now calls update_folder_count after commit_batch and emits folder-counts-changed so the sidebar count stays in sync; the notification is only emitted when the DB write actually succeeds store.ts / Timeline.tsx: - compareImages taken_asc/taken_desc: fall back to modified_at not created_at (created_at is always null) - Timeline groupImages: same fallback fix so images group by month correctly for libraries not yet re-indexed for EXIF - subscribeToProgress: listen for folder-counts-changed and call loadFolders() to keep the sidebar image count live --- src-tauri/src/db.rs | 27 +++++++++------------------ src-tauri/src/indexer.rs | 10 ++++++++++ src/components/Timeline.tsx | 2 +- src/store.ts | 9 +++++++-- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index e5454ea..b2c02ba 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -273,7 +273,6 @@ 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); @@ -316,6 +315,12 @@ pub fn migrate(conn: &Connection) -> Result<()> { ensure_column(conn, "images", "ai_tagged_at", "TEXT")?; ensure_column(conn, "images", "ai_tagger_error", "TEXT")?; ensure_column(conn, "images", "taken_at", "TEXT")?; + // Index must be created after ensure_column adds the column; it cannot live + // in the execute_batch above because that batch runs before the column exists + // on databases that predate Phase 1. + conn.execute_batch( + "CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);", + )?; ensure_column(conn, "folders", "scan_error", "TEXT")?; vector::migrate(conn)?; @@ -1500,8 +1505,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", + "taken_asc" => "COALESCE(taken_at, modified_at) ASC NULLS LAST", + "taken_desc" => "COALESCE(taken_at, modified_at) DESC NULLS LAST", _ => "modified_at DESC NULLS LAST", }; @@ -1671,7 +1676,6 @@ pub fn search_tags_autocomplete( pub struct ImagePathRecord { pub id: i64, pub path: String, - pub thumbnail_path: Option, } pub fn get_all_image_paths( @@ -1679,14 +1683,13 @@ pub fn get_all_image_paths( folder_id: Option, ) -> Result> { let mut stmt = conn.prepare( - "SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", + "SELECT id, path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id", )?; let rows = stmt .query_map(params![folder_id], |row| { Ok(ImagePathRecord { id: row.get(0)?, path: row.get(1)?, - thumbnail_path: row.get(2)?, }) })? .collect::>>()?; @@ -2057,18 +2060,6 @@ pub fn requeue_processing_tagging_jobs_for_folder(conn: &Connection, folder_id: Ok(()) } -/// Returns `true` when the job row for `image_id` currently has status = 'cancelled'. -/// Used by the worker to discard inference results for jobs that were cancelled -/// while inference was running. -pub fn is_tagging_job_cancelled(conn: &Connection, image_id: i64) -> Result { - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'", - [image_id], - |row| row.get(0), - )?; - Ok(count > 0) -} - /// Returns `true` when the job row for `image_id` is still `processing`. /// A `false` result means the row was reset to `pending` (pause) or `cancelled` /// while inference was running — either way the result must be discarded. diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 2ac7ed4..704c466 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -1480,8 +1480,17 @@ fn process_watcher_path( match commit_batch(pool, &[record]) { Ok(committed) if !committed.is_empty() => { + // Always emit the images — they are committed to the DB. emit_images(app, &IndexedImagesBatch { folder_id, images: committed }); emit_folder_job_progress(app, pool, &[folder_id], false); + // Update the sidebar count only if we successfully write the new + // count; skip the frontend notification on pool/DB failure to + // avoid showing a stale number. + if let Ok(count_conn) = pool.get() { + if db::update_folder_count(&count_conn, folder_id).is_ok() { + let _ = app.emit("folder-counts-changed", ()); + } + } } Ok(_) => {} Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e), @@ -1494,6 +1503,7 @@ fn process_watcher_path( if db::delete_images_by_ids(&conn, &[image_id]).is_ok() { db::update_folder_count(&conn, folder_id).ok(); let _ = app.emit("watcher-deleted", vec![image_id]); + let _ = app.emit("folder-counts-changed", ()); } } Ok(None) => {} // never indexed or already removed diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index e9e1c83..1855eb0 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -22,7 +22,7 @@ function buildLabel(key: string): string { function groupImages(images: ImageRecord[]): TimelineGroup[] { const map = new Map(); for (const img of images) { - const ds = img.taken_at ?? img.created_at; + const ds = img.taken_at ?? img.modified_at; const key = ds ? ds.substring(0, 7) : "unknown"; let bucket = map.get(key); if (bucket === undefined) { diff --git a/src/store.ts b/src/store.ts index 129ac4f..a3f6f43 100644 --- a/src/store.ts +++ b/src/store.ts @@ -498,9 +498,9 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number 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); + return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at); case "taken_desc": - return compareNullableDate(b.taken_at ?? b.created_at, a.taken_at ?? a.created_at); + return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at); default: return compareNullableDate(b.modified_at, a.modified_at); } @@ -1770,6 +1770,10 @@ export const useGalleryStore = create((set, get) => ({ }); }); + const unlistenFolderCounts = await listen("folder-counts-changed", () => { + void get().loadFolders(); + }); + return () => { unlistenProgress(); unlistenMediaJobs(); @@ -1778,6 +1782,7 @@ export const useGalleryStore = create((set, get) => ({ unlistenImages(); unlistenThumbnails(); unlistenWatcherDeleted(); + unlistenFolderCounts(); }; }, })); From a2804d8c1b1b5fadc6bc4864ed7502dfc2c16064 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 8 Jun 2026 22:26:28 +0100 Subject: [PATCH 6/6] fix(timeline): CodeRabbit review corrections - Fix cols overflow: use (containerWidth - GAP) formula so the grid never exceeds the container width - Explicitly pin "unknown" date keys to the end of the sort rather than relying on alphabetical accident ("u" > digits) - Guard buildLabel against non-YYYY-MM keys with finite/range checks and an isNaN fallback to "Unknown Date" - Clear similarSourceFolderId, similarFolderId, and similarCrop when switching to the timeline view so all similar-state is consistently reset --- src/components/Timeline.tsx | 14 +++++++++++--- src/store.ts | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 1855eb0..edd4f6b 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -15,7 +15,11 @@ interface TimelineGroup { function buildLabel(key: string): string { if (key === "unknown") return "Unknown Date"; const [yearStr, monthStr] = key.split("-"); - const date = new Date(Number(yearStr), Number(monthStr) - 1); + const year = Number(yearStr); + const month = Number(monthStr); + if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date"; + const date = new Date(year, month - 1); + if (isNaN(date.getTime())) return "Unknown Date"; return date.toLocaleDateString(undefined, { month: "long", year: "numeric" }); } @@ -32,7 +36,11 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] { bucket.push(img); } return Array.from(map.entries()) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .sort(([a], [b]) => { + if (a === "unknown") return 1; + if (b === "unknown") return -1; + return a < b ? -1 : a > b ? 1 : 0; + }) .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); } @@ -67,7 +75,7 @@ export function Timeline() { const tileSize = tileSizeForZoom(zoomPreset); const cols = useMemo( - () => Math.max(1, Math.floor((containerWidth + GAP) / (tileSize + GAP))), + () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), [containerWidth, tileSize], ); diff --git a/src/store.ts b/src/store.ts index a3f6f43..c82494d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -906,7 +906,7 @@ export const useGalleryStore = create((set, get) => ({ setView: (activeView) => { if (activeView === "timeline") { - set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null }); void get().loadImages(true); return; }