diff --git a/migrations/2026-05-06-000200_add_manual_date_override/down.sql b/migrations/2026-05-06-000200_add_manual_date_override/down.sql new file mode 100644 index 0000000..b3a5c35 --- /dev/null +++ b/migrations/2026-05-06-000200_add_manual_date_override/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE image_exif DROP COLUMN original_date_taken_source; +ALTER TABLE image_exif DROP COLUMN original_date_taken; diff --git a/migrations/2026-05-06-000200_add_manual_date_override/up.sql b/migrations/2026-05-06-000200_add_manual_date_override/up.sql new file mode 100644 index 0000000..9a7c648 --- /dev/null +++ b/migrations/2026-05-06-000200_add_manual_date_override/up.sql @@ -0,0 +1,15 @@ +-- Manual date_taken override: when an operator overrides a row's date via +-- POST /image/exif/date, the prior `(date_taken, date_taken_source)` is +-- snapshotted into these columns and the live columns hold the new value +-- with `date_taken_source = 'manual'`. POST /image/exif/date/clear restores +-- the pair and nulls the originals. +-- +-- The waterfall source-name set is now: +-- 'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual' +-- +-- The `idx_image_exif_date_backfill` partial index already filters to +-- `date_taken IS NULL OR date_taken_source = 'fs_time'`, so 'manual' rows +-- are naturally excluded from the per-tick backfill drain — no index +-- change needed. +ALTER TABLE image_exif ADD COLUMN original_date_taken BIGINT; +ALTER TABLE image_exif ADD COLUMN original_date_taken_source TEXT; diff --git a/src/data/mod.rs b/src/data/mod.rs index 2576ed0..be931ba 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -286,6 +286,16 @@ pub struct ExifMetadata { pub gps: Option, pub capture_settings: Option, pub date_taken: Option, + /// Which step of the canonical-date waterfall populated `date_taken`: + /// `"exif" | "exiftool" | "filename" | "fs_time" | "manual"`. NULL when + /// `date_taken` itself is NULL. + pub date_taken_source: Option, + /// When `date_taken_source = "manual"`, the prior `date_taken` snapshot. + /// Used by the UI's revert affordance and to label "manually overridden; + /// originally X" in the details modal. + pub original_date_taken: Option, + /// When `date_taken_source = "manual"`, the prior source. + pub original_date_taken_source: Option, } #[derive(Debug, Serialize)] @@ -370,6 +380,9 @@ impl From for ExifMetadata { None }, date_taken: exif.date_taken, + date_taken_source: exif.date_taken_source, + original_date_taken: exif.original_date_taken, + original_date_taken_source: exif.original_date_taken_source, } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index 32127cb..eeb457e 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -439,6 +439,32 @@ pub trait ExifDao: Sync + Send { source: &str, ) -> Result<(), DbError>; + /// Operator-driven date_taken override (POST /image/exif/date). Snapshots + /// the prior `(date_taken, date_taken_source)` into the `original_*` + /// pair on first override, then writes the new value with + /// `date_taken_source = 'manual'`. Subsequent overrides keep the + /// original snapshot intact so a single revert restores the resolver + /// result, not whatever override was set just before. Returns the + /// post-update row. + fn set_manual_date_taken( + &mut self, + context: &opentelemetry::Context, + library_id: i32, + rel_path: &str, + date_taken: i64, + ) -> Result; + + /// Revert a manual override (POST /image/exif/date/clear): restore + /// `date_taken` + `date_taken_source` from the `original_*` snapshot, + /// then null both originals. No-op (returns current row unchanged) when + /// no override is active. + fn clear_manual_date_taken( + &mut self, + context: &opentelemetry::Context, + library_id: i32, + rel_path: &str, + ) -> Result; + /// Single-query backend for `/memories`. Returns /// `(rel_path, date_taken, last_modified)` for rows in `library_id` /// whose `date_taken` falls within `[now - years_back y, now]` and @@ -1185,6 +1211,108 @@ impl ExifDao for SqliteExifDao { .map_err(|_| DbError::new(DbErrorKind::UpdateError)) } + fn set_manual_date_taken( + &mut self, + context: &opentelemetry::Context, + library_id_val: i32, + rel_path_val: &str, + date_taken_val: i64, + ) -> Result { + trace_db_call(context, "update", "set_manual_date_taken", |_span| { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + // Read-modify-write under the dao mutex so the snapshot is + // consistent with the value being overwritten. The mutex holds + // for the duration of this closure — no other writer can race. + let current: ImageExif = image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)) + .first(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("row not found"))?; + + // Snapshot only on first override. Subsequent overrides keep + // the original snapshot intact so a single revert restores + // the resolver-derived value, not the prior override. + let (orig_dt, orig_src) = if current.original_date_taken.is_none() { + (current.date_taken, current.date_taken_source.clone()) + } else { + ( + current.original_date_taken, + current.original_date_taken_source.clone(), + ) + }; + + diesel::update( + image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)), + ) + .set(( + date_taken.eq(Some(date_taken_val)), + date_taken_source.eq(Some("manual".to_string())), + original_date_taken.eq(orig_dt), + original_date_taken_source.eq(orig_src), + )) + .execute(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Update error"))?; + + image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)) + .first::(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Re-read error")) + }) + .map_err(|_| DbError::new(DbErrorKind::UpdateError)) + } + + fn clear_manual_date_taken( + &mut self, + context: &opentelemetry::Context, + library_id_val: i32, + rel_path_val: &str, + ) -> Result { + trace_db_call(context, "update", "clear_manual_date_taken", |_span| { + use schema::image_exif::dsl::*; + + let mut connection = self.connection.lock().expect("Unable to get ExifDao"); + + let current: ImageExif = image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)) + .first(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("row not found"))?; + + // No override active — nothing to revert. Return the current + // row unchanged so the endpoint is idempotent. + if current.original_date_taken.is_none() { + return Ok(current); + } + + diesel::update( + image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)), + ) + .set(( + date_taken.eq(current.original_date_taken), + date_taken_source.eq(current.original_date_taken_source.clone()), + original_date_taken.eq::>(None), + original_date_taken_source.eq::>(None), + )) + .execute(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Update error"))?; + + image_exif + .filter(library_id.eq(library_id_val)) + .filter(rel_path.eq(rel_path_val)) + .first::(connection.deref_mut()) + .map_err(|_| anyhow::anyhow!("Re-read error")) + }) + .map_err(|_| DbError::new(DbErrorKind::UpdateError)) + } + fn get_memories_in_window( &mut self, context: &opentelemetry::Context, diff --git a/src/database/models.rs b/src/database/models.rs index 1e3139d..186b85a 100644 --- a/src/database/models.rs +++ b/src/database/models.rs @@ -105,7 +105,15 @@ pub struct ImageExif { /// Unix seconds at which the resolve was committed. pub duplicate_decided_at: Option, /// Which step of the canonical-date waterfall populated `date_taken`. + /// Plus `"manual"` when the operator has set it via POST /image/exif/date. pub date_taken_source: Option, + /// Snapshot of the prior `date_taken` taken on first manual override. + /// NULL when no override is active. POST /image/exif/date/clear restores + /// `date_taken` from this column and nulls it back out. + pub original_date_taken: Option, + /// Snapshot of the prior `date_taken_source` taken on first manual + /// override. NULL when no override is active. + pub original_date_taken_source: Option, } #[derive(Insertable)] diff --git a/src/database/schema.rs b/src/database/schema.rs index 9a9958a..6532a92 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -126,6 +126,8 @@ diesel::table! { duplicate_of_hash -> Nullable, duplicate_decided_at -> Nullable, date_taken_source -> Nullable, + original_date_taken -> Nullable, + original_date_taken_source -> Nullable, } } diff --git a/src/files.rs b/src/files.rs index e4fb407..7db246f 100644 --- a/src/files.rs +++ b/src/files.rs @@ -1475,6 +1475,44 @@ mod tests { struct MockExifDao; + fn mock_exif_row( + library_id: i32, + rel_path: &str, + date_taken: Option, + date_taken_source: Option, + ) -> crate::database::models::ImageExif { + crate::database::models::ImageExif { + id: 1, + library_id, + file_path: rel_path.to_string(), + camera_make: None, + camera_model: None, + lens_model: None, + width: None, + height: None, + orientation: None, + gps_latitude: None, + gps_longitude: None, + gps_altitude: None, + focal_length: None, + aperture: None, + shutter_speed: None, + iso: None, + date_taken, + created_time: 0, + last_modified: 0, + content_hash: None, + size_bytes: None, + phash_64: None, + dhash_64: None, + duplicate_of_hash: None, + duplicate_decided_at: None, + date_taken_source, + original_date_taken: None, + original_date_taken_source: None, + } + } + impl ExifDao for MockExifDao { fn store_exif( &mut self, @@ -1509,6 +1547,8 @@ mod tests { duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: data.date_taken_source.clone(), + original_date_taken: None, + original_date_taken_source: None, }) } @@ -1553,6 +1593,8 @@ mod tests { duplicate_of_hash: None, duplicate_decided_at: None, date_taken_source: data.date_taken_source.clone(), + original_date_taken: None, + original_date_taken_source: None, }) } @@ -1666,6 +1708,28 @@ mod tests { Ok(()) } + fn set_manual_date_taken( + &mut self, + _context: &opentelemetry::Context, + library_id: i32, + rel_path: &str, + date_taken: i64, + ) -> Result { + // Mock — files.rs tests don't exercise the date-override endpoints. + // Returning a synthetic row keeps the trait satisfied without + // depending on private DbError constructors. + Ok(mock_exif_row(library_id, rel_path, Some(date_taken), Some("manual".to_string()))) + } + + fn clear_manual_date_taken( + &mut self, + _context: &opentelemetry::Context, + library_id: i32, + rel_path: &str, + ) -> Result { + Ok(mock_exif_row(library_id, rel_path, None, None)) + } + fn get_memories_in_window( &mut self, _context: &opentelemetry::Context, diff --git a/src/main.rs b/src/main.rs index 9d1775d..20b3456 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,7 +41,7 @@ use urlencoding::decode; use crate::ai::InsightGenerator; use crate::auth::login; use crate::data::*; -use crate::database::models::InsertImageExif; +use crate::database::models::{ImageExif, InsertImageExif}; use crate::database::*; use crate::files::{ RealFileSystem, RefreshThumbnailsMessage, is_image_or_video, is_valid_full_path, move_file, @@ -593,6 +593,154 @@ async fn set_image_gps( } } +/// Body for `POST /image/exif/date` — operator-driven date_taken override. +/// `date_taken` is unix seconds (matches `image_exif.date_taken`'s convention +/// — naive local reinterpreted as UTC, not real UTC; the Apollo client passes +/// through the same value the photo carousel rendered before edit). +#[derive(serde::Deserialize)] +struct SetDateRequest { + path: String, + library: Option, + date_taken: i64, +} + +/// Body for `POST /image/exif/date/clear` — revert a manual override and +/// restore the resolver-derived `(date_taken, date_taken_source)` pair from +/// the snapshot. +#[derive(serde::Deserialize)] +struct ClearDateRequest { + path: String, + library: Option, +} + +/// Build a `MetadataResponse` for the date endpoints. Mirrors +/// `get_file_metadata`'s shape so the client gets a single source of truth +/// after every mutation. Filesystem metadata is best-effort: if the file is +/// on a stale mount or moved, the DB-side override still succeeds and the +/// response carries `created=None, modified=None, size=0`. The DB row's +/// updated EXIF is what matters here. +fn build_metadata_response_for_date_mutation( + library: &libraries::Library, + rel_path: &str, + exif: ImageExif, +) -> MetadataResponse { + let full_path = is_valid_full_path(&library.root_path, &rel_path.to_string(), false); + let fs_meta = full_path + .as_ref() + .filter(|p| p.exists()) + .and_then(|p| std::fs::metadata(p).ok()); + let mut response: MetadataResponse = match fs_meta { + Some(m) => m.into(), + None => MetadataResponse { + created: None, + modified: None, + size: 0, + exif: None, + filename_date: None, + library_id: None, + library_name: None, + }, + }; + response.exif = Some(exif.into()); + response.library_id = Some(library.id); + response.library_name = Some(library.name.clone()); + response.filename_date = + memories::extract_date_from_filename(rel_path).map(|dt| dt.timestamp()); + response +} + +#[post("/image/exif/date")] +async fn set_image_date( + _: Claims, + request: HttpRequest, + body: web::Json, + app_state: Data, + exif_dao: Data>>, +) -> impl Responder { + let tracer = global_tracer(); + let context = extract_context_from_request(&request); + let mut span = tracer.start_with_context("set_image_date", &context); + let span_context = + opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); + + let library = libraries::resolve_library_param(&app_state, body.library.as_deref()) + .ok() + .flatten() + .unwrap_or_else(|| app_state.primary_library()); + + // Path normalization matches set_image_gps so a Windows-import client + // doesn't end up with a backslash variant that misses the row. + let normalized_path = body.path.replace('\\', "/"); + + let updated = { + let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); + dao.set_manual_date_taken(&span_context, library.id, &normalized_path, body.date_taken) + }; + + match updated { + Ok(row) => { + span.set_status(Status::Ok); + HttpResponse::Ok().json(build_metadata_response_for_date_mutation( + &library, + &normalized_path, + row, + )) + } + Err(e) => { + let msg = format!("set_manual_date_taken failed: {:?}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + // Likely "row not found" — the file isn't indexed under this + // (library, path). 404 lets the client distinguish from a 5xx. + HttpResponse::NotFound().body(msg) + } + } +} + +#[post("/image/exif/date/clear")] +async fn clear_image_date( + _: Claims, + request: HttpRequest, + body: web::Json, + app_state: Data, + exif_dao: Data>>, +) -> impl Responder { + let tracer = global_tracer(); + let context = extract_context_from_request(&request); + let mut span = tracer.start_with_context("clear_image_date", &context); + let span_context = + opentelemetry::Context::new().with_remote_span_context(span.span_context().clone()); + + let library = libraries::resolve_library_param(&app_state, body.library.as_deref()) + .ok() + .flatten() + .unwrap_or_else(|| app_state.primary_library()); + + let normalized_path = body.path.replace('\\', "/"); + + let updated = { + let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); + dao.clear_manual_date_taken(&span_context, library.id, &normalized_path) + }; + + match updated { + Ok(row) => { + span.set_status(Status::Ok); + HttpResponse::Ok().json(build_metadata_response_for_date_mutation( + &library, + &normalized_path, + row, + )) + } + Err(e) => { + let msg = format!("clear_manual_date_taken failed: {:?}", e); + error!("{}", msg); + span.set_status(Status::error(msg.clone())); + HttpResponse::NotFound().body(msg) + } + } +} + #[derive(serde::Deserialize)] struct UploadQuery { library: Option, @@ -1697,6 +1845,8 @@ fn main() -> std::io::Result<()> { .service(delete_favorite) .service(get_file_metadata) .service(set_image_gps) + .service(set_image_date) + .service(clear_image_date) .service(memories::list_memories) .service(ai::generate_insight_handler) .service(ai::generate_agentic_insight_handler)