Redirect manual date edits on a version path to its original (#194)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e1fdd43e5
commit
51600f900d
3 files changed
+129
-2
No files matched your search
@@ -188,6 +188,15 @@ pub trait VersionDao: Sync + Send {
|
|||||||
/// the original's rel_path (same library when possible).
|
/// the original's rel_path (same library when possible).
|
||||||
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, DbError>;
|
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, 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<Option<(i32, String)>, DbError>;
|
||||||
|
|
||||||
/// Which of `hashes` are versions of something.
|
/// Which of `hashes` are versions of something.
|
||||||
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError>;
|
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError>;
|
||||||
|
|
||||||
@@ -304,13 +313,25 @@ pub fn canonical_rel_path(
|
|||||||
library_id: Option<i32>,
|
library_id: Option<i32>,
|
||||||
rel_path: &str,
|
rel_path: &str,
|
||||||
) -> QueryResult<Option<String>> {
|
) -> QueryResult<Option<String>> {
|
||||||
|
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<i32>,
|
||||||
|
rel_path: &str,
|
||||||
|
) -> QueryResult<Option<(i32, String)>> {
|
||||||
#[derive(QueryableByName)]
|
#[derive(QueryableByName)]
|
||||||
struct Row {
|
struct Row {
|
||||||
|
#[diesel(sql_type = Integer)]
|
||||||
|
library_id: i32,
|
||||||
#[diesel(sql_type = Text)]
|
#[diesel(sql_type = Text)]
|
||||||
rel_path: String,
|
rel_path: String,
|
||||||
}
|
}
|
||||||
let rows = sql_query(
|
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 photo_versions pv ON pv.version_hash = v.content_hash \
|
||||||
JOIN image_exif o ON o.content_hash = pv.original_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) \
|
WHERE v.rel_path = ?1 AND (?2 IS NULL OR v.library_id = ?2) \
|
||||||
@@ -323,7 +344,7 @@ pub fn canonical_rel_path(
|
|||||||
.bind::<Text, _>(rel_path)
|
.bind::<Text, _>(rel_path)
|
||||||
.bind::<Nullable<Integer>, _>(library_id)
|
.bind::<Nullable<Integer>, _>(library_id)
|
||||||
.load::<Row>(conn)?;
|
.load::<Row>(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
|
/// 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))
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn original_location_of(
|
||||||
|
&mut self,
|
||||||
|
library_id: i32,
|
||||||
|
rel_path: &str,
|
||||||
|
) -> Result<Option<(i32, String)>, 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<HashSet<String>, DbError> {
|
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError> {
|
||||||
trace_db_call("query", "version_hashes_among", |_span| {
|
trace_db_call("query", "version_hashes_among", |_span| {
|
||||||
#[derive(QueryableByName)]
|
#[derive(QueryableByName)]
|
||||||
|
|||||||
@@ -926,6 +926,29 @@ fn earliest_fs_time(response: &MetadataResponse) -> Option<i64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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")]
|
#[post("/image/exif/date")]
|
||||||
#[tracing::instrument(name = "set_image_date", skip_all)]
|
#[tracing::instrument(name = "set_image_date", skip_all)]
|
||||||
pub async fn set_image_date(
|
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
|
// Path normalization matches set_image_gps so a Windows-import client
|
||||||
// doesn't end up with a backslash variant that misses the row.
|
// doesn't end up with a backslash variant that misses the row.
|
||||||
let normalized_path = body.path.replace('\\', "/");
|
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
|
// 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
|
// 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 normalized_path = body.path.replace('\\', "/");
|
||||||
|
let (library, normalized_path) = date_edit_target(&app_state, library, &normalized_path);
|
||||||
|
|
||||||
let updated = {
|
let updated = {
|
||||||
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
||||||
|
|||||||
@@ -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]
|
#[actix_rt::test]
|
||||||
async fn photos_without_versions_is_unchanged() {
|
async fn photos_without_versions_is_unchanged() {
|
||||||
let fx = fx();
|
let fx = fx();
|
||||||
|
|||||||
Reference in new issue
Block a user