diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index edd9078..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" @@ -3113,6 +3142,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" @@ -3124,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" @@ -3429,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" @@ -3510,6 +3580,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" @@ -3606,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" @@ -4290,8 +4385,10 @@ dependencies = [ "hf-hub", "hnsw_rs", "image", + "kamadak-exif", "log", "memmap2", + "notify", "ort", "r2d2", "r2d2_sqlite", @@ -6376,7 +6473,7 @@ checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -7503,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 2bd2dde..6c201c7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -50,6 +50,8 @@ 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" +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 9b5caf8..b2c02ba 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, @@ -313,6 +314,13 @@ 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")?; + // 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)?; @@ -334,8 +342,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 +353,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 +384,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 +1344,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, @@ -1347,9 +1357,47 @@ 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, 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 +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, modified_at) ASC NULLS LAST", + "taken_desc" => "COALESCE(taken_at, modified_at) DESC NULLS LAST", _ => "modified_at DESC NULLS LAST", }; @@ -1464,7 +1514,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, @@ -1626,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( @@ -1634,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::>>()?; @@ -2012,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. @@ -2128,27 +2164,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..704c466 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; @@ -425,6 +426,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 +509,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, @@ -1238,3 +1283,231 @@ 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() => { + // 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), + } + } 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]); + let _ = app.emit("folder-counts-changed", ()); + } + } + 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/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 + +
+
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" }); +} + +function groupImages(images: ImageRecord[]): TimelineGroup[] { + const map = new Map(); + for (const img of images) { + const ds = img.taken_at ?? img.modified_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]) => { + 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 })); +} + +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/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..c82494d 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; @@ -121,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; @@ -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.modified_at, b.taken_at ?? b.modified_at); + case "taken_desc": + return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at); default: return compareNullableDate(b.modified_at, a.modified_at); } @@ -898,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, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null }); + void get().loadImages(true); + return; + } if (activeView === "duplicates") { const { selectedFolderId, duplicateScanFolderId } = get(); if (duplicateScanFolderId !== selectedFolderId) { @@ -1740,6 +1752,28 @@ 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, + }; + }); + }); + + const unlistenFolderCounts = await listen("folder-counts-changed", () => { + void get().loadFolders(); + }); + return () => { unlistenProgress(); unlistenMediaJobs(); @@ -1747,6 +1781,8 @@ export const useGalleryStore = create((set, get) => ({ unlistenTaggerModelProgress(); unlistenImages(); unlistenThumbnails(); + unlistenWatcherDeleted(); + unlistenFolderCounts(); }; }, }));