feat: content-hash-aware tag/insight sharing + library scoping

Tags and insights now follow content across libraries via content_hash
lookups on the read path, so the same file indexed at different rel_paths
in multiple libraries shares its annotations. Recursive tag search scopes
hits to the selected library by checking each tagged rel_path against
the library's disk (with a content-hash sibling fallback so tags attached
under one library's rel_path still match a content-equivalent file in
another). The /image and /image/metadata handlers fall back across
libraries when the file isn't under the resolved one, so union-mode
search results (which carry no library attribution in the response)
still serve correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cameron
2026-04-17 18:06:02 -04:00
parent d79fe1ae35
commit 61f98066f6
6 changed files with 376 additions and 8 deletions

View File

@@ -341,6 +341,45 @@ pub trait ExifDao: Sync + Send {
context: &opentelemetry::Context,
hash: &str,
) -> Result<Option<ImageExif>, DbError>;
/// Given a file instance `(library_id, rel_path)`, return every distinct
/// rel_path in `image_exif` whose `content_hash` matches this file's.
/// Used by tag and insight read-paths so annotations follow content
/// rather than path, even when the same file is indexed under
/// different library roots. Falls back to `[rel_path]` when the file
/// hasn't been hashed yet.
fn get_rel_paths_sharing_content(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
rel_path: &str,
) -> Result<Vec<String>, DbError>;
/// All rel_paths known to live in a given library. Used by search to
/// scope tag-based (path-keyed) hits to a single library after joining
/// through the library-agnostic tag tables.
fn get_rel_paths_for_library(
&mut self,
context: &opentelemetry::Context,
library_id: i32,
) -> Result<Vec<String>, DbError>;
/// Look up a content_hash for a rel_path in *any* library. Useful when
/// the caller has a library-agnostic rel_path (e.g. from tagged_photo)
/// and wants to find content-equivalent siblings without knowing the
/// file's original library.
fn find_content_hash_anywhere(
&mut self,
context: &opentelemetry::Context,
rel_path: &str,
) -> Result<Option<String>, DbError>;
/// Given a content_hash, return all rel_paths carrying that hash.
fn get_rel_paths_by_hash(
&mut self,
context: &opentelemetry::Context,
hash: &str,
) -> Result<Vec<String>, DbError>;
}
pub struct SqliteExifDao {
@@ -775,4 +814,103 @@ impl ExifDao for SqliteExifDao {
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_rel_paths_sharing_content(
&mut self,
context: &opentelemetry::Context,
library_id_val: i32,
rel_path_val: &str,
) -> Result<Vec<String>, DbError> {
trace_db_call(context, "query", "get_rel_paths_sharing_content", |_span| {
use schema::image_exif::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
// Look up this file's content_hash. Missing row or NULL hash
// means we can't expand the match set; return the given
// rel_path so callers fall through to direct-match behavior.
let hash: Option<String> = image_exif
.filter(library_id.eq(library_id_val))
.filter(rel_path.eq(rel_path_val))
.select(content_hash)
.first::<Option<String>>(connection.deref_mut())
.optional()
.map_err(|_| anyhow::anyhow!("Query error"))?
.flatten();
let paths = match hash {
Some(h) => image_exif
.filter(content_hash.eq(h))
.select(rel_path)
.distinct()
.load::<String>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))?,
None => vec![rel_path_val.to_string()],
};
Ok(paths)
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_rel_paths_for_library(
&mut self,
context: &opentelemetry::Context,
library_id_val: i32,
) -> Result<Vec<String>, DbError> {
trace_db_call(context, "query", "get_rel_paths_for_library", |_span| {
use schema::image_exif::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.filter(library_id.eq(library_id_val))
.select(rel_path)
.load::<String>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn find_content_hash_anywhere(
&mut self,
context: &opentelemetry::Context,
rel_path_val: &str,
) -> Result<Option<String>, DbError> {
trace_db_call(context, "query", "find_content_hash_anywhere", |_span| {
use schema::image_exif::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.filter(rel_path.eq(rel_path_val))
.filter(content_hash.is_not_null())
.select(content_hash)
.first::<Option<String>>(connection.deref_mut())
.optional()
.map(|opt| opt.flatten())
.map_err(|_| anyhow::anyhow!("Query error"))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn get_rel_paths_by_hash(
&mut self,
context: &opentelemetry::Context,
hash: &str,
) -> Result<Vec<String>, DbError> {
trace_db_call(context, "query", "get_rel_paths_by_hash", |_span| {
use schema::image_exif::dsl::*;
let mut connection = self.connection.lock().expect("Unable to get ExifDao");
image_exif
.filter(content_hash.eq(hash))
.select(rel_path)
.distinct()
.load::<String>(connection.deref_mut())
.map_err(|_| anyhow::anyhow!("Query error"))
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
}