From 51600f900d779050e4db3cbf3f6e9059be9939d2 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Fri, 30 Oct 2026 19:26:37 -0400 Subject: [PATCH] Redirect manual date edits on a version path to its original (#194) Co-Authored-By: Claude Opus 5.5 --- src/database/version_dao.rs | 38 ++++++++++++++++- src/handlers/image.rs | 25 ++++++++++++ src/versions/integration_tests.rs | 68 +++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index 55f43f6..f7d0f42 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -188,6 +188,15 @@ pub trait VersionDao: Sync + Send { /// the original's rel_path (same library when possible). fn original_path_of(&mut self, rel_path: &str) -> Result, DbError>; + /// `(library_id, rel_path)` of the original of the version at + /// `(library_id, rel_path)`, when it is a linked version with a live + /// original. For per-library writes that must land on the original. + fn original_location_of( + &mut self, + library_id: i32, + rel_path: &str, + ) -> Result, DbError>; + /// Which of `hashes` are versions of something. fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError>; @@ -304,13 +313,25 @@ pub fn canonical_rel_path( library_id: Option, rel_path: &str, ) -> QueryResult> { + Ok(canonical_location(conn, library_id, rel_path)?.map(|(_, p)| p)) +} + +/// [`canonical_rel_path`] with the original's library too, for callers that +/// write per-library rows (the manual date lives on the `image_exif` row). +pub fn canonical_location( + conn: &mut SqliteConnection, + library_id: Option, + rel_path: &str, +) -> QueryResult> { #[derive(QueryableByName)] struct Row { + #[diesel(sql_type = Integer)] + library_id: i32, #[diesel(sql_type = Text)] rel_path: String, } let rows = sql_query( - "SELECT o.rel_path AS rel_path FROM image_exif v \ + "SELECT o.library_id AS library_id, o.rel_path AS rel_path FROM image_exif v \ JOIN photo_versions pv ON pv.version_hash = v.content_hash \ JOIN image_exif o ON o.content_hash = pv.original_hash \ WHERE v.rel_path = ?1 AND (?2 IS NULL OR v.library_id = ?2) \ @@ -323,7 +344,7 @@ pub fn canonical_rel_path( .bind::(rel_path) .bind::, _>(library_id) .load::(conn)?; - Ok(rows.into_iter().next().map(|r| r.rel_path)) + Ok(rows.into_iter().next().map(|r| (r.library_id, r.rel_path))) } /// Fold favorites and tags recorded on a version's own path into its @@ -522,6 +543,19 @@ impl VersionDao for SqliteVersionDao { .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) } + fn original_location_of( + &mut self, + library_id: i32, + rel_path: &str, + ) -> Result, DbError> { + trace_db_call("query", "original_location_of", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + canonical_location(&mut conn, Some(library_id), rel_path) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError> { trace_db_call("query", "version_hashes_among", |_span| { #[derive(QueryableByName)] diff --git a/src/handlers/image.rs b/src/handlers/image.rs index 6525080..dfc89ec 100644 --- a/src/handlers/image.rs +++ b/src/handlers/image.rs @@ -926,6 +926,29 @@ fn earliest_fs_time(response: &MetadataResponse) -> Option { } } +/// A manual date on a photo version (#191) is overwritten by the next +/// metadata sync (`sync_version_metadata` re-copies the original's date), so +/// the edit is redirected to the original, as tag writes are. Returns the +/// `(library, path)` the edit applies to: the original's when `path` is a +/// linked version with a live original, else the inputs unchanged. +fn date_edit_target<'a>( + app_state: &'a AppState, + library: &'a libraries::Library, + path: &str, +) -> (&'a libraries::Library, String) { + let original = app_state + .version_dao + .lock() + .expect("Unable to lock VersionDao") + .original_location_of(library.id, path) + .ok() + .flatten(); + match original { + Some((lib_id, rel)) => (app_state.library_by_id(lib_id).unwrap_or(library), rel), + None => (library, path.to_string()), + } +} + #[post("/image/exif/date")] #[tracing::instrument(name = "set_image_date", skip_all)] pub async fn set_image_date( @@ -949,6 +972,7 @@ pub async fn set_image_date( // 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 (library, normalized_path) = date_edit_target(&app_state, library, &normalized_path); // The user's display zone — the wall the UI sent is rendered in this // zone (ADR-0001, ticket #115). NULL / unparseable prefs fall back to @@ -1012,6 +1036,7 @@ pub async fn clear_image_date( }; let normalized_path = body.path.replace('\\', "/"); + let (library, normalized_path) = date_edit_target(&app_state, library, &normalized_path); let updated = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs index 1e4290b..8254d3e 100644 --- a/src/versions/integration_tests.rs +++ b/src/versions/integration_tests.rs @@ -462,6 +462,74 @@ async fn folders_holding_only_hidden_versions_are_left_out_of_dirs() { ); } +#[actix_rt::test] +async fn a_manual_date_on_a_version_path_lands_on_the_original() { + let (fx, original, v1, _v2) = seeded(); + fx.exec(&format!( + "UPDATE image_exif SET date_taken = 1500000000, date_utc = 1500000000, \ + date_taken_source = 'exif' WHERE content_hash = '{original}'" + )); + let app = awtest::init_service( + App::new() + .service(crate::handlers::image::set_image_date) + .service(crate::handlers::image::clear_image_date) + .app_data(fx.state.clone()) + .app_data(fx.exif.clone()), + ) + .await; + let post = |uri: &str, body: serde_json::Value| { + awtest::TestRequest::post() + .uri(uri) + .insert_header(("Authorization", format!("Bearer {}", Fx::token()))) + .set_json(body) + .to_request() + }; + let resp = awtest::call_service( + &app, + post( + "/image/exif/date", + serde_json::json!({"path": "2004/aiedits/scan.jpg.restored.png", "date_taken": 1_400_000_000}), + ), + ) + .await; + assert_eq!(resp.status().as_u16(), 200); + + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{original}' \ + AND date_taken_source = 'manual'" + )), + 1, + "the original carries the manual date" + ); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{v1}' \ + AND date_taken_source = 'manual'" + )), + 0, + "the version row is left to the metadata sync" + ); + + // The same redirect applies to clearing it. + let resp = awtest::call_service( + &app, + post( + "/image/exif/date/clear", + serde_json::json!({"path": "2004/aiedits/scan.jpg.restored.png"}), + ), + ) + .await; + assert!(resp.status().is_success()); + assert_eq!( + fx.count(&format!( + "SELECT COUNT(*) AS n FROM image_exif WHERE content_hash = '{original}' \ + AND date_taken_source = 'manual'" + )), + 0 + ); +} + #[actix_rt::test] async fn photos_without_versions_is_unchanged() { let fx = fx();