diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 793182359..1828399b6 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -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, } diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index bac31f488..084d6d6fe 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -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; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index b59257812..f4137cb61 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -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 { let mut out = Vec::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e42ed5ea4..a9341b9b7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -658,6 +658,9 @@ pub struct Caches { counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, filter_values: Option<(u64, std::sync::Arc)>, + album_counts: Option<(u64, std::sync::Arc>)>, + /// 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)>, } @@ -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> { + 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::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); + } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 725163226..03524d46c 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -398,7 +398,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()); diff --git a/docs/parity.md b/docs/parity.md index a33caa461..84b23a043 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |