mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge pull request #63 from storytold/fix/issue-36-smart-album-counts
Issue #36: cache smart-album counts in the sidebar
This commit is contained in:
6 files changed
+114
-3
No files matched your search
@@ -289,7 +289,8 @@ impl Catalog {
|
||||
/// Number of photos shown for an album in the sources list (excludes deleted photos).
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.album_photos(id).len(),
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
|
||||
@@ -174,6 +174,11 @@ impl Filter {
|
||||
if v.is_empty() { "all photos".into() } else { v.join(", ") }
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock (only "in the last…" rules do).
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now)
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
|
||||
@@ -341,6 +341,15 @@ impl RuleSet {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether matches depend on the clock ("in the last…" rules, nested groups included): the
|
||||
/// same photos can enter or leave the set without any catalog change.
|
||||
pub fn depends_on_now(&self) -> bool {
|
||||
self.rules.iter().any(|r| match r {
|
||||
Rule::Group { group } => group.depends_on_now(),
|
||||
Rule::Field { op, .. } => op == "inLast" || op == "notInLast",
|
||||
})
|
||||
}
|
||||
|
||||
/// Unknown fields or operators (for command validation), as readable messages.
|
||||
pub fn problems(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -658,6 +658,9 @@ pub struct Caches {
|
||||
counts: Option<(u64, LibraryCounts)>,
|
||||
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
|
||||
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
|
||||
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
|
||||
/// How often the album counts were recomputed (tests check that unchanged frames don't).
|
||||
pub album_count_scans: usize,
|
||||
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
|
||||
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
|
||||
}
|
||||
@@ -779,4 +782,95 @@ impl Caches {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Every album's photo count for the sidebar (a smart album evaluates its rules, which
|
||||
/// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules
|
||||
/// all bump its revision) and — only while some smart album has an "in the last…" rule —
|
||||
/// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock
|
||||
/// within a minute without rescanning every frame.
|
||||
pub fn album_counts(
|
||||
&mut self,
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
now: &str,
|
||||
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> {
|
||||
let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now()));
|
||||
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
|
||||
let k = key_of((cat.revision, minute));
|
||||
match &self.album_counts {
|
||||
Some((h, v)) if *h == k => v.clone(),
|
||||
_ => {
|
||||
if relative {
|
||||
// "in the last N days" counts back from the session clock, as in the grid
|
||||
lightcraft_catalog::rules::set_now(Some(now.to_string()));
|
||||
}
|
||||
let v: std::sync::Arc<std::collections::HashMap<_, _>> =
|
||||
std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect());
|
||||
self.album_count_scans += 1;
|
||||
self.album_counts = Some((k, v.clone()));
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_tests {
|
||||
use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source};
|
||||
|
||||
fn photo(id: u64, captured: &str, rating: u8) -> Op {
|
||||
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some(captured.into());
|
||||
p.rating = rating;
|
||||
Op::AddPhoto { photo: Box::new(p) }
|
||||
}
|
||||
|
||||
fn smart(id: u64, rules: serde_json::Value) -> Op {
|
||||
let mut a = Album::new(AlbumId(id), format!("smart {id}"));
|
||||
let rules: RuleSet = serde_json::from_value(rules).unwrap();
|
||||
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
|
||||
/// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for
|
||||
/// "in the last…" rules) the clock crossing into a new minute recompute them.
|
||||
#[test]
|
||||
fn smart_album_counts_are_cached_until_something_changes() {
|
||||
let mut cat = Catalog::default();
|
||||
let mut c = super::Caches::default();
|
||||
for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() {
|
||||
cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap();
|
||||
}
|
||||
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
|
||||
cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap();
|
||||
let now = "2026-09-30T12:00:00";
|
||||
let n = c.album_counts(&cat, now);
|
||||
assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1));
|
||||
assert_eq!(c.album_count_scans, 1);
|
||||
for _ in 0..10 {
|
||||
c.album_counts(&cat, "2026-09-30T12:00:40");
|
||||
}
|
||||
assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan");
|
||||
// metadata change
|
||||
cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap();
|
||||
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2);
|
||||
// rule change
|
||||
let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap();
|
||||
cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap();
|
||||
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1);
|
||||
// an hour later the 11:59:30 photo has left "in the last hour", with no catalog change
|
||||
assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0);
|
||||
assert_eq!(c.album_count_scans, 4);
|
||||
lightcraft_catalog::rules::set_now(None);
|
||||
}
|
||||
|
||||
/// Without "in the last…" rules the clock never causes a rescan.
|
||||
#[test]
|
||||
fn absolute_rules_ignore_the_clock() {
|
||||
let mut cat = Catalog::default();
|
||||
let mut c = super::Caches::default();
|
||||
cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap();
|
||||
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
|
||||
c.album_counts(&cat, "2026-09-30T12:00:00");
|
||||
c.album_counts(&cat, "2027-01-01T00:00:00");
|
||||
assert_eq!(c.album_count_scans, 1);
|
||||
}
|
||||
}
|
||||
@@ -391,7 +391,9 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent
|
||||
} else {
|
||||
let sel = app.session.source == LibrarySource::Album(a.id);
|
||||
let icon = if a.is_smart() { Icon::SmartAlbum } else { Icon::Album };
|
||||
let n = app.session.catalog.album_count(a.id);
|
||||
// cached: a smart album's count scans the catalog
|
||||
let now = (app.session.clock)();
|
||||
let n = app.caches.album_counts(&app.session.catalog, &now).get(&a.id).copied().unwrap_or(0);
|
||||
// the album B adds to is marked "+"
|
||||
let target =
|
||||
app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection());
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters |
|
||||
| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort |
|
||||
| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) |
|
||||
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs` | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`) |
|
||||
| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists |
|
||||
| LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |
|
||||
| LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library |
|
||||
| LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS |
|
||||
|
||||
Reference in new issue
Block a user