Files
lightcraft/crates/catalog/src/dates.rs
T
francoisgn 77f313a0f2 Merge PR #2506: Edit Capture Time leaves a capture time it can't fully read
# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
2026-10-22 22:29:19 +03:00

427 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Calendar helpers for ISO 8601 local times (`2026-09-30T14:05:00`): seconds ↔ civil date,
//! weekday and month names, shifting a time by an offset, and grouping a photo list into runs of
//! the same day / month / year (the date headers of the photo grid).
use chrono::Datelike;
use serde::{Deserialize, Serialize};
pub use crate::stacks::iso_seconds;
use crate::{Catalog, PhotoId, SortKey};
pub const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
pub const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC, proleptic Gregorian). A time outside chrono's year
/// range still converts: the calendar repeats every 400 years, so the instant is moved into range
/// by whole cycles and only the year is carried (matching the old `civil_from_days`), rather than
/// resetting to the epoch. The callers here stay well inside years 0000–9999.
pub fn civil(secs: i64) -> String {
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
let y = i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX });
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not).
format!("{y:04}-{}", d.and_time(t).format("%m-%dT%H:%M:%S"))
}
_ => "1970-01-01T00:00:00".to_string(), // unreachable
}
}
/// Weekday name of an ISO date (`None` if it doesn't parse).
pub fn weekday(iso: &str) -> Option<&'static str> {
let days = iso_seconds(iso.get(..10)?)?.div_euclid(86_400);
let date = chrono::NaiveDate::from_num_days_from_ce_opt(i32::try_from(days.checked_add(719_163)?).ok()?)?;
Some(WEEKDAYS[date.weekday().num_days_from_monday() as usize])
}
/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was.
/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years
/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be
/// anything, it never overflows. Stricter than [`iso_seconds`], which reads what it can for sorting:
/// a time cut short, seconds that aren't digits or anything after the zone is `None` here, not
/// written back as a clean-looking time.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
let iso = iso.trim();
let tail = iso.get(tail_start(iso)?..)?;
let t = iso_seconds(iso)?.checked_add(secs)?;
let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?);
if !(first..=last).contains(&t) {
return None;
}
Some(format!("{}{tail}", civil(t)))
}
/// Where the part of an ISO time that [`shift_iso`] keeps as it was (fraction, zone) starts, or
/// `None` unless `iso` is a date alone (`2026-01-01`) or a date and `HH:MM`, optional `:SS` with
/// an optional fraction (`.5`), and an optional zone (`Z`, `+02:00`, `-0500`, `+02`), and nothing after.
fn tail_start(iso: &str) -> Option<usize> {
let b = iso.as_bytes();
let digits = |x: &[u8]| !x.is_empty() && x.iter().all(u8::is_ascii_digit);
let digits_at = |a: usize, z: usize| b.get(a..z).is_some_and(digits);
let at = |i: usize, c: u8| b.get(i) == Some(&c);
if !(digits_at(0, 4) && at(4, b'-') && digits_at(5, 7) && at(7, b'-') && digits_at(8, 10)) {
return None;
}
if b.len() == 10 {
return Some(10);
}
if !((at(10, b'T') || at(10, b' ')) && digits_at(11, 13) && at(13, b':') && digits_at(14, 16)) {
return None;
}
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
let start = if !at(16, b':') {
16
} else if digits_at(17, 19) {
19
} else {
return None;
};
let mut zone = b.get(start..)?;
// a fraction only after the seconds: `10:00.5` is half a minute, not half a second
if let ([b'.', rest @ ..], 19) = (zone, start) {
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
zone = rest.get(n..)?;
if n == 0 {
return None;
}
}
let ok = match zone {
[] | [b'Z'] => true,
[b'+' | b'-', h1, h2, b':', m1, m2] => digits(&[*h1, *h2, *m1, *m2]),
[b'+' | b'-', hm @ ..] => matches!(hm.len(), 2 | 4) && digits(hm),
_ => false,
};
ok.then_some(start)
}
/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing
/// seconds or time). `None` if it isn't a valid date.
pub fn normalize_iso(s: &str) -> Option<String> {
let t = iso_seconds(s)?;
let out = civil(t);
// reject impossible dates that the arithmetic silently rolls over (2026-02-30)
if out.get(..10) != s.trim().get(..10) {
return None;
}
Some(out)
}
/// Granularity of the grid's date headers.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GroupBy {
/// Day headers, or months/years when the thumbnails are small (decided by the UI).
#[default]
Auto,
None,
Day,
Month,
Year,
}
impl GroupBy {
pub fn parse(s: &str) -> Option<GroupBy> {
Some(match s.to_ascii_lowercase().as_str() {
"auto" => GroupBy::Auto,
"none" | "off" => GroupBy::None,
"day" => GroupBy::Day,
"month" => GroupBy::Month,
"year" => GroupBy::Year,
_ => return None,
})
}
/// Length of the ISO prefix that identifies a group.
fn prefix(self) -> usize {
match self {
GroupBy::Year => 4,
GroupBy::Month => 7,
_ => 10,
}
}
}
/// A run of consecutive photos with the same date (one header in the grid).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DateRun {
/// `2026-09-30` / `2026-09` / `2026`, or empty for photos without a usable date.
pub key: String,
/// "Wednesday, 30 September 2026" / "September 2026" / "2026" / "Unknown Date".
pub label: String,
/// Index of the first photo in the list.
pub start: usize,
pub count: usize,
}
/// A capture time for display: `2022-03-30T22:11:11` → "March 30, 2022 at 10:11:11 PM" (the
/// date alone when there is no time; the input unchanged when it doesn't parse).
pub fn display_time(iso: &str) -> String {
let num = |r: std::ops::Range<usize>| iso.get(r).and_then(|v| v.parse::<u32>().ok());
let (Some(y), Some(mo), Some(d)) = (num(0..4), num(5..7), num(8..10)) else { return iso.to_string() };
let Some(month) = MONTHS.get((mo as usize).wrapping_sub(1)) else { return iso.to_string() };
let date = format!("{month} {d}, {y}");
match (num(11..13), num(14..16), num(17..19)) {
(Some(h), Some(mi), s) => {
let (h12, ampm) = match h {
0 => (12, "AM"),
1..=11 => (h, "AM"),
12 => (12, "PM"),
_ => (h - 12, "PM"),
};
format!("{date} at {h12}:{mi:02}:{:02} {ampm}", s.unwrap_or(0))
}
_ => date,
}
}
/// Header text for a group key.
pub fn group_label(key: &str) -> String {
let month = |k: &str| k.get(5..7).and_then(|m| m.parse::<usize>().ok()).and_then(|m| MONTHS.get(m.wrapping_sub(1)));
// `get` rather than `[..]`: a non-ASCII key must not split a character
let year = key.get(..4).unwrap_or(key);
match key.len() {
10 => match (weekday(key), month(key), key.get(8..10).and_then(|d| d.parse::<u32>().ok())) {
(Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {year}"),
_ => key.to_string(),
},
7 => month(key).map(|m| format!("{m} {year}")).unwrap_or_else(|| key.to_string()),
4 => key.to_string(),
_ => "Unknown Date".into(),
}
}
impl Catalog {
/// Split `ids` (the grid order) into runs of the same day / month / year for `key`'s date.
/// Members of a stack share the group of the stack's first photo in the list, so a stack is
/// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`]
/// ([`GroupBy::Auto`] means days here).
pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec<DateRun> {
if !groups_by_date(key, by) || ids.is_empty() {
return Vec::new();
}
date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by))
}
/// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` /
/// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its
/// first photo in the list there.
pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String {
let Some(p) = self.photo(id) else { return String::new() };
let d = match key {
SortKey::CaptureDate => p.captured.as_deref(),
SortKey::ImportDate => Some(p.imported.as_str()),
SortKey::EditDate => p.edited.as_deref(),
_ => None,
};
d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string()
}
}
/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`.
pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool {
by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate)
}
/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see
/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]).
pub fn date_runs_of<K: AsRef<str>>(
ids: &[PhotoId],
stacks: &std::collections::HashMap<PhotoId, (crate::StackId, usize)>,
mut group: impl FnMut(usize, PhotoId) -> K,
) -> Vec<DateRun> {
let mut runs: Vec<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = Default::default();
for (i, id) in ids.iter().enumerate() {
let own;
let k: &str = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()),
None => {
own = group(i, *id);
own.as_ref()
}
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }),
}
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Op, Photo, Source};
#[test]
fn calendar() {
assert_eq!(civil(0), "1970-01-01T00:00:00");
assert_eq!(civil(951_782_400), "2000-02-29T00:00:00");
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
assert_eq!(civil(253_402_300_800), "10000-01-01T00:00:00");
// Beyond chrono's year range the old `civil_from_days` still converted; pin that output
// (never the epoch) in both directions and at the i64 extremes.
assert_eq!(civil(10_000_000_000_000), "318857-05-20T17:46:40");
assert_eq!(civil(-10_000_000_000_000), "-314918-08-13T06:13:20");
assert_eq!(civil(i64::MAX), "292277026596-12-04T15:30:07");
assert_eq!(civil(i64::MIN), "-292277022657-01-27T08:29:52");
assert_eq!(weekday("2026-09-30"), Some("Wednesday"));
assert_eq!(weekday("2026-10-01T09:00:00"), Some("Thursday"));
assert_eq!(weekday("2000-02-29"), Some("Tuesday"));
assert_eq!(group_label("2026-09-29"), "Tuesday, 29 September 2026");
assert_eq!(group_label("2026-02"), "February 2026");
assert_eq!(group_label("2026"), "2026");
assert_eq!(group_label(""), "Unknown Date");
// non-ASCII keys of a date's length used to be sliced mid-character
assert_eq!(group_label("2024-01é0"), "2024-01é0");
assert_eq!(group_label("202é01"), "January 202é01");
assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00"));
assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00"));
assert_eq!(shift_iso("nope", 5), None);
// hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date
// here can read back) is refused rather than stored
for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] {
assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}");
}
assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None);
assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None);
assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59"));
assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00"));
assert_eq!(normalize_iso("2026-02-30"), None);
}
#[test]
fn shift_keeps_the_zone_of_a_time_without_seconds() {
assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
}
#[test]
fn shift_refuses_a_time_it_cant_fully_read() {
for bad in [
"2026-01-01T10:0",
"2026-01-01T10",
"2026-01-01T",
"2026-01-01T10:00:xx",
"2026-01-01T10:00:0",
"2026-01-01T10:00:00+",
"2026-01-01T10:00:00+2",
"2026-01-01T10:00:00+02:0",
"2026-01-01T10:00:00.",
"2026-01-01T10:00:00.5.5",
"2026-01-01T10:00.5",
"2026-01-01T10:00:00ZZ",
"2026-01-01T10:00:00 and more",
"2026-01-01T10:00 and more",
"2026-01-01x10:00:00",
"2026/01/01T10:00:00",
] {
assert_eq!(shift_iso(bad, 3600), None, "{bad} doesn't fully read, so it must not be written back");
}
// multi-byte characters where the time, the seconds and the zone start never panic
for at in [11, 16, 18, 21] {
let mut s = String::from("2026-01-01T10:00:00+02:00");
s.replace_range(at..at + 1, "é");
assert_eq!(shift_iso(&s, 3600), None, "{s}");
assert_eq!(shift_iso(s.get(..at + 2).unwrap(), 3600), None, "{s}");
}
}
#[test]
fn shift_keeps_a_fraction_and_zone_and_reads_back() {
for (from, to) in [
("2026-01-01", "2026-01-01T01:00:00"),
(" 2026-01-01T10:00 ", "2026-01-01T11:00:00"),
("2026-01-01 10:00:30", "2026-01-01T11:00:30"),
("2026-01-01T10:00:30.5", "2026-01-01T11:00:30.5"),
("2026-01-01T10:00:30Z", "2026-01-01T11:00:30Z"),
("2026-01-01T10:00:30+02:00", "2026-01-01T11:00:30+02:00"),
("2026-01-01T10:00:30-0500", "2026-01-01T11:00:30-0500"),
("2026-01-01T10:00:30+02", "2026-01-01T11:00:30+02"),
("2026-01-01T10:00:30.125+02:00", "2026-01-01T11:00:30.125+02:00"),
] {
assert_eq!(shift_iso(from, 3600).as_deref(), Some(to), "{from}");
assert_eq!(shift_iso(to, 0).as_deref(), Some(to), "{to} reads back");
}
}
/// Sorting, grouping and date rules read with `iso_seconds`, which stays lenient: only the
/// write path refuses what it can't fully read.
#[test]
fn reading_stays_lenient_for_sorting() {
let at_ten = iso_seconds("2026-01-01T10:00:00");
assert_eq!(iso_seconds("2026-01-01T10:00:00 and more"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:00:xx"), at_ten);
assert_eq!(iso_seconds("2026-01-01T10:0"), iso_seconds("2026-01-01"));
}
#[test]
fn impossible_times_are_rejected_not_rolled_over() {
for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] {
assert_eq!(normalize_iso(bad), None, "{bad}");
assert_eq!(shift_iso(bad, 5), None, "{bad}");
}
assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59"));
}
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
let times = ["2026-09-30T18:00:00", "2026-09-30T09:00:00", "2026-09-29T23:59:00", "2026-08-01T10:00:00", "2025-12-31T10:00:00", ""];
let mut ids = Vec::new();
for t in times {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-10-01T00:00:00");
p.captured = (!t.is_empty()).then(|| t.to_string());
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
ids.push(id);
}
let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::<Vec<_>>();
let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day);
// the undated photo is grouped under Unknown Date
assert_eq!(
shape(&day),
vec![
("2026-09-30".into(), 0, 2),
("2026-09-29".into(), 2, 1),
("2026-08-01".into(), 3, 1),
("2025-12-31".into(), 4, 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, 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!(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
let r = c.date_runs(&ids, SortKey::EditDate, GroupBy::Day);
assert_eq!(shape(&r), vec![(String::new(), 0, 6)]);
assert_eq!(r[0].label, "Unknown Date");
// a stack spanning midnight stays in its first member's group
let op = c.group_ops(ids[1], &[ids[1], ids[2]], false).unwrap();
c.apply(op).unwrap();
let arranged = c.arrange_stacks(&ids[..5]);
assert_eq!(
shape(&c.date_runs(&arranged, SortKey::CaptureDate, GroupBy::Day))[..2],
[("2026-09-30".into(), 0, 3), ("2026-08-01".into(), 3, 1)]
);
}
}