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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user