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:
1 parent
aefc4bc411
commit
0e1fdd43e5
3 files changed
+122
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user