Give the calendar one reading of a capture instant
The era-based conversion sat in library_ui.rs with two readers: the
timeline's month headings and, through format_date, the exporter's {date}
token. Import folder templates are a third, and three copies of a date
calculation that could drift apart is one too many — an image filed under a
date the timeline does not show it on is a file the user cannot find.
Moved to dr_types::time, which is where shared vocabulary lives, and given
the offset-aware reading an import needs: a shot taken at 23:30 in Tokyo
belongs in Tokyo's day, and filing by UTC would split one night's
photographs across two folders at whatever hour the offset happens to be.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ use std::ops::Range;
|
|||||||
pub mod colour;
|
pub mod colour;
|
||||||
pub mod selector;
|
pub mod selector;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod time;
|
||||||
|
|
||||||
pub use colour::{Chromaticities, Transfer};
|
pub use colour::{Chromaticities, Transfer};
|
||||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||||
@@ -18,6 +19,7 @@ pub use settings::{
|
|||||||
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||||
ExportTarget, OutputSharpening, Settings, SizingMode,
|
ExportTarget, OutputSharpening, Settings, SizingMode,
|
||||||
};
|
};
|
||||||
|
pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil};
|
||||||
|
|
||||||
/// Identifies a granted library location — a directory on Linux, a persisted
|
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||||
/// document tree on Android.
|
/// document tree on Android.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! Capture instants as civil dates.
|
||||||
|
//!
|
||||||
|
//! Hand-rolled rather than pulling in chrono for four fields — the same
|
||||||
|
//! reasoning as the connector's HTTP date parsing, and the same algorithm the
|
||||||
|
//! timeline has always used.
|
||||||
|
//!
|
||||||
|
//! # Why this is shared vocabulary rather than a helper where it is needed
|
||||||
|
//!
|
||||||
|
//! Three places convert a capture instant to a date, and they must agree:
|
||||||
|
//! the timeline's month headings, the exporter's `{date}` token (FR-EXP-6),
|
||||||
|
//! and the folder an import writes into (FR-CAT-10, FR-NC-7a). A photograph
|
||||||
|
//! filed under `2026-08-21` while the timeline shows it on the 22nd is not a
|
||||||
|
//! cosmetic disagreement — it is a file the user cannot find where the app
|
||||||
|
//! told them to look.
|
||||||
|
|
||||||
|
/// A civil date and hour, as the calendar shows it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct Civil {
|
||||||
|
pub year: i64,
|
||||||
|
/// 1–12.
|
||||||
|
pub month: i64,
|
||||||
|
/// 1–31.
|
||||||
|
pub day: i64,
|
||||||
|
/// 0–23.
|
||||||
|
pub hour: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix seconds to a civil date, via the usual era-based algorithm.
|
||||||
|
///
|
||||||
|
/// The reading is UTC. A capture instant carries its own zone separately
|
||||||
|
/// (`Metadata::captured_offset`), because a photograph's date is local to
|
||||||
|
/// where it was taken: see [`civil_from_unix_at`].
|
||||||
|
pub fn civil_from_unix(t: i64) -> Civil {
|
||||||
|
let days = t.div_euclid(86_400);
|
||||||
|
let secs = t.rem_euclid(86_400);
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
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 };
|
||||||
|
Civil {
|
||||||
|
year: if m <= 2 { y + 1 } else { y },
|
||||||
|
month: m,
|
||||||
|
day: d,
|
||||||
|
hour: secs / 3600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same instant read in the zone the camera recorded.
|
||||||
|
///
|
||||||
|
/// `offset` is minutes east of UTC, as EXIF `OffsetTimeOriginal` gives it.
|
||||||
|
/// Absent on most bodies before ~2018, in which case the stored reading is
|
||||||
|
/// already the local wall clock (see `Metadata::captured_at`) and passing
|
||||||
|
/// `None` is correct rather than a fallback.
|
||||||
|
///
|
||||||
|
/// This is the reading an *import* must use. A shot taken at 23:30 in Tokyo
|
||||||
|
/// belongs in Tokyo's day: filing it by UTC would put a night's photographs in
|
||||||
|
/// two different folders, split at whatever hour the offset happens to be.
|
||||||
|
pub fn civil_from_unix_at(t: i64, offset: Option<i32>) -> Civil {
|
||||||
|
civil_from_unix(t + offset.unwrap_or(0) as i64 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A capture instant as `YYYY-MM-DD`.
|
||||||
|
pub fn format_date(t: i64) -> String {
|
||||||
|
let c = civil_from_unix(t);
|
||||||
|
format!("{}-{:02}-{:02}", c.year, c.month, c.day)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_epoch_is_the_first_of_january_1970() {
|
||||||
|
let c = civil_from_unix(0);
|
||||||
|
assert_eq!((c.year, c.month, c.day, c.hour), (1970, 1, 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_leap_day_is_the_twenty_ninth_of_february() {
|
||||||
|
// 2024-02-29T12:00:00Z. A year-400 rule mistake shows up here first.
|
||||||
|
let c = civil_from_unix(1_709_208_000);
|
||||||
|
assert_eq!((c.year, c.month, c.day, c.hour), (2024, 2, 29, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_year_2000_is_a_leap_year_and_1900_was_not() {
|
||||||
|
// 2000-02-29T00:00:00Z exists...
|
||||||
|
let c = civil_from_unix(951_782_400);
|
||||||
|
assert_eq!((c.year, c.month, c.day), (2000, 2, 29));
|
||||||
|
// ...and 1900-03-01 is the day after 1900-02-28, with no 29th between.
|
||||||
|
let feb28 = civil_from_unix(-2_203_977_600);
|
||||||
|
assert_eq!((feb28.year, feb28.month, feb28.day), (1900, 2, 28));
|
||||||
|
let next = civil_from_unix(-2_203_977_600 + 86_400);
|
||||||
|
assert_eq!((next.year, next.month, next.day), (1900, 3, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_instant_before_the_epoch_does_not_wrap() {
|
||||||
|
// Euclidean division rather than truncating: a 1969 date must not
|
||||||
|
// round toward zero into 1970.
|
||||||
|
let c = civil_from_unix(-1);
|
||||||
|
assert_eq!((c.year, c.month, c.day, c.hour), (1969, 12, 31, 23));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_late_evening_shot_keeps_its_own_days_date() {
|
||||||
|
// 2026-08-21T22:30:00Z is already the 22nd in Tokyo (+540 minutes).
|
||||||
|
// Filing it by UTC would put it a day earlier than the photographer
|
||||||
|
// remembers taking it.
|
||||||
|
let utc = civil_from_unix_at(1_787_351_400, None);
|
||||||
|
assert_eq!((utc.year, utc.month, utc.day), (2026, 8, 21));
|
||||||
|
let tokyo = civil_from_unix_at(1_787_351_400, Some(540));
|
||||||
|
assert_eq!((tokyo.year, tokyo.month, tokyo.day), (2026, 8, 22));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_western_offset_can_move_the_date_back() {
|
||||||
|
// The mirror case: 2026-08-22T02:00:00Z is still the 21st in New York.
|
||||||
|
let ny = civil_from_unix_at(1_787_364_000, Some(-240));
|
||||||
|
assert_eq!((ny.year, ny.month, ny.day), (2026, 8, 21));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_formatted_date_is_zero_padded() {
|
||||||
|
assert_eq!(format_date(1_709_208_000), "2024-02-29");
|
||||||
|
// Single-digit months and days pad, so names sort lexicographically —
|
||||||
|
// which is the whole reason a folder template is worth having.
|
||||||
|
assert_eq!(format_date(0), "1970-01-01");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3428,27 +3428,18 @@ fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String {
|
|||||||
/// reading so a filename and the timeline cannot disagree about what day a
|
/// reading so a filename and the timeline cannot disagree about what day a
|
||||||
/// photograph was taken.
|
/// photograph was taken.
|
||||||
pub fn format_date(t: i64) -> String {
|
pub fn format_date(t: i64) -> String {
|
||||||
let (y, m, d, _) = civil_from_unix(t);
|
dr_types::format_date(t)
|
||||||
format!("{y}-{m:02}-{d:02}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix seconds to a civil date, via the usual era-based algorithm.
|
/// Unix seconds to a civil date, in the tuple shape this module reads it in.
|
||||||
///
|
///
|
||||||
/// Hand-rolled rather than pulling in chrono for four fields — the same
|
/// The algorithm moved to `dr_types::time` when import folder templates
|
||||||
/// reasoning as the connector's HTTP date parsing.
|
/// (FR-CAT-10) became a third caller. Three readings of the same instant that
|
||||||
|
/// could drift apart is one too many: an image filed under a date the timeline
|
||||||
|
/// does not show it on is a file the user cannot find.
|
||||||
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) {
|
||||||
let days = t.div_euclid(86_400);
|
let c = dr_types::civil_from_unix(t);
|
||||||
let secs = t.rem_euclid(86_400);
|
(c.year, c.month, c.day, c.hour)
|
||||||
let z = days + 719_468;
|
|
||||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
||||||
let doe = z - era * 146_097;
|
|
||||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
|
||||||
let y = yoe + era * 400;
|
|
||||||
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 };
|
|
||||||
(if m <= 2 { y + 1 } else { y }, m, d, secs / 3600)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy decoded RGBA into a Slint image.
|
/// Copy decoded RGBA into a Slint image.
|
||||||
|
|||||||
Reference in New Issue
Block a user