feat(versions): link at ingest, hide in listings, version endpoints

XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention
links at ingest; unlinked name candidates are retried each tick. Versions
are hidden from /photos (SQL for the recursive listing, hidden-row set for
the walk and tag branches), /memories, reels, /photos/exif, the GPS summary,
unified search and the media gauges while their original is reachable in the
view; include_versions=true lists them. /photos gains the parallel arrays
photo_display_paths / photo_version_counts, memories / search hits /
/photos/exif rows gain display_path / version_count, all skipped when empty.
GET /image/versions and PUT /image/versions/display (library optional).
Tags and favorites on a version path bind to the original; faces, CLIP and
duplicate grouping skip version hashes; insight generation redirects to the
original. Orphan GC and the back-ref refresh cover the new tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 11:28:34 -04:00
1 parent 044240037a
commit 080a75c336
24 files changed
+1614 -47

No files matched your search

+19 -6
View File
@@ -170,6 +170,10 @@ pub trait VersionDao: Sync + Send {
/// The link row for a version hash, if any.
fn get_link(&mut self, version_hash: &str) -> Result<Option<PhotoVersionRow>, DbError>;
/// If `rel_path` is a linked version whose original is still indexed,
/// the original's rel_path (same library when possible).
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, DbError>;
/// Which of `hashes` are versions of something.
fn version_hashes_among(&mut self, hashes: &[String]) -> Result<HashSet<String>, DbError>;
@@ -191,8 +195,11 @@ pub trait VersionDao: Sync + Send {
fn get_display(&mut self, original_hash: &str) -> Result<Option<String>, DbError>;
/// Set (`Some`) or clear (`None`) the global display choice.
fn set_display(&mut self, original_hash: &str, display_hash: Option<&str>)
-> Result<(), DbError>;
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
@@ -406,6 +413,15 @@ impl VersionDao for SqliteVersionDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn original_path_of(&mut self, rel_path: &str) -> Result<Option<String>, DbError> {
trace_db_call("query", "original_path_of", |_span| {
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
canonical_rel_path(&mut conn, 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> {
trace_db_call("query", "version_hashes_among", |_span| {
#[derive(QueryableByName)]
@@ -912,10 +928,7 @@ mod tests {
);
// 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"]
);
assert_eq!(visible(&[]), vec!["a/orig.jpg.restored.png", "b/other.jpg"]);
}
#[test]