Files
lightcraft/crates/catalog/src/dates.rs
T

277 lines
12 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 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` (proleptic Gregorian; H. Hinnant's algorithm).
pub fn civil(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
}
/// Days since 1970-01-01 → (year, month 1..=12, day 1..=31).
pub fn civil_from_days(days: i64) -> (i64, i64, i64) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
/// 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);
// 1970-01-01 was a Thursday
Some(WEEKDAYS[(days + 3).rem_euclid(7) 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.
pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
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;
}
let tail = iso.trim().get(19..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
/// 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 by == GroupBy::None || ids.is_empty() {
return Vec::new();
}
let n = by.prefix();
let date_of = |id: &PhotoId| -> 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(..n)).unwrap_or("").to_string()
};
if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) {
return Vec::new();
}
let stacks = self.stack_index();
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 k = match stacks.get(id) {
Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(),
None => date_of(id),
};
match runs.last_mut() {
Some(r) if r.key == k => r.count += 1,
_ => runs.push(DateRun { label: group_label(&k), key: k, 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");
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 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)]
);
}
}