mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +00:00
fix a crash, a false changelog entry, and three things 1.0 would freeze
Two adversarial reviews of the release. Both said do not ship, and they were right twice over. The seven-day view panicked. elide() cut a title at cut.len() / 2, a byte index into a string it had counted in characters, and every movable feast title carries an em dash: at fourteen terminal widths, 80 among them, it aborted with exit 101 on the README's own headline command. tests/elide.rs now cuts every title the program can print at every width it can be asked to print at. The changelog's first Calendar entry was false. It said a vigil or polyeleos saint reaches fish in the three lesser fasts; the code gives wine and oil to every rank but great, and caps the Dormition Fast at wine and oil whatever the rank. A reader in the Dormition Fast was told this release let a saint bring fish. docs/versioning.md makes that section a contract, so a wrong entry there is worse than a wrong comment. The Python bindings read the config file where the CLI reads the environment first, so TYPIKON_JURISDICTION moved one and not the other and the two answered for different churches on the same machine. CI now asserts they agree, and runs the README example checker, which was standing between the docs and reality with nothing running it. Three changes to the surface, made now because after 1.0.0 they are a major version: binds is null rather than the string none where a church legislates no binding fast at all; iCalendar UIDs carry the tradition and the discipline, which two feeds of the same day were sharing while saying different things; and Date's Julian Day Number is behind an accessor rather than a public field. fastLabel, emoji and reason are documented as presentation. The fields are stable, the wording is not, and freezing three human-facing strings for a major version was not the intent.
This commit is contained in:
+12
-1
@@ -25,7 +25,7 @@ use std::fmt;
|
||||
/// A newtype over the JDN rather than a (y, m, d) triple, so that day
|
||||
/// arithmetic cannot go wrong at a month or year boundary.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
|
||||
pub struct Date(pub i32);
|
||||
pub struct Date(i32);
|
||||
|
||||
/// A date in the Julian calendar. Deliberately a *different type* from `Date`:
|
||||
/// mixing the two is the single most common way calendar code goes wrong, and
|
||||
@@ -122,6 +122,17 @@ fn jdn_to_julian(jdn: i32) -> (i32, u32, u32) {
|
||||
}
|
||||
|
||||
impl Date {
|
||||
/// The Julian Day Number this date is stored as. The representation is an
|
||||
/// implementation detail; this is the supported way to read it.
|
||||
pub fn jdn(self) -> i32 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// A date from a Julian Day Number.
|
||||
pub fn from_jdn(jdn: i32) -> Date {
|
||||
Date(jdn)
|
||||
}
|
||||
|
||||
pub fn from_ymd(year: i32, month: u32, day: u32) -> Option<Date> {
|
||||
if !(1..=12).contains(&month) || day == 0 || day > days_in_month(year, month) {
|
||||
return None;
|
||||
|
||||
+13
-5
@@ -34,7 +34,14 @@ pub struct Day {
|
||||
pub emoji: &'static str,
|
||||
pub reason: String,
|
||||
pub obligation: Option<Obligation>,
|
||||
pub binds: Level,
|
||||
/// The minimum a church's particular law requires of this day. `None`
|
||||
/// where nothing binds, which for most jurisdictions is every day: the
|
||||
/// Orthodox Church legislates the fast as tradition rather than precept.
|
||||
/// Distinguishing that from "binds, at level none" is why it is an option.
|
||||
pub binds: Option<Level>,
|
||||
/// Whether the level shown is the Typikon's rather than this church's own
|
||||
/// particular law.
|
||||
pub shown_at_typikon: bool,
|
||||
pub pascha: Date,
|
||||
/// Days from this day to Pascha, **signed**, and to the Pascha of this
|
||||
/// day's own civil year. Negative after Pascha has passed: 31 December
|
||||
@@ -86,7 +93,7 @@ pub struct DayJson<'a> {
|
||||
pub emoji: &'a str,
|
||||
pub reason: &'a str,
|
||||
pub obligation: Option<&'a str>,
|
||||
pub binds: &'a str,
|
||||
pub binds: Option<&'a str>,
|
||||
pub pascha: String,
|
||||
pub days_to_pascha: i32,
|
||||
pub julian_offset: i32,
|
||||
@@ -116,7 +123,7 @@ impl Day {
|
||||
Obligation::Obligatory => "obligatory",
|
||||
Obligation::Customary => "customary",
|
||||
}),
|
||||
binds: self.binds.key(),
|
||||
binds: self.binds.map(|b| b.key()),
|
||||
pascha: self.pascha.iso(),
|
||||
days_to_pascha: self.days_to_pascha,
|
||||
julian_offset: self.julian_offset,
|
||||
@@ -200,12 +207,12 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
||||
if !std::ptr::eq(shown, rule) {
|
||||
fast.obligation = match rule.obligation_for.and_then(|f| f(&ctx)) {
|
||||
Some((reason, binds)) => {
|
||||
fast.binds = binds;
|
||||
fast.binds = Some(binds);
|
||||
fast.reason = format!("{} \u{2014} {reason}", fast.reason);
|
||||
Some(Obligation::Obligatory)
|
||||
}
|
||||
None => {
|
||||
fast.binds = Level::None;
|
||||
fast.binds = None;
|
||||
if fast.level == Level::None { None } else { Some(Obligation::Customary) }
|
||||
}
|
||||
};
|
||||
@@ -230,6 +237,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
||||
reason,
|
||||
obligation,
|
||||
binds,
|
||||
shown_at_typikon: !std::ptr::eq(shown, rule),
|
||||
pascha: mv.pascha,
|
||||
days_to_pascha: mv.pascha - date,
|
||||
julian_offset: date.julian_offset(),
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ fn cell(day: &Day, column: &str) -> String {
|
||||
Some(crate::fasting::Obligation::Customary) => "customary".into(),
|
||||
None => String::new(),
|
||||
},
|
||||
"binds" => day.binds.key().to_string(),
|
||||
"binds" => day.binds.map(|b| b.key()).unwrap_or("").to_string(),
|
||||
"pascha" => day.pascha.iso(),
|
||||
"days_to_pascha" => day.days_to_pascha.to_string(),
|
||||
"julian_offset" => day.julian_offset.to_string(),
|
||||
|
||||
+11
-1
@@ -112,7 +112,17 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
||||
|
||||
lines.extend([
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}-{}@typikon", start, d.jurisdiction),
|
||||
// The tradition and the discipline change what the event says, so
|
||||
// they belong in its identity. Without them a client subscribed to
|
||||
// two feeds treats them as one event and keeps whichever arrived
|
||||
// last.
|
||||
format!(
|
||||
"UID:{}-{}-{}-{}@typikon",
|
||||
start,
|
||||
d.jurisdiction,
|
||||
d.tradition,
|
||||
if d.shown_at_typikon { "typikon" } else { "particular" }
|
||||
),
|
||||
format!("DTSTAMP:{stamp}"),
|
||||
format!("DTSTART;VALUE=DATE:{start}"),
|
||||
format!("DTEND;VALUE=DATE:{end}"),
|
||||
|
||||
+7
-5
@@ -97,9 +97,11 @@ pub struct Fast {
|
||||
/// The rule that decided it, so the answer can be argued with.
|
||||
pub reason: String,
|
||||
pub obligation: Option<Obligation>,
|
||||
/// The binding minimum, where one exists. `None` for most jurisdictions,
|
||||
/// which is the honest answer rather than a softening.
|
||||
pub binds: Level,
|
||||
/// The binding minimum, where one exists at all. `None` for most
|
||||
/// jurisdictions, which is the honest answer rather than a softening:
|
||||
/// their tradition legislates no binding fast, which is a different fact
|
||||
/// from a day that happens to bind nothing.
|
||||
pub binds: Option<Level>,
|
||||
}
|
||||
|
||||
impl Fast {
|
||||
@@ -108,7 +110,7 @@ impl Fast {
|
||||
level,
|
||||
reason: reason.to_string(),
|
||||
obligation,
|
||||
binds: Level::None,
|
||||
binds: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ impl Fast {
|
||||
level,
|
||||
reason: reason.to_string(),
|
||||
obligation: Some(Obligation::Obligatory),
|
||||
binds,
|
||||
binds: Some(binds),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-3
@@ -155,12 +155,28 @@ fn list_jurisdictions() {
|
||||
println!(" calendar or thirteen days behind it?\n");
|
||||
}
|
||||
|
||||
fn elide(text: &str, width: usize) -> String {
|
||||
/// Cut a title to `width` characters, on a word boundary where there is one,
|
||||
/// and mark it with an ellipsis.
|
||||
///
|
||||
/// The halfway point has to be found with `char_indices`. Taking it as
|
||||
/// `cut.len() / 2` is a byte index into a string counted in characters, and
|
||||
/// every movable feast title carries an em dash, so at the widths where the
|
||||
/// midpoint landed inside one this panicked: 80 columns among them.
|
||||
pub fn elide(text: &str, width: usize) -> String {
|
||||
if width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if text.chars().count() <= width {
|
||||
return text.to_string();
|
||||
}
|
||||
let cut: String = text.chars().take(width - 1).collect();
|
||||
let head = match cut[cut.len() / 2..].find(' ') {
|
||||
let midpoint = cut
|
||||
.char_indices()
|
||||
.nth(cut.chars().count() / 2)
|
||||
.map_or(cut.len(), |(i, _)| i);
|
||||
// Only break on a word if one falls in the second half; otherwise a single
|
||||
// long word would be cut back to almost nothing.
|
||||
let head = match cut[midpoint..].find(' ') {
|
||||
Some(_) => match cut.trim_end().rfind(' ') {
|
||||
Some(i) => &cut[..i],
|
||||
None => &cut,
|
||||
@@ -467,7 +483,7 @@ fn today() -> Date {
|
||||
.unwrap_or(0);
|
||||
// Local midnight, via the offset the system reports for now.
|
||||
let local = secs + local_offset_secs(secs);
|
||||
Date(2440588 + (local.div_euclid(86400)) as i32)
|
||||
Date::from_jdn(2440588 + (local.div_euclid(86400)) as i32)
|
||||
}
|
||||
|
||||
/// Seconds east of UTC, read from /etc/localtime through the `date` command
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
|
||||
(mv.fathers_first_council, "The Sunday of the Fathers of the First Council", None),
|
||||
(mv.clean_monday, "Clean Monday \u{2014} the first day of the Great Fast", None),
|
||||
// A sentinel that can never match, keeping the array a fixed size.
|
||||
(Date(i32::MIN), "", None),
|
||||
(Date::from_jdn(i32::MIN), "", None),
|
||||
];
|
||||
table
|
||||
.iter()
|
||||
|
||||
+8
-3
@@ -71,7 +71,12 @@ fn resolve(
|
||||
) -> PyResult<&'static Jurisdiction> {
|
||||
let saved = crate::config::saved();
|
||||
|
||||
let name = jurisdiction.map(str::to_owned).or(saved.jurisdiction);
|
||||
// The environment beats the file, which is the order main.rs uses. Reading
|
||||
// only the file meant TYPIKON_JURISDICTION moved the command line and left
|
||||
// the bindings behind, so the two answered for different churches on the
|
||||
// same machine, which is the exact failure the note above is about.
|
||||
let (default_name, _) = crate::config::default_jurisdiction();
|
||||
let name = jurisdiction.map(str::to_owned).or(default_name);
|
||||
let base = jurisdictions::resolve(name.as_deref())
|
||||
.map_err(|n| PyValueError::new_err(format!("unknown jurisdiction {n:?}")))?;
|
||||
|
||||
@@ -217,8 +222,8 @@ impl PyDay {
|
||||
}
|
||||
|
||||
#[getter]
|
||||
fn binds(&self) -> &'static str {
|
||||
self.inner.binds.key()
|
||||
fn binds(&self) -> Option<&'static str> {
|
||||
self.inner.binds.map(|b| b.key())
|
||||
}
|
||||
|
||||
#[getter]
|
||||
|
||||
+11
-5
@@ -8,11 +8,17 @@
|
||||
//! life and a law, and the Eastern Catholic churches, which do have law about
|
||||
//! this, are handled in their own module.
|
||||
//!
|
||||
//! A saint of vigil or polyeleos rank lifts the weekday discipline of a fast:
|
||||
//! to wine and oil inside the Great Fast, as far as fish in the three lesser
|
||||
//! fasts. That is why the printed calendars mark so many Wednesdays in the
|
||||
//! Nativity Fast for wine and oil, and it is the rank in the menologion that
|
||||
//! decides it.
|
||||
//! A ranked saint lifts the weekday discipline of a fast, and how far depends
|
||||
//! on both the rank and the season:
|
||||
//!
|
||||
//! | | Great Fast | Dormition | Nativity, Apostles |
|
||||
//! |---|---|---|---|
|
||||
//! | vigil or polyeleos | wine and oil | wine and oil | wine and oil |
|
||||
//! | great feast | wine and oil | wine and oil | fish |
|
||||
//!
|
||||
//! No saint of any rank brings fish into the Great Fast or the Dormition Fast.
|
||||
//! That is why the printed calendars mark so many Wednesdays in the Nativity
|
||||
//! Fast for wine and oil, and it is the rank in the menologion that decides it.
|
||||
//!
|
||||
//! Where usages differ, this follows the common published rule and says so in
|
||||
//! the reason. The known divergences:
|
||||
|
||||
Reference in New Issue
Block a user