Hide folders holding only hidden versions from /photos dirs (#194)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-30 19:24:22 -04:00
1 parent aefc4bc411
commit 0e1fdd43e5
3 files changed
+122

No files matched your search

+76
View File
@@ -228,6 +228,17 @@ pub trait VersionDao: Sync + Send {
view_library_ids: &[i32],
) -> Result<HashSet<(i32, String)>, DbError>;
/// Of `dirs` (rel paths), those that hold indexed files and where every
/// indexed file anywhere underneath is a version hidden in the view.
/// Such a folder would open empty. A folder with no indexed rows at all
/// is not reported (nothing says it is safe to hide). One indexed range
/// scan per dir; no filesystem walk.
fn dirs_holding_only_hidden_versions(
&mut self,
view_library_ids: &[i32],
dirs: &[String],
) -> Result<HashSet<String>, DbError>;
/// Rows in a library whose file name follows the version convention
/// (`<name>.<kind>[.<n>].<ext>`), have a hash and aren't linked yet.
/// Returns `(id, rel_path, content_hash)` ordered by row id, starting
@@ -780,6 +791,71 @@ impl VersionDao for SqliteVersionDao {
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn dirs_holding_only_hidden_versions(
&mut self,
view_library_ids: &[i32],
dirs: &[String],
) -> Result<HashSet<String>, DbError> {
if dirs.is_empty() {
return Ok(HashSet::new());
}
trace_db_call("query", "dirs_holding_only_hidden_versions", |_span| {
#[derive(QueryableByName)]
struct Counts {
#[diesel(sql_type = BigInt)]
total: i64,
#[diesel(sql_type = BigInt)]
visible: i64,
}
#[derive(QueryableByName)]
struct Id {
#[diesel(sql_type = Integer)]
id: i32,
}
let mut conn = self.connection.lock().expect("Unable to get VersionDao");
// Scan the view's libraries (all of them when unscoped) so the
// (library_id, rel_path) index serves the prefix range.
let scan: Vec<i32> = if view_library_ids.is_empty() {
sql_query("SELECT id FROM libraries")
.load::<Id>(&mut *conn)?
.into_iter()
.map(|r| r.id)
.collect()
} else {
view_library_ids.to_vec()
};
let list = scan
.iter()
.map(i32::to_string)
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT COUNT(*) AS total, \
COALESCE(SUM(CASE WHEN {} THEN 1 ELSE 0 END), 0) AS visible \
FROM image_exif \
WHERE library_id IN ({list}) AND rel_path >= ? AND rel_path < ?",
visible_version_clause(view_library_ids)
);
let mut out = HashSet::new();
for dir in dirs {
let dir = dir.trim_end_matches('/');
if dir.is_empty() {
continue;
}
// '0' is the character after '/', closing the prefix range.
let c = sql_query(&sql)
.bind::<Text, _>(format!("{dir}/"))
.bind::<Text, _>(format!("{dir}0"))
.get_result::<Counts>(&mut *conn)?;
if c.total > 0 && c.visible == 0 {
out.insert(dir.to_string());
}
}
Ok(out)
})
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
}
fn unlinked_name_candidates(
&mut self,
library_id: i32,
+17
View File
@@ -725,6 +725,23 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
return HttpResponse::BadRequest().finish();
}
// A folder whose indexed contents are all hidden versions (typically
// `aiedits/`) would open empty: leave it out unless versions are asked
// for. Recursive listings are flat and carry no dirs.
if !search_recursively && !include_versions && !dirs_set.is_empty() {
let candidates: Vec<String> = dirs_set.iter().cloned().collect();
let view: Vec<i32> = library.into_iter().map(|l| l.id).collect();
match app_state
.version_dao
.lock()
.expect("Unable to lock VersionDao")
.dirs_holding_only_hidden_versions(&view, &candidates)
{
Ok(empty) => dirs_set.retain(|d| !empty.contains(d.trim_end_matches('/'))),
Err(e) => warn!("dirs_holding_only_hidden_versions failed: {:?}", e),
}
}
let dirs: Vec<String> = dirs_set.into_iter().collect();
info!(
+29
View File
@@ -433,6 +433,35 @@ async fn photos_hides_versions_and_adds_parallel_arrays() {
);
}
#[actix_rt::test]
async fn folders_holding_only_hidden_versions_are_left_out_of_dirs() {
let (fx, _o, _v1, _v2) = seeded();
// A second folder whose aiedits holds a version that is NOT hidden
// (its original isn't indexed), and a folder nothing is indexed under.
fx.put_png("2004/album_a/aiedits/lost.jpg.edit.png", None, 20);
std::fs::create_dir_all(fx.root.join("2004/not_indexed")).unwrap();
let app = photos_app!(fx);
let dirs = |body: &serde_json::Value| -> Vec<String> {
let mut d: Vec<String> = body["dirs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
d.sort();
d
};
let (_, body) = get_json!(app, "/photos?path=2004");
assert_eq!(dirs(&body), vec!["2004/album_a", "2004/not_indexed"]);
let (_, body) = get_json!(app, "/photos?path=2004&include_versions=true");
assert_eq!(
dirs(&body),
vec!["2004/aiedits", "2004/album_a", "2004/not_indexed"]
);
}
#[actix_rt::test]
async fn photos_without_versions_is_unchanged() {
let fx = fx();