diff --git a/src/database/version_dao.rs b/src/database/version_dao.rs index 61e852b..55f43f6 100644 --- a/src/database/version_dao.rs +++ b/src/database/version_dao.rs @@ -228,6 +228,17 @@ pub trait VersionDao: Sync + Send { view_library_ids: &[i32], ) -> Result, 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, DbError>; + /// Rows in a library whose file name follows the version convention /// (`.[.].`), 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, 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 = if view_library_ids.is_empty() { + sql_query("SELECT id FROM libraries") + .load::(&mut *conn)? + .into_iter() + .map(|r| r.id) + .collect() + } else { + view_library_ids.to_vec() + }; + let list = scan + .iter() + .map(i32::to_string) + .collect::>() + .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::(format!("{dir}/")) + .bind::(format!("{dir}0")) + .get_result::(&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, diff --git a/src/files.rs b/src/files.rs index fcd398b..8f38412 100644 --- a/src/files.rs +++ b/src/files.rs @@ -725,6 +725,23 @@ pub async fn list_photos( 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 = dirs_set.iter().cloned().collect(); + let view: Vec = 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 = dirs_set.into_iter().collect(); info!( diff --git a/src/versions/integration_tests.rs b/src/versions/integration_tests.rs index b3f27e6..1e4290b 100644 --- a/src/versions/integration_tests.rs +++ b/src/versions/integration_tests.rs @@ -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 { + let mut d: Vec = 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();