diff --git a/migrations/2026-10-30-000000_add_photo_versions/down.sql b/migrations/2026-10-30-000000_add_photo_versions/down.sql new file mode 100644 index 0000000..b09661a --- /dev/null +++ b/migrations/2026-10-30-000000_add_photo_versions/down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS photo_version_display; +DROP TABLE IF EXISTS photo_version_references; +DROP TABLE IF EXISTS photo_versions; diff --git a/migrations/2026-10-30-000000_add_photo_versions/up.sql b/migrations/2026-10-30-000000_add_photo_versions/up.sql new file mode 100644 index 0000000..9bfcf67 --- /dev/null +++ b/migrations/2026-10-30-000000_add_photo_versions/up.sql @@ -0,0 +1,41 @@ +-- Photo versions (#191): an AI restoration, upscale or edit that sits on +-- disk near its original and is shown as the *same photo*. +-- +-- The link is hash-keyed like every other derived table (CLAUDE.md → +-- "Multi-library data model"): a version records the blake3 +-- `content_hash` of its primary original, so the link survives moves, +-- renames and an archive handoff. File name and location are hints only. +-- The version keeps its own `image_exif` row, so it still downloads and +-- thumbnails on its own; listings hide it while its original is reachable +-- in the current view (same rule as `duplicate_of_hash`). + +-- A file is a version of at most one original. +CREATE TABLE photo_versions ( + version_hash TEXT PRIMARY KEY NOT NULL, + original_hash TEXT NOT NULL, + kind TEXT NOT NULL, -- restored | upscale | edit + source TEXT NOT NULL, -- xmp | name | backfill | api + created_at BIGINT NOT NULL, + -- Denormalized back-ref to a live image_exif row for the version, kept + -- current by the back-ref refresh pass (same pattern as face_detections). + library_id INTEGER NOT NULL, + rel_path TEXT NOT NULL +); +CREATE INDEX idx_photo_versions_original ON photo_versions(original_hash); + +-- Reference photos (extra context fed to the model). Provenance only: a +-- reference is never hidden or grouped because of the version. +CREATE TABLE photo_version_references ( + version_hash TEXT NOT NULL, + reference_hash TEXT NOT NULL, + PRIMARY KEY (version_hash, reference_hash) +); +CREATE INDEX idx_photo_version_references_ref ON photo_version_references(reference_hash); + +-- The global (not per-user) choice of which file represents a photo. +-- No row = show the original. +CREATE TABLE photo_version_display ( + original_hash TEXT PRIMARY KEY NOT NULL, + display_hash TEXT NOT NULL, + decided_at BIGINT NOT NULL +); diff --git a/src/database/mod.rs b/src/database/mod.rs index afa62f0..f08e893 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -54,6 +54,7 @@ pub mod reconcile; pub mod schema; pub mod search_dao; pub mod user_ai_prefs_dao; +pub mod version_dao; pub use calendar_dao::{CalendarEventDao, SqliteCalendarEventDao}; pub use daily_summary_dao::{DailySummaryDao, InsertDailySummary, SqliteDailySummaryDao}; @@ -71,6 +72,7 @@ pub use precomputed_reel_dao::{PrecomputedReelDao, SqlitePrecomputedReelDao}; pub use preview_dao::{PreviewDao, SqlitePreviewDao}; pub use search_dao::{SearchHistoryDao, SqliteSearchHistoryDao}; pub use user_ai_prefs_dao::{SqliteUserAiPrefsDao, UserAiPrefsDao}; +pub use version_dao::{SqliteVersionDao, VersionDao}; pub trait UserDao { fn create_user(&mut self, user: &str, password: &str) -> Option; diff --git a/src/database/schema.rs b/src/database/schema.rs index e2d4f7e..ed2cb02 100644 --- a/src/database/schema.rs +++ b/src/database/schema.rs @@ -345,6 +345,33 @@ diesel::table! { } } +diesel::table! { + photo_version_display (original_hash) { + original_hash -> Text, + display_hash -> Text, + decided_at -> BigInt, + } +} + +diesel::table! { + photo_version_references (version_hash, reference_hash) { + version_hash -> Text, + reference_hash -> Text, + } +} + +diesel::table! { + photo_versions (version_hash) { + version_hash -> Text, + original_hash -> Text, + kind -> Text, + source -> Text, + created_at -> BigInt, + library_id -> Integer, + rel_path -> Text, + } +} + diesel::table! { precomputed_reels (id) { id -> Integer, @@ -392,6 +419,9 @@ diesel::allow_tables_to_appear_in_same_query!( personas, persons, photo_insights, + photo_version_display, + photo_version_references, + photo_versions, precomputed_reels, search_history, tagged_photo, diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs new file mode 100644 index 0000000..56f65c7 --- /dev/null +++ b/src/database/version_dao.rs @@ -0,0 +1,1079 @@ +//! Photo versions (#191): hash-keyed links from an AI restoration / upscale +//! / edit to its primary original, the global display choice, and the SQL +//! the listings use to hide a version while its original is reachable. +//! +//! The link is the blake3 `content_hash` of the original (CLAUDE.md → +//! "Multi-library data model"). A version keeps its own `image_exif` row, +//! so `/image?path=` and thumbnails keep working; only listings +//! hide it. `photo_versions` is tiny next to `image_exif`, so reads that +//! need "which files are versions" load that set instead of joining per row. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use diesel::prelude::*; +use diesel::sql_query; +use diesel::sql_types::{BigInt, Integer, Nullable, Text}; +use diesel::sqlite::SqliteConnection; + +use crate::database::{DbError, DbErrorKind, connect}; +use crate::otel::trace_db_call; + +/// The `kind` values a version may carry. +pub const KINDS: &[&str] = &["restored", "upscale", "edit"]; + +/// `date_taken_source` on a version row whose date, GPS and camera fields +/// were copied from its original. +pub const DATE_SOURCE_VERSION_OF: &str = "version_of"; + +/// `photo_versions.source`: how the link was learned. +pub const SOURCE_XMP: &str = "xmp"; +pub const SOURCE_NAME: &str = "name"; +pub const SOURCE_BACKFILL: &str = "backfill"; + +pub fn is_known_kind(kind: &str) -> bool { + KINDS.contains(&kind) +} + +/// SQL predicate over an unaliased `image_exif` row: true unless the row is +/// a version that is hidden in the view (`view_library_ids` empty = every +/// library). +/// +/// Same reachability rule as `duplicate_of_hash` in +/// `list_rel_paths_for_libraries`: a version hides only while a row for its +/// original exists in the view, so a version whose original was deleted, or +/// lives in a library outside the view, stays visible as a normal photo. +/// (Unlike duplicates the unscoped view still needs the existence check: +/// the original may be gone.) Library ids are `i32`, so inlining the list +/// is safe. +pub fn visible_version_clause(view_library_ids: &[i32]) -> String { + let scope = if view_library_ids.is_empty() { + String::new() + } else { + let list = view_library_ids + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!(" AND orig.library_id IN ({list})") + }; + format!( + "NOT EXISTS (SELECT 1 FROM photo_versions pv \ + JOIN image_exif orig ON orig.content_hash = pv.original_hash \ + WHERE pv.version_hash = image_exif.content_hash{scope})" + ) +} + +/// SQL predicate over an unaliased `image_exif` row: true unless the row's +/// hash is a version at all. For derived work (faces, CLIP, duplicates) +/// that never runs on a version, whatever the view. +pub const NOT_A_VERSION_CLAUSE: &str = "NOT EXISTS (SELECT 1 FROM photo_versions pv \ + WHERE pv.version_hash = image_exif.content_hash)"; + +/// Same as [`NOT_A_VERSION_CLAUSE`] for a raw-SQL query that aliases +/// `image_exif` as `alias`. +pub fn not_a_version_clause_for(alias: &str) -> String { + format!( + "NOT EXISTS (SELECT 1 FROM photo_versions pv WHERE pv.version_hash = {alias}.content_hash)" + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, QueryableByName, serde::Serialize)] +pub struct PhotoVersionRow { + #[diesel(sql_type = Text)] + pub version_hash: String, + #[diesel(sql_type = Text)] + pub original_hash: String, + #[diesel(sql_type = Text)] + pub kind: String, + #[diesel(sql_type = Text)] + pub source: String, + #[diesel(sql_type = BigInt)] + pub created_at: i64, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, +} + +/// A link to record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewVersionLink { + pub version_hash: String, + pub original_hash: String, + pub kind: String, + pub source: String, + /// Back-ref to a live `image_exif` row of the version. + pub library_id: i32, + pub rel_path: String, + /// Hashes of reference photos (provenance only). + pub references: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkOutcome { + /// A new link was recorded. + Linked, + /// The version already had a link; it was left alone. + AlreadyLinked, + /// The link makes no sense (self-link, unknown kind, cycle). + Rejected(&'static str), +} + +/// A live version file of an original, for `GET /image/versions`. +#[derive(Debug, Clone, QueryableByName)] +pub struct VersionFile { + #[diesel(sql_type = Text)] + pub version_hash: String, + #[diesel(sql_type = Text)] + pub kind: String, + #[diesel(sql_type = Text)] + pub source: String, + #[diesel(sql_type = BigInt)] + pub created_at: i64, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, + #[diesel(sql_type = Nullable)] + pub width: Option, + #[diesel(sql_type = Nullable)] + pub height: Option, +} + +/// One file (some `image_exif` row) for a hash. +#[derive(Debug, Clone, PartialEq, Eq, QueryableByName)] +pub struct HashedFile { + #[diesel(sql_type = Text)] + pub content_hash: String, + #[diesel(sql_type = Integer)] + pub library_id: i32, + #[diesel(sql_type = Text)] + pub rel_path: String, +} + +/// What `/photos` adds next to an original: how many live versions it has +/// and, if the global choice isn't the original, the file to display. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PhotoVersionSummary { + pub version_count: u32, + /// `(library_id, rel_path)` of the chosen version file. + pub display: Option<(i32, String)>, +} + +pub trait VersionDao: Sync + Send { + /// Record a link (and its reference photos). Flattens chains: an + /// `original_hash` that is itself a version is replaced by its root, and + /// versions already pointing at the newly linked file move up to the root. + fn link_version(&mut self, link: &NewVersionLink) -> Result; + + /// The link row for a version hash, if any. + fn get_link(&mut self, version_hash: &str) -> Result, DbError>; + + /// Which of `hashes` are versions of something. + fn version_hashes_among(&mut self, hashes: &[String]) -> Result, DbError>; + + /// Live versions of an original (those with an `image_exif` row), + /// oldest first. + fn list_versions(&mut self, original_hash: &str) -> Result, DbError>; + + /// Reference photo hashes recorded for a version. + fn list_references(&mut self, version_hash: &str) -> Result, DbError>; + + /// First live `image_exif` row for each hash, preferring `prefer_library`. + fn files_for_hashes( + &mut self, + hashes: &[String], + prefer_library: Option, + ) -> Result, DbError>; + + /// The stored display choice for an original, if any. + fn get_display(&mut self, original_hash: &str) -> Result, DbError>; + + /// Set (`Some`) or clear (`None`) the global display choice. + fn set_display(&mut self, original_hash: &str, display_hash: Option<&str>) + -> Result<(), DbError>; + + /// Per original `(library_id, rel_path)` that has at least one live + /// version: the version count and display file. Empty when no photo has + /// versions, so `/photos` stays byte-identical. + fn photo_summaries(&mut self) -> Result, DbError>; + + /// `(library_id, rel_path)` of every version row hidden in the view + /// (`view_library_ids` empty = all libraries). For listings that don't + /// go through SQL (filesystem walk, tag search, media gauges). + fn hidden_version_rows( + &mut self, + view_library_ids: &[i32], + ) -> Result, DbError>; + + /// Rows in a library whose file name follows the version convention + /// (`.[.].`), have a hash and aren't linked yet. + /// Returns `(rel_path, content_hash)`, oldest first. + fn unlinked_name_candidates( + &mut self, + library_id: i32, + limit: i64, + ) -> Result, DbError>; + + /// Copy the original's date, GPS and camera fields onto every version + /// row whose copy is stale. Returns the number of rows updated. + /// Idempotent; only converted originals (`date_utc` set) are copied, so + /// the legacy-convention date drains never fight the copy. + fn sync_version_metadata(&mut self) -> Result; +} + +pub struct SqliteVersionDao { + connection: Arc>, +} + +impl Default for SqliteVersionDao { + fn default() -> Self { + Self::new() + } +} + +impl SqliteVersionDao { + pub fn new() -> Self { + SqliteVersionDao { + connection: Arc::new(Mutex::new(connect())), + } + } + + /// Test-only constructor sharing an already-wrapped connection, so + /// other DAOs see the rows this one writes. + #[cfg(test)] + pub fn from_shared(connection: Arc>) -> Self { + SqliteVersionDao { connection } + } +} + +/// Chunk size for `IN (...)` lists, safely under SQLite's variable limit. +const CHUNK: usize = 500; + +fn placeholders(n: usize) -> String { + vec!["?"; n].join(",") +} + +/// If `rel_path` is a linked version whose original has a live row, the +/// original's rel_path (same library when possible). Tag and favorite +/// writes on a version path bind to the original through this. +pub fn canonical_rel_path( + conn: &mut SqliteConnection, + rel_path: &str, +) -> QueryResult> { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + rel_path: String, + } + let rows = sql_query( + "SELECT 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 = ? \ + ORDER BY (o.library_id = v.library_id) DESC, o.id \ + LIMIT 1", + ) + .bind::(rel_path) + .load::(conn)?; + Ok(rows.into_iter().next().map(|r| r.rel_path)) +} + +/// Root original of `hash`: follows `photo_versions` until a hash that is +/// not itself a version. `None` on a cycle. +fn root_original(conn: &mut SqliteConnection, hash: &str) -> QueryResult> { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + original_hash: String, + } + let mut current = hash.to_string(); + for _ in 0..8 { + let next = sql_query("SELECT original_hash FROM photo_versions WHERE version_hash = ?") + .bind::(¤t) + .load::(conn)? + .into_iter() + .next(); + match next { + Some(r) => current = r.original_hash, + None => return Ok(Some(current)), + } + } + Ok(None) +} + +/// Columns the version copy takes from its original, in `UPDATE ... SET (..)` +/// order, followed by the source marker. +const COPIED_COLUMNS: &str = "date_taken, date_utc, camera_offset_minutes, date_taken_tz_source, \ + gps_latitude, gps_longitude, gps_altitude, camera_make, camera_model, lens_model, \ + focal_length, aperture, shutter_speed, iso"; + +fn copied_differs() -> String { + COPIED_COLUMNS + .split(',') + .map(|c| c.trim()) + .map(|c| format!("v.{c} IS NOT o.{c}")) + .collect::>() + .join(" OR ") +} + +impl VersionDao for SqliteVersionDao { + fn link_version(&mut self, link: &NewVersionLink) -> Result { + trace_db_call("insert", "link_version", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + if !is_known_kind(&link.kind) { + return Ok(LinkOutcome::Rejected("unknown kind")); + } + if link.version_hash == link.original_hash { + return Ok(LinkOutcome::Rejected("a file cannot be a version of itself")); + } + + conn.immediate_transaction::(|conn| { + let existing = + sql_query("SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path FROM photo_versions WHERE version_hash = ?") + .bind::(&link.version_hash) + .load::(conn)?; + if !existing.is_empty() { + return Ok(LinkOutcome::AlreadyLinked); + } + + // Flatten: link to the root, never to another version. + let Some(root) = root_original(conn, &link.original_hash)? else { + return Ok(LinkOutcome::Rejected("link would form a cycle")); + }; + if root == link.version_hash { + return Ok(LinkOutcome::Rejected("link would form a cycle")); + } + + sql_query( + "INSERT INTO photo_versions \ + (version_hash, original_hash, kind, source, created_at, library_id, rel_path) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind::(&link.version_hash) + .bind::(&root) + .bind::(&link.kind) + .bind::(&link.source) + .bind::(chrono::Utc::now().timestamp()) + .bind::(link.library_id) + .bind::(&link.rel_path) + .execute(conn)?; + + // Versions that pointed at this file now point at the root. + sql_query("UPDATE photo_versions SET original_hash = ? WHERE original_hash = ?") + .bind::(&root) + .bind::(&link.version_hash) + .execute(conn)?; + // A display choice made for the file that just became a + // version is meaningless. + sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") + .bind::(&link.version_hash) + .execute(conn)?; + + for reference in &link.references { + // The primary original is not a reference to itself. + if *reference == root || *reference == link.version_hash { + continue; + } + sql_query( + "INSERT OR IGNORE INTO photo_version_references \ + (version_hash, reference_hash) VALUES (?, ?)", + ) + .bind::(&link.version_hash) + .bind::(reference) + .execute(conn)?; + } + Ok(LinkOutcome::Linked) + }) + }) + .map_err(|e| DbError::log(DbErrorKind::InsertError, e)) + } + + fn get_link(&mut self, version_hash: &str) -> Result, DbError> { + trace_db_call("query", "get_version_link", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT version_hash, original_hash, kind, source, created_at, library_id, rel_path \ + FROM photo_versions WHERE version_hash = ?", + ) + .bind::(version_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().next()) + .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)] + struct Row { + #[diesel(sql_type = Text)] + version_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + let mut out = HashSet::new(); + for chunk in hashes.chunks(CHUNK) { + let sql = format!( + "SELECT version_hash FROM photo_versions WHERE version_hash IN ({})", + placeholders(chunk.len()) + ); + let mut q = sql_query(sql).into_boxed(); + for h in chunk { + q = q.bind::(h); + } + let rows = q + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + out.extend(rows.into_iter().map(|r| r.version_hash)); + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn list_versions(&mut self, original_hash: &str) -> Result, DbError> { + trace_db_call("query", "list_versions", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT pv.version_hash AS version_hash, pv.kind AS kind, pv.source AS source, \ + pv.created_at AS created_at, e.library_id AS library_id, \ + e.rel_path AS rel_path, e.width AS width, e.height AS height \ + FROM photo_versions pv \ + JOIN image_exif e ON e.id = \ + (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.version_hash) \ + WHERE pv.original_hash = ? \ + ORDER BY pv.created_at, e.rel_path", + ) + .bind::(original_hash) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn list_references(&mut self, version_hash: &str) -> Result, DbError> { + trace_db_call("query", "list_version_references", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + reference_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query( + "SELECT reference_hash FROM photo_version_references \ + WHERE version_hash = ? ORDER BY reference_hash", + ) + .bind::(version_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().map(|r| r.reference_hash).collect()) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn files_for_hashes( + &mut self, + hashes: &[String], + prefer_library: Option, + ) -> Result, DbError> { + trace_db_call("query", "version_files_for_hashes", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + let mut out: HashMap = HashMap::new(); + for chunk in hashes.chunks(CHUNK) { + let sql = format!( + "SELECT content_hash, library_id, rel_path FROM image_exif \ + WHERE content_hash IN ({}) ORDER BY id", + placeholders(chunk.len()) + ); + let mut q = sql_query(sql).into_boxed(); + for h in chunk { + q = q.bind::(h); + } + let rows = q + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + for r in rows { + let better = match out.get(&r.content_hash) { + None => true, + Some((lib, _)) => { + prefer_library == Some(r.library_id) && prefer_library != Some(*lib) + } + }; + if better { + out.insert(r.content_hash, (r.library_id, r.rel_path)); + } + } + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn get_display(&mut self, original_hash: &str) -> Result, DbError> { + trace_db_call("query", "get_version_display", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + display_hash: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query("SELECT display_hash FROM photo_version_display WHERE original_hash = ?") + .bind::(original_hash) + .load::(&mut *conn) + .map(|rows| rows.into_iter().next().map(|r| r.display_hash)) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn set_display( + &mut self, + original_hash: &str, + display_hash: Option<&str>, + ) -> Result<(), DbError> { + trace_db_call("update", "set_version_display", |_span| { + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + match display_hash { + Some(display) => sql_query( + "INSERT INTO photo_version_display (original_hash, display_hash, decided_at) \ + VALUES (?, ?, ?) \ + ON CONFLICT(original_hash) DO UPDATE SET \ + display_hash = excluded.display_hash, decided_at = excluded.decided_at", + ) + .bind::(original_hash) + .bind::(display) + .bind::(chrono::Utc::now().timestamp()) + .execute(&mut *conn), + None => sql_query("DELETE FROM photo_version_display WHERE original_hash = ?") + .bind::(original_hash) + .execute(&mut *conn), + } + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("Update error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) + } + + fn photo_summaries(&mut self) -> Result, DbError> { + trace_db_call("query", "photo_version_summaries", |_span| { + #[derive(QueryableByName)] + struct OriginalRow { + #[diesel(sql_type = Text)] + original_hash: String, + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + #[diesel(sql_type = BigInt)] + version_count: i64, + } + #[derive(QueryableByName)] + struct DisplayRow { + #[diesel(sql_type = Text)] + original_hash: String, + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + let originals = sql_query( + "SELECT o.content_hash AS original_hash, o.library_id AS library_id, \ + o.rel_path AS rel_path, \ + (SELECT COUNT(*) FROM photo_versions pv \ + WHERE pv.original_hash = o.content_hash \ + AND EXISTS (SELECT 1 FROM image_exif v \ + WHERE v.content_hash = pv.version_hash)) AS version_count \ + FROM image_exif o \ + WHERE o.content_hash IN (SELECT original_hash FROM photo_versions)", + ) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + if originals.is_empty() { + return Ok(HashMap::new()); + } + + // Only a display choice that is still a version of that original + // counts; anything else falls back to the original. + let displays = sql_query( + "SELECT d.original_hash AS original_hash, v.library_id AS library_id, \ + v.rel_path AS rel_path \ + FROM photo_version_display d \ + JOIN photo_versions pv ON pv.version_hash = d.display_hash \ + AND pv.original_hash = d.original_hash \ + JOIN image_exif v ON v.content_hash = d.display_hash \ + ORDER BY v.id", + ) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + let mut display_by_hash: HashMap> = HashMap::new(); + for d in displays { + display_by_hash + .entry(d.original_hash) + .or_default() + .push((d.library_id, d.rel_path)); + } + + let mut out = HashMap::new(); + for o in originals { + if o.version_count == 0 { + continue; + } + let display = display_by_hash.get(&o.original_hash).and_then(|files| { + files + .iter() + .find(|(lib, _)| *lib == o.library_id) + .or_else(|| files.first()) + .cloned() + }); + out.insert( + (o.library_id, o.rel_path), + PhotoVersionSummary { + version_count: o.version_count as u32, + display, + }, + ); + } + Ok(out) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn hidden_version_rows( + &mut self, + view_library_ids: &[i32], + ) -> Result, DbError> { + trace_db_call("query", "hidden_version_rows", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Integer)] + library_id: i32, + #[diesel(sql_type = Text)] + rel_path: String, + } + let scope = if view_library_ids.is_empty() { + String::new() + } else { + let list = view_library_ids + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!(" AND orig.library_id IN ({list})") + }; + let sql = format!( + "SELECT image_exif.library_id AS library_id, image_exif.rel_path AS rel_path \ + FROM image_exif \ + JOIN photo_versions pv ON pv.version_hash = image_exif.content_hash \ + WHERE EXISTS (SELECT 1 FROM image_exif orig \ + WHERE orig.content_hash = pv.original_hash{scope})" + ); + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query(sql) + .load::(&mut *conn) + .map(|rows| { + rows.into_iter() + .map(|r| (r.library_id, r.rel_path)) + .collect() + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn unlinked_name_candidates( + &mut self, + library_id: i32, + limit: i64, + ) -> Result, DbError> { + trace_db_call("query", "unlinked_name_candidates", |_span| { + #[derive(QueryableByName)] + struct Row { + #[diesel(sql_type = Text)] + rel_path: String, + #[diesel(sql_type = Text)] + content_hash: String, + } + let kinds = KINDS + .iter() + .map(|k| format!("rel_path LIKE '%.{k}.%'")) + .collect::>() + .join(" OR "); + let sql = format!( + "SELECT rel_path, content_hash FROM image_exif \ + WHERE library_id = ? AND content_hash IS NOT NULL AND ({kinds}) \ + AND NOT EXISTS (SELECT 1 FROM photo_versions pv \ + WHERE pv.version_hash = image_exif.content_hash) \ + ORDER BY id LIMIT ?" + ); + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + sql_query(sql) + .bind::(library_id) + .bind::(limit) + .load::(&mut *conn) + .map(|rows| { + rows.into_iter() + .map(|r| (r.rel_path, r.content_hash)) + .collect() + }) + .map_err(|e| anyhow::anyhow!("Query error: {}", e)) + }) + .map_err(|e| DbError::log(DbErrorKind::QueryError, e)) + } + + fn sync_version_metadata(&mut self) -> Result { + trace_db_call("update", "sync_version_metadata", |_span| { + #[derive(QueryableByName)] + struct Pair { + #[diesel(sql_type = Integer)] + vid: i32, + #[diesel(sql_type = Integer)] + oid: i32, + } + let mut conn = self.connection.lock().expect("Unable to get VersionDao"); + + // Version rows whose copy is missing or stale, against the + // earliest row of a converted original. `IS NOT` compares NULLs. + let stale = sql_query(format!( + "SELECT v.id AS vid, o.id AS oid \ + FROM image_exif v \ + JOIN photo_versions pv ON pv.version_hash = v.content_hash \ + JOIN image_exif o ON o.id = \ + (SELECT MIN(id) FROM image_exif WHERE content_hash = pv.original_hash) \ + WHERE o.date_taken IS NOT NULL AND o.date_utc IS NOT NULL \ + AND (v.date_taken_source IS NOT '{DATE_SOURCE_VERSION_OF}' OR {})", + copied_differs() + )) + .load::(&mut *conn) + .map_err(|e| anyhow::anyhow!("Query error: {}", e))?; + + for p in &stale { + sql_query(format!( + "UPDATE image_exif SET ({COPIED_COLUMNS}, date_taken_source) = \ + (SELECT {COPIED_COLUMNS}, '{DATE_SOURCE_VERSION_OF}' \ + FROM image_exif WHERE id = ?) \ + WHERE id = ?" + )) + .bind::(p.oid) + .bind::(p.vid) + .execute(&mut *conn) + .map_err(|e| anyhow::anyhow!("Update error: {}", e))?; + } + Ok(stale.len()) + }) + .map_err(|e| DbError::log(DbErrorKind::UpdateError, e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test::in_memory_db_connection; + + fn dao() -> (SqliteVersionDao, Arc>) { + let conn = Arc::new(Mutex::new(in_memory_db_connection())); + (SqliteVersionDao::from_shared(conn.clone()), conn) + } + + fn exec(conn: &Arc>, sql: &str) { + sql_query(sql) + .execute(&mut *conn.lock().unwrap()) + .unwrap_or_else(|e| panic!("{sql}: {e}")); + } + + fn insert_exif(conn: &Arc>, lib: i32, rel: &str, hash: &str) { + exec( + conn, + &format!( + "INSERT OR IGNORE INTO libraries (id, name, root_path, created_at) \ + VALUES ({lib}, 'lib{lib}', '/tmp/lib{lib}', 0)" + ), + ); + exec( + conn, + &format!( + "INSERT INTO image_exif (library_id, rel_path, created_time, last_modified, content_hash) \ + VALUES ({lib}, '{rel}', 0, 0, '{hash}')" + ), + ); + } + + fn link(vh: &str, oh: &str, rel: &str) -> NewVersionLink { + NewVersionLink { + version_hash: vh.into(), + original_hash: oh.into(), + kind: "restored".into(), + source: SOURCE_XMP.into(), + library_id: 1, + rel_path: rel.into(), + references: vec![], + } + } + + #[test] + fn link_is_recorded_once_and_rejects_nonsense() { + let (mut dao, _c) = dao(); + assert_eq!( + dao.link_version(&link("v", "o", "a/v.png")).unwrap(), + LinkOutcome::Linked + ); + assert_eq!( + dao.link_version(&link("v", "other", "a/v.png")).unwrap(), + LinkOutcome::AlreadyLinked, + "an existing link is never silently re-pointed" + ); + assert_eq!(dao.get_link("v").unwrap().unwrap().original_hash, "o"); + assert!(matches!( + dao.link_version(&link("x", "x", "a/x.png")).unwrap(), + LinkOutcome::Rejected(_) + )); + let mut bad_kind = link("y", "o", "a/y.png"); + bad_kind.kind = "bogus".into(); + assert!(matches!( + dao.link_version(&bad_kind).unwrap(), + LinkOutcome::Rejected(_) + )); + } + + #[test] + fn chains_are_flattened_to_the_root() { + let (mut dao, _c) = dao(); + dao.link_version(&link("v1", "root", "v1.png")).unwrap(); + // A second-generation edit derived from the first version. + dao.link_version(&link("v2", "v1", "v2.png")).unwrap(); + assert_eq!(dao.get_link("v2").unwrap().unwrap().original_hash, "root"); + + // Linking an existing original underneath a new root moves its versions up. + dao.link_version(&link("o2", "top", "o2.png")).unwrap(); + dao.link_version(&link("child", "o2", "child.png")).unwrap(); + assert_eq!(dao.get_link("child").unwrap().unwrap().original_hash, "top"); + + // A cycle is refused. + assert!(matches!( + dao.link_version(&link("root", "v2", "root.png")).unwrap(), + LinkOutcome::Rejected(_) + )); + } + + #[test] + fn references_are_stored_but_never_the_original() { + let (mut dao, _c) = dao(); + let mut l = link("v", "o", "v.png"); + l.references = vec!["ref1".into(), "o".into(), "ref2".into(), "ref1".into()]; + dao.link_version(&l).unwrap(); + assert_eq!(dao.list_references("v").unwrap(), vec!["ref1", "ref2"]); + } + + #[test] + fn visible_clause_hides_only_while_original_is_reachable() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a/orig.jpg", "o"); + insert_exif(&c, 1, "a/orig.jpg.restored.png", "v"); + insert_exif(&c, 2, "b/other.jpg", "other"); + dao.link_version(&link("v", "o", "a/orig.jpg.restored.png")) + .unwrap(); + + let visible = |libs: &[i32]| -> Vec { + #[derive(QueryableByName)] + struct R { + #[diesel(sql_type = Text)] + rel_path: String, + } + let mut rows: Vec = sql_query(format!( + "SELECT rel_path FROM image_exif WHERE {} ORDER BY rel_path", + visible_version_clause(libs) + )) + .load::(&mut *c.lock().unwrap()) + .unwrap() + .into_iter() + .map(|r| r.rel_path) + .collect(); + rows.sort(); + rows + }; + + assert_eq!(visible(&[]), vec!["a/orig.jpg", "b/other.jpg"]); + assert_eq!(visible(&[1, 2]), vec!["a/orig.jpg", "b/other.jpg"]); + // The clause only decides hiding; the caller scopes rows to the view. + // With the view narrowed to lib 2 the pair's original is out of + // reach, so the version is not hidden. + assert!(visible(&[2]).contains(&"a/orig.jpg.restored.png".to_string())); + exec( + &c, + "UPDATE image_exif SET library_id = 2 WHERE rel_path = 'a/orig.jpg'", + ); + assert!( + visible(&[1]).contains(&"a/orig.jpg.restored.png".to_string()), + "original lives outside library 1" + ); + // Original deleted: the version shows up everywhere. + exec(&c, "DELETE FROM image_exif WHERE rel_path = 'a/orig.jpg'"); + assert_eq!( + visible(&[]), + vec!["a/orig.jpg.restored.png", "b/other.jpg"] + ); + } + + #[test] + fn hidden_rows_and_summaries_follow_the_same_rule() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v1"); + insert_exif(&c, 1, "aiedits/orig.jpg.edit.png", "v2"); + dao.link_version(&link("v1", "o", "aiedits/orig.jpg.restored.png")) + .unwrap(); + let mut l2 = link("v2", "o", "aiedits/orig.jpg.edit.png"); + l2.kind = "edit".into(); + dao.link_version(&l2).unwrap(); + + let hidden = dao.hidden_version_rows(&[]).unwrap(); + assert_eq!(hidden.len(), 2); + assert!(hidden.contains(&(1, "aiedits/orig.jpg.edit.png".to_string()))); + assert!(dao.hidden_version_rows(&[3]).unwrap().is_empty()); + + let s = dao.photo_summaries().unwrap(); + let sum = &s[&(1, "orig.jpg".to_string())]; + assert_eq!(sum.version_count, 2); + assert_eq!(sum.display, None); + + dao.set_display("o", Some("v2")).unwrap(); + let s = dao.photo_summaries().unwrap(); + assert_eq!( + s[&(1, "orig.jpg".to_string())].display, + Some((1, "aiedits/orig.jpg.edit.png".to_string())) + ); + + // A display file that vanished falls back to the original. + exec(&c, "DELETE FROM image_exif WHERE content_hash = 'v2'"); + let s = dao.photo_summaries().unwrap(); + let sum = &s[&(1, "orig.jpg".to_string())]; + assert_eq!(sum.version_count, 1); + assert_eq!(sum.display, None); + + dao.set_display("o", None).unwrap(); + assert_eq!(dao.get_display("o").unwrap(), None); + } + + #[test] + fn no_versions_means_no_summaries() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a.jpg", "a"); + assert!(dao.photo_summaries().unwrap().is_empty()); + assert!(dao.hidden_version_rows(&[]).unwrap().is_empty()); + } + + #[test] + fn canonical_path_resolves_a_version_to_its_original() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "aiedits/orig.jpg.restored.png", "v"); + dao.link_version(&link("v", "o", "aiedits/orig.jpg.restored.png")) + .unwrap(); + let mut conn = c.lock().unwrap(); + assert_eq!( + canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(), + Some("orig.jpg".to_string()) + ); + assert_eq!(canonical_rel_path(&mut conn, "orig.jpg").unwrap(), None); + drop(conn); + // Original gone: nothing to bind to, the version is a normal photo. + exec(&c, "DELETE FROM image_exif WHERE content_hash = 'o'"); + let mut conn = c.lock().unwrap(); + assert_eq!( + canonical_rel_path(&mut conn, "aiedits/orig.jpg.restored.png").unwrap(), + None + ); + } + + #[test] + fn sync_copies_date_gps_and_camera_from_a_converted_original() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "orig.jpg", "o"); + insert_exif(&c, 1, "orig.jpg.restored.png", "v"); + dao.link_version(&link("v", "o", "orig.jpg.restored.png")) + .unwrap(); + + // Unconverted original: nothing is copied (the version keeps its own date). + exec( + &c, + "UPDATE image_exif SET date_taken = 1000 WHERE content_hash = 'o'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 0); + + exec( + &c, + "UPDATE image_exif SET date_taken = 1000, date_utc = 1000, camera_offset_minutes = -240, \ + date_taken_tz_source = 'offset_tag', gps_latitude = 12.5, gps_longitude = 7.25, \ + camera_make = 'Canon', camera_model = 'EOS', date_taken_source = 'exif' \ + WHERE content_hash = 'o'", + ); + exec( + &c, + "UPDATE image_exif SET date_taken = 5, date_taken_source = 'fs_time' WHERE content_hash = 'v'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 1); + assert_eq!(dao.sync_version_metadata().unwrap(), 0, "idempotent"); + + #[derive(QueryableByName)] + struct R { + #[diesel(sql_type = Nullable)] + date_utc: Option, + #[diesel(sql_type = Nullable)] + src: Option, + #[diesel(sql_type = Nullable)] + lat: Option, + #[diesel(sql_type = Nullable)] + make: Option, + } + let row = sql_query( + "SELECT date_utc, date_taken_source AS src, gps_latitude AS lat, camera_make AS make \ + FROM image_exif WHERE content_hash = 'v'", + ) + .get_result::(&mut *c.lock().unwrap()) + .unwrap(); + assert_eq!(row.date_utc, Some(1000)); + assert_eq!(row.src.as_deref(), Some("version_of")); + assert_eq!(row.lat, Some(12.5)); + assert_eq!(row.make.as_deref(), Some("Canon")); + + // The original's date changes (manual override): the copy follows. + exec( + &c, + "UPDATE image_exif SET date_taken = 2000, date_utc = 2000 WHERE content_hash = 'o'", + ); + assert_eq!(dao.sync_version_metadata().unwrap(), 1); + } + + #[test] + fn name_candidates_skip_linked_rows() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "a/x.jpg.restored.png", "h1"); + insert_exif(&c, 1, "a/x.jpg.edit.2.png", "h2"); + insert_exif(&c, 1, "a/plain.png", "h3"); + insert_exif(&c, 2, "a/y.jpg.restored.png", "h4"); + dao.link_version(&link("h2", "ox", "a/x.jpg.edit.2.png")) + .unwrap_or(LinkOutcome::Linked); + let got = dao.unlinked_name_candidates(1, 100).unwrap(); + assert_eq!(got, vec![("a/x.jpg.restored.png".to_string(), "h1".into())]); + } + + #[test] + fn version_hashes_among_and_files_for_hashes() { + let (mut dao, c) = dao(); + insert_exif(&c, 1, "o.jpg", "o"); + insert_exif(&c, 2, "o.jpg", "o"); + dao.link_version(&link("v", "o", "v.png")).unwrap(); + let got = dao + .version_hashes_among(&["v".to_string(), "o".to_string()]) + .unwrap(); + assert_eq!(got, HashSet::from(["v".to_string()])); + let files = dao.files_for_hashes(&["o".to_string()], Some(2)).unwrap(); + assert_eq!(files["o"], (2, "o.jpg".to_string())); + let files = dao.files_for_hashes(&["o".to_string()], None).unwrap(); + assert_eq!(files["o"].0, 1); + } +}