From e7da5d34a25e2d8292e0242c0f7e5c21511d336b Mon Sep 17 00:00:00 2001 From: peiyan0 Date: Fri, 9 Oct 2026 16:09:23 +0800 Subject: [PATCH] catalog: group photos without capture date under Unknown Date (#437) --- crates/catalog/src/dates.rs | 16 ++++++++----- crates/catalog/src/query.rs | 18 ++++++++++----- crates/catalog/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 06d5c2c0..06e8e0f1 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -155,7 +155,7 @@ impl Catalog { let date_of = |id: &PhotoId| -> String { let Some(p) = self.photo(*id) else { return String::new() }; let d = match key { - SortKey::CaptureDate => Some(p.date()), + SortKey::CaptureDate => p.captured.as_deref(), SortKey::ImportDate => Some(p.imported.as_str()), SortKey::EditDate => p.edited.as_deref(), _ => None, @@ -222,7 +222,7 @@ mod tests { } let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::>(); let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day); - // the undated photo falls back to its import date + // the undated photo is grouped under Unknown Date assert_eq!( shape(&day), vec![ @@ -230,16 +230,20 @@ mod tests { ("2026-09-29".into(), 2, 1), ("2026-08-01".into(), 3, 1), ("2025-12-31".into(), 4, 1), - ("2026-10-01".into(), 5, 1) + (String::new(), 5, 1) ] ); assert_eq!(day[0].label, "Wednesday, 30 September 2026"); + assert_eq!(day[4].label, "Unknown Date"); assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day); assert_eq!( - shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)), - vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)] + shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Month)), + vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1), (String::new(), 5, 1)] + ); + assert_eq!( + shape(&c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Year)), + vec![("2026".into(), 0, 4), ("2025".into(), 4, 1), (String::new(), 5, 1)] ); - assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]); assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty()); assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty()); // edit date: unedited photos have no date diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 47223d5d..c2e27cbd 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -141,7 +141,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "person" | "who" => has_person(p, val), "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), - "date" => p.date().starts_with(val), + "date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)), "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), _ => false, @@ -284,12 +284,18 @@ impl Filter { return false; } if let Some(from) = &self.date_from - && p.date() < from.as_str() + && match p.captured.as_deref() { + Some(c) => c < from.as_str(), + None => true, + } { return false; } if let Some(to) = &self.date_to - && p.date().get(..to.len()).unwrap_or(p.date()) > to.as_str() + && match p.captured.as_deref() { + Some(c) => c.get(..to.len()).unwrap_or(c) > to.as_str(), + None => true, + } { return false; } @@ -299,7 +305,7 @@ impl Filter { return false; } if let Some(d) = &self.date - && !p.date().starts_with(d.as_str()) + && !p.captured.as_deref().is_some_and(|c| c.starts_with(d.as_str())) { return false; } @@ -344,7 +350,7 @@ impl Catalog { let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); v.sort_by(|a, b| { let o = match sort.key { - SortKey::CaptureDate => a.date().cmp(b.date()), + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), SortKey::ImportDate => a.imported.cmp(&b.imported), SortKey::EditDate => a.edited.cmp(&b.edited), SortKey::FileName => a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()), @@ -363,7 +369,7 @@ impl Catalog { use std::collections::BTreeMap; let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| p.in_library()) { - let d = p.date(); + let Some(d) = p.captured.as_deref() else { continue }; if d.len() >= 10 { let (months, days) = years.entry(&d[..4]).or_default(); *months.entry(&d[..7]).or_default() += 1; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 8d34d5ef..4c2bb33e 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -456,3 +456,48 @@ fn random_sort_has_no_date_headers() { let ids = many(&mut c, 5); assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty()); } + +#[test] +fn undated_photos_group_under_unknown_date_and_sort_together() { + let mut c = Catalog::new(); + let add_photo = |c: &mut Catalog, name: &str, captured: Option<&str>, imported: &str| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 0 }, name, "JPEG", 100, 100, imported); + p.captured = captured.map(str::to_string); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let p_dated1 = add_photo(&mut c, "dated1.jpg", Some("2026-05-10T12:00:00"), "2026-10-01T00:00:00"); + let p_dated2 = add_photo(&mut c, "dated2.jpg", Some("2026-05-20T12:00:00"), "2026-10-01T00:00:00"); + let p_undated1 = add_photo(&mut c, "undated1.jpg", None, "2026-10-05T00:00:00"); + let p_undated2 = add_photo(&mut c, "undated2.jpg", None, "2026-10-02T00:00:00"); + + // Descending sort (default, newest first): dated photos first (newest to oldest), undated photos at the end + let desc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }); + assert_eq!(desc, vec![p_dated2, p_dated1, p_undated1, p_undated2]); + + // Ascending sort (oldest first): undated photos at the beginning, dated photos from oldest to newest + let asc = c.query(&Filter::default(), &Sort { key: SortKey::CaptureDate, ascending: true, ..Default::default() }); + assert_eq!(asc, vec![p_undated2, p_undated1, p_dated1, p_dated2]); + + // date_runs groups undated photos under Unknown Date + let runs = c.date_runs(&desc, SortKey::CaptureDate, GroupBy::Day); + assert_eq!(runs.len(), 3); + assert_eq!(runs[0].key, "2026-05-20"); + assert_eq!(runs[1].key, "2026-05-10"); + assert_eq!(runs[2].key, ""); + assert_eq!(runs[2].label, "Unknown Date"); + assert_eq!(runs[2].count, 2); + + // date_groups sidebar tree only counts photos with capture dates + let groups = c.date_groups(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].year, "2026"); + assert_eq!(groups[0].count, 2); + + // Filter by capture date prefix does not match undated photos + let filter_day = Filter { date: Some("2026-10-05".into()), ..Default::default() }; + assert_eq!(c.query(&filter_day, &Sort::default()), vec![]); + let filter_year = Filter { date: Some("2026".into()), ..Default::default() }; + assert_eq!(c.query(&filter_year, &Sort { key: SortKey::CaptureDate, ascending: false, ..Default::default() }), vec![p_dated2, p_dated1]); +}