image_exif: manual date_taken override (set/clear endpoints)
Add `POST /image/exif/date` and `POST /image/exif/date/clear` so an operator can correct a row whose canonical-date waterfall landed on the wrong value (camera clock reset, fs_time fallback for a copied-from- backup file, etc). New `original_date_taken` / `original_date_taken_source` columns snapshot the prior value on first override so revert is lossless. The waterfall source set is now `'exif' | 'exiftool' | 'filename' | 'fs_time' | 'manual'`. The existing `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 drain — no index change needed. `ExifMetadata` now exposes `date_taken_source` + originals so a UI can render "manually set; was X via filename". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ImageExif, DbError>;
|
||||
|
||||
/// 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<ImageExif, DbError>;
|
||||
|
||||
/// 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<ImageExif, DbError> {
|
||||
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::<ImageExif>(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<ImageExif, DbError> {
|
||||
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::<Option<i64>>(None),
|
||||
original_date_taken_source.eq::<Option<String>>(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::<ImageExif>(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,
|
||||
|
||||
@@ -105,7 +105,15 @@ pub struct ImageExif {
|
||||
/// Unix seconds at which the resolve was committed.
|
||||
pub duplicate_decided_at: Option<i64>,
|
||||
/// 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<String>,
|
||||
/// 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<i64>,
|
||||
/// Snapshot of the prior `date_taken_source` taken on first manual
|
||||
/// override. NULL when no override is active.
|
||||
pub original_date_taken_source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Insertable)]
|
||||
|
||||
@@ -126,6 +126,8 @@ diesel::table! {
|
||||
duplicate_of_hash -> Nullable<Text>,
|
||||
duplicate_decided_at -> Nullable<BigInt>,
|
||||
date_taken_source -> Nullable<Text>,
|
||||
original_date_taken -> Nullable<BigInt>,
|
||||
original_date_taken_source -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user