mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +00:00
rebuild the engine in Rust
Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.
The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.
What the type system actually buys, which is less than the usual claim and
more than nothing:
- Level is an ordered enum, strictest first, so reads as 'a is
stricter' and relaxed_to() is the one place a feast can lift a fast. No
rule can get the direction backwards.
- Date is a newtype over the Julian Day Number and JulianDate is a separate
type, so a civil date and an ecclesiastical one cannot be mixed up. That
confusion is the single most common way calendar code goes wrong.
- Octets is a newtype in the iCalendar folder, because the bug it replaced
counted characters against a limit RFC 5545 states in octets.
- Movables is a struct, not a map, so a mistyped key is a compile error
rather than a None at the far end of the program.
Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.
The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
//! Conversion between the civil calendar and the calendar a church reckons in.
|
||||
//!
|
||||
//! Every date in this library exists twice. There is the **civil** date, what a
|
||||
//! phone says, always Gregorian; and the **ecclesiastical** date, the day the
|
||||
//! Church calls it. For a church on the new calendar those are the same date.
|
||||
//! For one on the old calendar they differ by the drift between the Julian and
|
||||
//! Gregorian reckonings: thirteen days from 1900 until 14 March 2100, and
|
||||
//! fourteen from the 15th, the day after a Julian 29 February the Gregorian
|
||||
//! calendar does not have.
|
||||
//!
|
||||
//! Keeping both is what makes a single menologion serve every jurisdiction. The
|
||||
//! Nativity is 25 December in the Julian and the Gregorian books alike; the two
|
||||
//! traditions disagree about which morning that is, not about which date it is.
|
||||
//!
|
||||
//! Everything here is built on the Julian Day Number, which is why this crate
|
||||
//! carries its own date type instead of a calendar library: the conversions the
|
||||
//! liturgical year needs *are* JDN arithmetic, and a `Date` that is a JDN in a
|
||||
//! newtype makes "add a week" and "what is this in the Julian calendar" the
|
||||
//! same two lines.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// A civil (proleptic Gregorian) date, stored as a Julian Day Number.
|
||||
///
|
||||
/// 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);
|
||||
|
||||
/// 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
|
||||
/// the compiler can simply forbid it.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
pub struct JulianDate {
|
||||
pub year: i32,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Weekday {
|
||||
Monday,
|
||||
Tuesday,
|
||||
Wednesday,
|
||||
Thursday,
|
||||
Friday,
|
||||
Saturday,
|
||||
Sunday,
|
||||
}
|
||||
|
||||
pub const MONTHS: [&str; 12] = [
|
||||
"January", "February", "March", "April", "May", "June", "July", "August",
|
||||
"September", "October", "November", "December",
|
||||
];
|
||||
|
||||
const MONTHS_SHORT: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
impl Weekday {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Weekday::Monday => "Monday",
|
||||
Weekday::Tuesday => "Tuesday",
|
||||
Weekday::Wednesday => "Wednesday",
|
||||
Weekday::Thursday => "Thursday",
|
||||
Weekday::Friday => "Friday",
|
||||
Weekday::Saturday => "Saturday",
|
||||
Weekday::Sunday => "Sunday",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short(self) -> &'static str {
|
||||
&self.name()[..3]
|
||||
}
|
||||
|
||||
pub fn is_weekend(self) -> bool {
|
||||
matches!(self, Weekday::Saturday | Weekday::Sunday)
|
||||
}
|
||||
}
|
||||
|
||||
/// Julian Day Number for a proleptic Gregorian date.
|
||||
pub fn gregorian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
|
||||
let a = (14 - month as i32) / 12;
|
||||
let y = year + 4800 - a;
|
||||
let m = month as i32 + 12 * a - 3;
|
||||
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - 32045
|
||||
}
|
||||
|
||||
/// Julian Day Number for a proleptic Julian date.
|
||||
pub fn julian_to_jdn(year: i32, month: u32, day: u32) -> i32 {
|
||||
let a = (14 - month as i32) / 12;
|
||||
let y = year + 4800 - a;
|
||||
let m = month as i32 + 12 * a - 3;
|
||||
day as i32 + (153 * m + 2) / 5 + 365 * y + y / 4 - 32083
|
||||
}
|
||||
|
||||
fn jdn_to_gregorian(jdn: i32) -> (i32, u32, u32) {
|
||||
let a = jdn + 32044;
|
||||
let b = a.div_euclid(146097);
|
||||
let c = a.rem_euclid(146097);
|
||||
let d = (4 * c + 3) / 146097;
|
||||
let e = c - (146097 * d) / 4;
|
||||
let f = (4 * e + 3) / 1461;
|
||||
let g = e - (1461 * f) / 4;
|
||||
let h = (5 * g + 2) / 153;
|
||||
let day = g - (153 * h + 2) / 5 + 1;
|
||||
let month = h + 3 - 12 * (h / 10);
|
||||
let year = 400 * b + 100 * d + f - 4800 + h / 10;
|
||||
(year, month as u32, day as u32)
|
||||
}
|
||||
|
||||
fn jdn_to_julian(jdn: i32) -> (i32, u32, u32) {
|
||||
let c = jdn + 32082;
|
||||
let d = (4 * c + 3) / 1461;
|
||||
let e = c - (1461 * d) / 4;
|
||||
let m = (5 * e + 2) / 153;
|
||||
let day = e - (153 * m + 2) / 5 + 1;
|
||||
let month = m + 3 - 12 * (m / 10);
|
||||
let year = d - 4800 + m / 10;
|
||||
(year, month as u32, day as u32)
|
||||
}
|
||||
|
||||
impl Date {
|
||||
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;
|
||||
}
|
||||
Some(Date(gregorian_to_jdn(year, month, day)))
|
||||
}
|
||||
|
||||
pub fn ymd(self) -> (i32, u32, u32) {
|
||||
jdn_to_gregorian(self.0)
|
||||
}
|
||||
|
||||
pub fn year(self) -> i32 {
|
||||
self.ymd().0
|
||||
}
|
||||
|
||||
pub fn month(self) -> u32 {
|
||||
self.ymd().1
|
||||
}
|
||||
|
||||
pub fn day(self) -> u32 {
|
||||
self.ymd().2
|
||||
}
|
||||
|
||||
pub fn weekday(self) -> Weekday {
|
||||
// JDN 0 was a Monday, so the modulus lands directly on the enum.
|
||||
match self.0.rem_euclid(7) {
|
||||
0 => Weekday::Monday,
|
||||
1 => Weekday::Tuesday,
|
||||
2 => Weekday::Wednesday,
|
||||
3 => Weekday::Thursday,
|
||||
4 => Weekday::Friday,
|
||||
5 => Weekday::Saturday,
|
||||
_ => Weekday::Sunday,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Julian calendar date of this civil date.
|
||||
pub fn to_julian(self) -> JulianDate {
|
||||
let (year, month, day) = jdn_to_julian(self.0);
|
||||
JulianDate { year, month, day }
|
||||
}
|
||||
|
||||
/// How many days the Julian reckoning currently lags the Gregorian one.
|
||||
/// Computed, never assumed, so nothing here breaks at the 2100 boundary.
|
||||
pub fn julian_offset(self) -> i32 {
|
||||
let j = self.to_julian();
|
||||
self.0 - gregorian_to_jdn(j.year, j.month, j.day)
|
||||
}
|
||||
|
||||
pub fn iso(self) -> String {
|
||||
let (y, m, d) = self.ymd();
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// "Fri 18 Sep", the width the seven-day view lines up on.
|
||||
pub fn short(self) -> String {
|
||||
let (_, m, d) = self.ymd();
|
||||
format!("{} {} {}", self.weekday().short(), d, MONTHS_SHORT[(m - 1) as usize])
|
||||
}
|
||||
|
||||
/// "18 September 2026"
|
||||
pub fn long(self) -> String {
|
||||
let (y, m, d) = self.ymd();
|
||||
format!("{} {} {}", d, MONTHS[(m - 1) as usize], y)
|
||||
}
|
||||
|
||||
pub fn parse_iso(s: &str) -> Option<Date> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let year = s[0..4].parse().ok()?;
|
||||
let month = s[5..7].parse().ok()?;
|
||||
let day = s[8..10].parse().ok()?;
|
||||
Date::from_ymd(year, month, day)
|
||||
}
|
||||
}
|
||||
|
||||
impl JulianDate {
|
||||
/// The civil date of a Julian calendar date.
|
||||
pub fn to_civil(self) -> Date {
|
||||
Date(julian_to_jdn(self.year, self.month, self.day))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Add<i32> for Date {
|
||||
type Output = Date;
|
||||
fn add(self, days: i32) -> Date {
|
||||
Date(self.0 + days)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Sub<i32> for Date {
|
||||
type Output = Date;
|
||||
fn sub(self, days: i32) -> Date {
|
||||
Date(self.0 - days)
|
||||
}
|
||||
}
|
||||
|
||||
/// Days between two dates, which is a count and not a date.
|
||||
impl std::ops::Sub<Date> for Date {
|
||||
type Output = i32;
|
||||
fn sub(self, other: Date) -> i32 {
|
||||
self.0 - other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Date {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.iso())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
pub fn days_in_month(year: i32, month: u32) -> u32 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap(year) => 29,
|
||||
2 => 28,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is (month, day) within an inclusive fixed-date range, wrapping the year?
|
||||
///
|
||||
/// The Nativity Fast does not wrap; the fast-free stretch from the Nativity to
|
||||
/// the eve of Theophany does. Both are written the same way here.
|
||||
pub fn fixed_between(md: (u32, u32), start: (u32, u32), end: (u32, u32)) -> bool {
|
||||
if start <= end {
|
||||
start <= md && md <= end
|
||||
} else {
|
||||
md >= start || md <= end
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Where the default jurisdiction is remembered.
|
||||
//!
|
||||
//! Choosing a reckoning is a once-in-a-lifetime decision for most people, so it
|
||||
//! should not be a flag typed every morning. The first run writes the choice
|
||||
//! down; every run after reads it. An explicit flag always wins over the file.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const ENV_VAR: &str = "TYPIKON_JURISDICTION";
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Source {
|
||||
Flag,
|
||||
Environment,
|
||||
Config,
|
||||
Default,
|
||||
}
|
||||
|
||||
pub fn path() -> PathBuf {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var_os("HOME").unwrap_or_default();
|
||||
PathBuf::from(home).join(".config")
|
||||
});
|
||||
base.join("typikon").join("config.toml")
|
||||
}
|
||||
|
||||
/// The one key this file holds, read without a TOML parser because a
|
||||
/// dependency for `key = "value"` is a dependency too many.
|
||||
fn read() -> Option<String> {
|
||||
let text = std::fs::read_to_string(path()).ok()?;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
if key.trim() != "jurisdiction" {
|
||||
continue;
|
||||
}
|
||||
let value = value.trim().trim_matches(['"', '\'']);
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn write(jurisdiction: &str) -> std::io::Result<PathBuf> {
|
||||
let p = path();
|
||||
if let Some(dir) = p.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(
|
||||
&p,
|
||||
format!(
|
||||
"# Which reckoning typikon uses when no --jurisdiction is given.\n\
|
||||
# Run `typikon --list` to see every accepted value.\n\
|
||||
jurisdiction = \"{jurisdiction}\"\n"
|
||||
),
|
||||
)?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// The environment beats the config file; both beat the built-in default.
|
||||
pub fn default_jurisdiction() -> (Option<String>, Source) {
|
||||
if let Ok(v) = std::env::var(ENV_VAR) {
|
||||
if !v.is_empty() {
|
||||
return (Some(v), Source::Environment);
|
||||
}
|
||||
}
|
||||
match read() {
|
||||
Some(v) => (Some(v), Source::Config),
|
||||
None => (None, Source::Default),
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
//! A single day, fully reckoned.
|
||||
//!
|
||||
//! `Day` is what everything else in the crate produces and what every front end
|
||||
//! consumes. It carries the civil date, the ecclesiastical date, the season,
|
||||
//! the commemorations and the fast, and it carries the jurisdiction that
|
||||
//! produced it, so a caller can always ask by whose reckoning.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::calendars::{Date, Weekday, MONTHS};
|
||||
use crate::fasting::{Fast, Level, Obligation};
|
||||
use crate::jurisdictions::Jurisdiction;
|
||||
use crate::menologion::{self, Menologion, Rank};
|
||||
use crate::paschalion::{movable_cycle, Movables};
|
||||
use crate::rules::{self, Context};
|
||||
use crate::seasons::season_for;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Day {
|
||||
pub date: Date,
|
||||
pub weekday: Weekday,
|
||||
pub jurisdiction: &'static str,
|
||||
pub calendar: &'static str,
|
||||
/// The ecclesiastical date, as "25 December".
|
||||
pub church_date: String,
|
||||
pub season: Option<crate::seasons::Season>,
|
||||
pub cycle: Option<String>,
|
||||
pub feast: Option<String>,
|
||||
pub rank: Option<Rank>,
|
||||
pub commemorations: Vec<String>,
|
||||
pub fast: Level,
|
||||
pub fast_label: &'static str,
|
||||
pub emoji: &'static str,
|
||||
pub reason: String,
|
||||
pub obligation: Option<Obligation>,
|
||||
pub binds: Level,
|
||||
pub pascha: Date,
|
||||
pub days_to_pascha: i32,
|
||||
pub julian_offset: i32,
|
||||
}
|
||||
|
||||
impl Day {
|
||||
pub fn is_obligatory(&self) -> bool {
|
||||
self.obligation == Some(Obligation::Obligatory)
|
||||
}
|
||||
|
||||
/// What to put on one line: the feast if there is one, else the day's
|
||||
/// principal commemoration.
|
||||
pub fn title(&self) -> &str {
|
||||
match &self.feast {
|
||||
Some(f) => f,
|
||||
None => self.commemorations.first().map(String::as_str).unwrap_or(""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The wire format. Flat and stable, because front ends are the thing most
|
||||
/// likely to be written by someone else and a schema that shifts underneath
|
||||
/// them is a bad neighbour. Field names match the shape front ends already read.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayJson<'a> {
|
||||
pub date: String,
|
||||
pub weekday: &'a str,
|
||||
pub jurisdiction: &'a str,
|
||||
pub calendar: &'a str,
|
||||
pub church_date: &'a str,
|
||||
pub season: Option<&'a str>,
|
||||
pub cycle: Option<&'a str>,
|
||||
pub feast: Option<&'a str>,
|
||||
pub rank: Option<&'a str>,
|
||||
pub title: &'a str,
|
||||
pub commemorations: &'a [String],
|
||||
pub fast: &'a str,
|
||||
pub fast_label: &'a str,
|
||||
pub emoji: &'a str,
|
||||
pub reason: &'a str,
|
||||
pub obligation: Option<&'a str>,
|
||||
pub binds: &'a str,
|
||||
pub pascha: String,
|
||||
pub days_to_pascha: i32,
|
||||
pub julian_offset: i32,
|
||||
}
|
||||
|
||||
impl Day {
|
||||
pub fn to_json(&self) -> DayJson<'_> {
|
||||
DayJson {
|
||||
date: self.date.iso(),
|
||||
weekday: self.weekday.name(),
|
||||
jurisdiction: self.jurisdiction,
|
||||
calendar: self.calendar,
|
||||
church_date: &self.church_date,
|
||||
season: self.season.map(|s| s.name()),
|
||||
cycle: self.cycle.as_deref(),
|
||||
feast: self.feast.as_deref(),
|
||||
rank: self.rank.map(|r| r.key()),
|
||||
title: self.title(),
|
||||
commemorations: &self.commemorations,
|
||||
fast: self.fast.key(),
|
||||
fast_label: self.fast_label,
|
||||
emoji: self.emoji,
|
||||
reason: &self.reason,
|
||||
obligation: self.obligation.map(|o| match o {
|
||||
Obligation::Obligatory => "obligatory",
|
||||
Obligation::Customary => "customary",
|
||||
}),
|
||||
binds: self.binds.key(),
|
||||
pascha: self.pascha.iso(),
|
||||
days_to_pascha: self.days_to_pascha,
|
||||
julian_offset: self.julian_offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reckon one civil date in one jurisdiction.
|
||||
pub fn describe(date: Date, jur: &'static Jurisdiction) -> Day {
|
||||
let men = menologion::for_jurisdiction(jur.menaia);
|
||||
describe_with(date, jur, &men)
|
||||
}
|
||||
|
||||
/// As `describe`, but with a menologion the caller already built. Reckoning a
|
||||
/// year one day at a time should not rebuild the overlay 365 times.
|
||||
pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -> Day {
|
||||
let rule = rules::get(jur.rule);
|
||||
let (fy, fm, fd) = jur.fixed_of_civil(date);
|
||||
let mv = movable_cycle(date.year(), jur.paschalion);
|
||||
let apostles_end = jur.civil_of_fixed(date.year(), 6, 28);
|
||||
let season = season_for(date, (fm, fd), &mv, apostles_end);
|
||||
|
||||
let entry = men.get(fm, fd).cloned().unwrap_or_default();
|
||||
let mut commemorations = entry.commemorations;
|
||||
let mut rank = entry.rank;
|
||||
|
||||
// When a movable feast and a fixed one land together, the higher rank is
|
||||
// the day's feast and the other joins the commemorations. Letting the
|
||||
// movable one win unconditionally lost the Annunciation whenever it fell in
|
||||
// Great Week: the Great Feast of the Incarnation demoted to a bullet, and
|
||||
// its rank overwritten with the lesser one the fasting rules then read.
|
||||
let fixed_feast = rank
|
||||
.filter(|r| r.names_the_day())
|
||||
.and_then(|_| commemorations.first().cloned());
|
||||
let movable = menologion::movable_feast_for(date, &mv);
|
||||
|
||||
let feast: Option<String> = match (movable, &fixed_feast) {
|
||||
(Some((mov_title, mov_rank)), Some(fixed)) if rank > mov_rank => {
|
||||
let mov = mov_title.to_string();
|
||||
if !commemorations.contains(&mov) {
|
||||
commemorations.insert(1.min(commemorations.len()), mov);
|
||||
}
|
||||
Some(fixed.clone())
|
||||
}
|
||||
(Some((mov_title, mov_rank)), fixed) => {
|
||||
if mov_rank >= rank {
|
||||
rank = mov_rank;
|
||||
}
|
||||
if let Some(f) = fixed {
|
||||
if !commemorations.contains(f) {
|
||||
commemorations.insert(1.min(commemorations.len()), f.clone());
|
||||
}
|
||||
}
|
||||
Some(mov_title.to_string())
|
||||
}
|
||||
(None, fixed) => fixed.clone(),
|
||||
};
|
||||
|
||||
let ctx = Context {
|
||||
civil: date,
|
||||
fixed: (fm, fd),
|
||||
fixed_year: fy,
|
||||
weekday: date.weekday(),
|
||||
mv: &mv,
|
||||
season,
|
||||
feast: feast
|
||||
.clone()
|
||||
.or_else(|| commemorations.first().cloned())
|
||||
.unwrap_or_default(),
|
||||
rank,
|
||||
jurisdiction: jur,
|
||||
};
|
||||
|
||||
let Fast { level, reason, obligation, binds } = (rule.fast_for)(&ctx);
|
||||
|
||||
Day {
|
||||
date,
|
||||
weekday: date.weekday(),
|
||||
jurisdiction: jur.key,
|
||||
calendar: jur.calendar().key(),
|
||||
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
|
||||
season,
|
||||
cycle: entry.cycle,
|
||||
feast,
|
||||
rank,
|
||||
commemorations,
|
||||
fast: level,
|
||||
fast_label: (rule.vocabulary.label)(level),
|
||||
emoji: (rule.vocabulary.emoji)(level),
|
||||
reason,
|
||||
obligation,
|
||||
binds,
|
||||
pascha: mv.pascha,
|
||||
days_to_pascha: mv.pascha - date,
|
||||
julian_offset: date.julian_offset(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `days` consecutive days from `start`.
|
||||
pub fn week(start: Date, days: i32, jur: &'static Jurisdiction) -> Vec<Day> {
|
||||
let men = menologion::for_jurisdiction(jur.menaia);
|
||||
(0..days.max(1))
|
||||
.map(|n| describe_with(start + n, jur, &men))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every key date of a year, for checking against a published calendar.
|
||||
pub fn year_of(year: i32, jur: &'static Jurisdiction) -> (Movables, [(&'static str, Date); 4]) {
|
||||
let mv = movable_cycle(year, jur.paschalion);
|
||||
let fixed = [
|
||||
("apostles end", jur.civil_of_fixed(year, 6, 28)),
|
||||
("dormition", jur.civil_of_fixed(year, 8, 15)),
|
||||
("nativity", jur.civil_of_fixed(year, 12, 25)),
|
||||
("theophany", jur.civil_of_fixed(year, 1, 6)),
|
||||
];
|
||||
(mv, fixed)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! iCalendar, so the calendar can live where the rest of your year does.
|
||||
//!
|
||||
//! Each day becomes one all-day event. The summary is the feast or the
|
||||
//! principal commemoration; the description carries the fast, the season and
|
||||
//! the rest of the day's commemorations. Days with nothing to say, no feast and
|
||||
//! no fast, are left out, because a subscribed calendar that marks all 365 days
|
||||
//! is one nobody keeps.
|
||||
|
||||
use crate::day::Day;
|
||||
use crate::fasting::{Level, Obligation};
|
||||
|
||||
/// A count of octets, which is what RFC 5545 measures content lines in.
|
||||
///
|
||||
/// A newtype because the Python this was ported from folded on *characters*
|
||||
/// instead, and an emoji is four octets: lines went eight over a limit the code
|
||||
/// believed it was respecting. Counting the wrong unit is not a mistake a name
|
||||
/// can prevent, but it is one a type can.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
struct Octets(usize);
|
||||
|
||||
/// RFC 5545: a content line SHOULD NOT be longer than 75 octets, excluding the
|
||||
/// line break.
|
||||
const LIMIT: Octets = Octets(75);
|
||||
|
||||
fn octets_of(c: char) -> Octets {
|
||||
Octets(c.len_utf8())
|
||||
}
|
||||
|
||||
fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str(r"\\"),
|
||||
';' => out.push_str(r"\;"),
|
||||
',' => out.push_str(r"\,"),
|
||||
'\n' => out.push_str(r"\n"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Break a content line on octets, continued with a leading space. The split
|
||||
/// never falls inside a character: the budget is spent per codepoint, so a
|
||||
/// four-octet emoji moves to the next line whole.
|
||||
fn fold(line: &str) -> String {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut used = Octets(0);
|
||||
for c in line.chars() {
|
||||
let n = octets_of(c);
|
||||
if Octets(used.0 + n.0) > LIMIT {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
cur.push(' ');
|
||||
used = Octets(1);
|
||||
}
|
||||
cur.push(c);
|
||||
used = Octets(used.0 + n.0);
|
||||
}
|
||||
out.push(cur);
|
||||
out.join("\r\n")
|
||||
}
|
||||
|
||||
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
||||
let mut lines: Vec<String> = vec![
|
||||
"BEGIN:VCALENDAR".into(),
|
||||
"VERSION:2.0".into(),
|
||||
"PRODID:-//typikon//EN".into(),
|
||||
"CALSCALE:GREGORIAN".into(),
|
||||
"METHOD:PUBLISH".into(),
|
||||
format!("X-WR-CALNAME:{}", escape(name)),
|
||||
];
|
||||
|
||||
for d in days {
|
||||
if d.feast.is_none() && d.fast == Level::None {
|
||||
continue;
|
||||
}
|
||||
let start = d.date.iso().replace('-', "");
|
||||
let end = (d.date + 1).iso().replace('-', "");
|
||||
let summary = if d.emoji.is_empty() {
|
||||
d.title().to_string()
|
||||
} else {
|
||||
format!("{} {}", d.emoji, d.title())
|
||||
};
|
||||
|
||||
let mut body: Vec<String> = Vec::new();
|
||||
if d.fast != Level::None {
|
||||
body.push(if d.reason.is_empty() {
|
||||
format!("Fast: {}", d.fast_label)
|
||||
} else {
|
||||
format!("Fast: {} ({})", d.fast_label, d.reason)
|
||||
});
|
||||
}
|
||||
if d.obligation == Some(Obligation::Obligatory) {
|
||||
body.push("Obligatory.".into());
|
||||
}
|
||||
if let Some(s) = d.season {
|
||||
body.push(format!("Season: {}", s.name()));
|
||||
}
|
||||
if let Some(c) = &d.cycle {
|
||||
let mut chars = c.chars();
|
||||
let capped = match chars.next() {
|
||||
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
};
|
||||
body.push(capped);
|
||||
}
|
||||
body.push(format!("Church date: {}", d.church_date));
|
||||
for c in &d.commemorations {
|
||||
body.push(c.clone());
|
||||
}
|
||||
|
||||
lines.extend([
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}-{}@typikon", start, d.jurisdiction),
|
||||
format!("DTSTAMP:{stamp}"),
|
||||
format!("DTSTART;VALUE=DATE:{start}"),
|
||||
format!("DTEND;VALUE=DATE:{end}"),
|
||||
fold(&format!("SUMMARY:{}", escape(&summary))),
|
||||
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))),
|
||||
"TRANSP:TRANSPARENT".into(),
|
||||
"END:VEVENT".into(),
|
||||
]);
|
||||
}
|
||||
|
||||
lines.push("END:VCALENDAR".into());
|
||||
let mut s = lines.join("\r\n");
|
||||
s.push_str("\r\n");
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! JSON, which is how every front end reads this.
|
||||
//!
|
||||
//! The shape is flat and stable: front ends are the thing most likely to be
|
||||
//! written by someone else, and a schema that shifts underneath them is a bad
|
||||
//! neighbour. One day is an object; several are `{"days": [...]}`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::day::{Day, DayJson};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Days<'a> {
|
||||
days: Vec<DayJson<'a>>,
|
||||
}
|
||||
|
||||
pub fn to_json(days: &[Day], indent: Option<usize>) -> String {
|
||||
match days {
|
||||
[one] => encode(&one.to_json(), indent),
|
||||
many => encode(
|
||||
&Days { days: many.iter().map(Day::to_json).collect() },
|
||||
indent,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode<T: Serialize>(value: &T, indent: Option<usize>) -> String {
|
||||
match indent {
|
||||
None => serde_json::to_string(value).expect("a day serialises"),
|
||||
Some(n) => {
|
||||
let pad = vec![b' '; n];
|
||||
let fmt = serde_json::ser::PrettyFormatter::with_indent(&pad);
|
||||
let mut buf = Vec::new();
|
||||
let mut ser = serde_json::Serializer::with_formatter(&mut buf, fmt);
|
||||
value.serialize(&mut ser).expect("a day serialises");
|
||||
String::from_utf8(buf).expect("serde emits utf-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod ics;
|
||||
pub mod json;
|
||||
|
||||
pub use ics::to_ics;
|
||||
pub use json::to_json;
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
//! What is kept on a day, and whether anything about it binds.
|
||||
//!
|
||||
//! Six levels, ordered from strictest to laxest. They are the Typikon's own
|
||||
//! distinctions, not a simplification of them: the difference between a day of
|
||||
//! xerophagy and a day of wine and oil is the difference the books actually
|
||||
//! make, and a calendar that reports both as "fasting" has thrown away the
|
||||
//! information its reader wanted.
|
||||
//!
|
||||
//! The same six serve a tradition that makes fewer distinctions. Ruthenian
|
||||
//! particular law knows three degrees, and each maps exactly onto one of these:
|
||||
//! its "strict abstinence" is `WineOil`, because it does not regulate oil or
|
||||
//! wine, and its "abstinence" is `Dairy`, meat alone being set aside. So a
|
||||
//! ruleset emits the subset it uses and names those levels in its own
|
||||
//! vocabulary, rather than every tradition being forced through one set of
|
||||
//! words.
|
||||
//!
|
||||
//! **Level and obligation are separate questions.** A day can be kept strictly
|
||||
//! by universal custom and bind no one, and that is the ordinary case in the
|
||||
//! Eastern Catholic churches, whose particular law requires very little.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Ordered strictest first, so `a < b` reads as "a is stricter than b" and the
|
||||
/// derived `Ord` is the comparison the rules want.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Level {
|
||||
/// No food until evening.
|
||||
Strict,
|
||||
/// No oil, no wine; no meat, fish, eggs or dairy.
|
||||
Xerophagy,
|
||||
/// Wine and oil permitted; no meat, fish, eggs or dairy.
|
||||
WineOil,
|
||||
/// Fish, wine and oil permitted; no meat, eggs or dairy.
|
||||
Fish,
|
||||
/// Dairy, eggs and fish permitted; no meat.
|
||||
Dairy,
|
||||
/// No fast.
|
||||
None,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Level::Strict => "strict",
|
||||
Level::Xerophagy => "xerophagy",
|
||||
Level::WineOil => "wine_oil",
|
||||
Level::Fish => "fish",
|
||||
Level::Dairy => "dairy",
|
||||
Level::None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_fast(self) -> bool {
|
||||
self != Level::None
|
||||
}
|
||||
|
||||
/// The stricter of two levels.
|
||||
pub fn stricter_of(self, other: Level) -> Level {
|
||||
self.min(other)
|
||||
}
|
||||
|
||||
/// The laxer of two levels.
|
||||
pub fn laxer_of(self, other: Level) -> Level {
|
||||
self.max(other)
|
||||
}
|
||||
|
||||
/// Relax towards `target`, but never past `cap` and never tightening.
|
||||
///
|
||||
/// This is the arithmetic every "a feast lifts the fast" rule wants, in one
|
||||
/// place, so no rule can get the direction backwards.
|
||||
pub fn relaxed_to(self, target: Level, cap: Level) -> Level {
|
||||
let lifted = self.laxer_of(target).stricter_of(cap);
|
||||
if lifted < self {
|
||||
self
|
||||
} else {
|
||||
lifted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether anything about the day actually binds. The Orthodox Church keeps the
|
||||
/// fast as tradition rather than precept, so its rule reports `Customary`
|
||||
/// throughout; the Eastern Catholic churches have law, and theirs does not.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Obligation {
|
||||
Obligatory,
|
||||
Customary,
|
||||
}
|
||||
|
||||
/// How a day is kept, and what about it binds.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Fast {
|
||||
/// The day as the tradition keeps it.
|
||||
pub level: Level,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Fast {
|
||||
pub fn new(level: Level, reason: &str, obligation: Option<Obligation>) -> Fast {
|
||||
Fast {
|
||||
level,
|
||||
reason: reason.to_string(),
|
||||
obligation,
|
||||
binds: Level::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A day kept by custom, binding no one.
|
||||
pub fn customary(level: Level, reason: &str) -> Fast {
|
||||
Fast::new(level, reason, Some(Obligation::Customary))
|
||||
}
|
||||
|
||||
/// A day with no fast at all.
|
||||
pub fn free(reason: &str) -> Fast {
|
||||
Fast::new(Level::None, reason, None)
|
||||
}
|
||||
|
||||
/// A day that binds, and the minimum it binds to.
|
||||
pub fn binding(level: Level, reason: &str, binds: Level) -> Fast {
|
||||
Fast {
|
||||
level,
|
||||
reason: reason.to_string(),
|
||||
obligation: Some(Obligation::Obligatory),
|
||||
binds,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Who keeps which calendar, and by whose rule.
|
||||
//!
|
||||
//! A jurisdiction is four independent choices, and conflating any two of them
|
||||
//! is how calendar code usually goes wrong:
|
||||
//!
|
||||
//! - **paschalion**: which table gives Pascha
|
||||
//! - **menologion**: which calendar the fixed feasts are read on
|
||||
//! - **rule**: which fasting discipline is kept
|
||||
//! - **menaia**: which supplementary commemorations to load, if any
|
||||
//!
|
||||
//! The pairings are not free. Almost every Orthodox Church keeps the Julian
|
||||
//! Paschalion; what divides them is the menologion. The "old calendar" churches
|
||||
//! read the fixed feasts on the Julian calendar too, the "new calendar"
|
||||
//! churches on the Revised Julian, which agrees with the Gregorian for every
|
||||
//! date until 2800. So a new-calendar church keeps the Nativity with the West
|
||||
//! and Pascha with the East, which is not a compromise but exactly what the
|
||||
//! 1923 congress decided.
|
||||
//!
|
||||
//! Only two bodies here use the Gregorian Paschalion: the Finnish Orthodox
|
||||
//! Church, alone among the Orthodox, and the Byzantine Catholic churches.
|
||||
|
||||
use crate::calendars::Date;
|
||||
use crate::paschalion::Paschalion;
|
||||
|
||||
/// Which calendar the *fixed* feasts are read on.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum MenologionKind {
|
||||
Julian,
|
||||
/// Revised Julian, which agrees with the Gregorian for every date until 2800.
|
||||
RevisedJulian,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum RuleKind {
|
||||
Orthodox,
|
||||
Ruthenian,
|
||||
}
|
||||
|
||||
/// The short name people use for a paschalion/menologion combination.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Calendar {
|
||||
Old,
|
||||
New,
|
||||
Western,
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Calendar::Old => "old",
|
||||
Calendar::New => "new",
|
||||
Calendar::Western => "western",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Jurisdiction {
|
||||
pub key: &'static str,
|
||||
pub name: &'static str,
|
||||
pub paschalion: Paschalion,
|
||||
pub menologion: MenologionKind,
|
||||
pub rule: RuleKind,
|
||||
/// Supplementary commemoration sets to overlay, fetched not shipped.
|
||||
pub menaia: &'static [&'static str],
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
impl Jurisdiction {
|
||||
pub fn calendar(&self) -> Calendar {
|
||||
match (self.paschalion, self.menologion) {
|
||||
(Paschalion::Gregorian, MenologionKind::RevisedJulian) => Calendar::Western,
|
||||
(_, MenologionKind::Julian) => Calendar::Old,
|
||||
_ => Calendar::New,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ecclesiastical (year, month, day) a civil date is reckoned as.
|
||||
pub fn fixed_of_civil(&self, date: Date) -> (i32, u32, u32) {
|
||||
match self.menologion {
|
||||
MenologionKind::Julian => {
|
||||
let j = date.to_julian();
|
||||
(j.year, j.month, j.day)
|
||||
}
|
||||
MenologionKind::RevisedJulian => date.ymd(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The civil date of an ecclesiastical date in a given church year.
|
||||
pub fn civil_of_fixed(&self, year: i32, month: u32, day: u32) -> Date {
|
||||
match self.menologion {
|
||||
MenologionKind::Julian => {
|
||||
crate::calendars::JulianDate { year, month, day }.to_civil()
|
||||
}
|
||||
MenologionKind::RevisedJulian => Date::from_ymd(year, month, day)
|
||||
.unwrap_or_else(|| Date::from_ymd(year, month, 28).expect("28th exists")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use MenologionKind::{Julian as JulMen, RevisedJulian as RevJul};
|
||||
use Paschalion::{Gregorian as GregP, Julian as JulP};
|
||||
use RuleKind::{Orthodox as Orth, Ruthenian as Ruth};
|
||||
|
||||
const NO_MENAIA: &[&str] = &[];
|
||||
const RUTHENIAN_MENAIA: &[&str] = &["ruthenian"];
|
||||
|
||||
/// A reckoning without naming a church. `new` is the default.
|
||||
pub const CALENDARS: &[Jurisdiction] = &[
|
||||
Jurisdiction { key: "old", name: "Old Calendar", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Julian throughout, the reckoning of Moscow, Serbia, Georgia, Jerusalem and the Holy Mountain." },
|
||||
Jurisdiction { key: "new", name: "New Calendar", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Revised Julian fixed feasts with the Julian Paschalion: Constantinople, Greece, Antioch, Romania, Bulgaria, the OCA." },
|
||||
Jurisdiction { key: "western", name: "Gregorian", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Gregorian throughout, Pascha included. Kept by the Finnish Orthodox Church and by most Byzantine Catholics." },
|
||||
];
|
||||
|
||||
pub const JURISDICTIONS: &[Jurisdiction] = &[
|
||||
// old calendar
|
||||
Jurisdiction { key: "moscow", name: "Russian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "rocor", name: "Russian Orthodox Church Outside Russia", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "serbian", name: "Serbian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "georgian", name: "Georgian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "jerusalem", name: "Patriarchate of Jerusalem", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "athos", name: "The Holy Mountain", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Athonite practice is stricter than the printed Typikon at many points." },
|
||||
Jurisdiction { key: "polish", name: "Polish Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Returned to the Julian menologion in 2014 after decades on the Revised Julian; some parishes kept the new calendar." },
|
||||
Jurisdiction { key: "macedonian", name: "Macedonian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
|
||||
// new calendar
|
||||
Jurisdiction { key: "constantinople", name: "Ecumenical Patriarchate", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "goarch", name: "Greek Orthodox Archdiocese of America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "greece", name: "Church of Greece", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "antiochian", name: "Antiochian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "oca", name: "Orthodox Church in America", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Most parishes keep the new calendar; a minority, chiefly of Carpatho-Russian and Alaskan background, keep the old." },
|
||||
Jurisdiction { key: "romanian", name: "Romanian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "bulgarian", name: "Bulgarian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "cyprus", name: "Church of Cyprus", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "albanian", name: "Orthodox Church of Albania", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA, note: "" },
|
||||
Jurisdiction { key: "ukrainian", name: "Orthodox Church of Ukraine", paschalion: JulP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "Moved the fixed feasts to the Revised Julian calendar on 1 September 2023. The Julian Paschalion is retained." },
|
||||
|
||||
// gregorian throughout
|
||||
Jurisdiction { key: "finnish", name: "Finnish Orthodox Church", paschalion: GregP, menologion: RevJul, rule: Orth, menaia: NO_MENAIA,
|
||||
note: "The only Orthodox Church keeping the Gregorian Paschalion." },
|
||||
|
||||
// eastern catholic
|
||||
Jurisdiction { key: "ruthenian", name: "Byzantine Catholic Metropolia of Pittsburgh", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
|
||||
note: "Gregorian throughout. Fasting is reported under Ruthenian particular law, which binds far less than the Typikon prescribes." },
|
||||
Jurisdiction { key: "ugcc", name: "Ukrainian Greek Catholic Church", paschalion: JulP, menologion: RevJul, rule: Ruth, menaia: RUTHENIAN_MENAIA,
|
||||
note: "Moved the fixed feasts to the Revised Julian calendar on 1 September 2023, keeping the Julian Paschalion. Eparchies outside Ukraine vary; several keep the Gregorian Paschalion." },
|
||||
Jurisdiction { key: "melkite", name: "Melkite Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA,
|
||||
note: "Practice is not uniform: parishes in the Middle East commonly keep the Julian Paschalion for the sake of the local Orthodox." },
|
||||
Jurisdiction { key: "romanian-catholic", name: "Romanian Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, menaia: NO_MENAIA, note: "" },
|
||||
];
|
||||
|
||||
/// A jurisdiction key, a calendar name, or `None` for the default.
|
||||
pub fn resolve(name: Option<&str>) -> Result<&'static Jurisdiction, String> {
|
||||
let Some(name) = name else {
|
||||
return Ok(&CALENDARS[1]); // new
|
||||
};
|
||||
let key = name.trim().to_ascii_lowercase();
|
||||
JURISDICTIONS
|
||||
.iter()
|
||||
.chain(CALENDARS.iter())
|
||||
.find(|j| j.key == key)
|
||||
.ok_or_else(|| name.to_string())
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
pub mod calendars;
|
||||
pub mod config;
|
||||
pub mod day;
|
||||
pub mod export;
|
||||
pub mod fasting;
|
||||
pub mod jurisdictions;
|
||||
pub mod menaia;
|
||||
pub mod menologion;
|
||||
pub mod paschalion;
|
||||
pub mod rules;
|
||||
pub mod seasons;
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
//! The command line.
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use typikon::calendars::Date;
|
||||
use typikon::config::{self, Source};
|
||||
use typikon::day::{week, year_of, Day};
|
||||
use typikon::export::{to_ics, to_json};
|
||||
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
|
||||
use typikon::menaia;
|
||||
use typikon::rules;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "typikon",
|
||||
version,
|
||||
about = "The Orthodox liturgical day: feasts, seasons and the fast."
|
||||
)]
|
||||
struct Args {
|
||||
/// A church or a reckoning; see --list
|
||||
#[arg(short = 'j', long, visible_alias = "calendar", short_alias = 'c')]
|
||||
jurisdiction: Option<String>,
|
||||
|
||||
/// YYYY-MM-DD (default: today)
|
||||
#[arg(short, long)]
|
||||
date: Option<String>,
|
||||
|
||||
/// How many days from the start date, inclusive
|
||||
#[arg(short = 'n', long, default_value_t = 1)]
|
||||
days: i32,
|
||||
|
||||
/// Machine-readable output
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// An iCalendar feed
|
||||
#[arg(long)]
|
||||
ics: bool,
|
||||
|
||||
/// Indent JSON output
|
||||
#[arg(long)]
|
||||
indent: Option<usize>,
|
||||
|
||||
/// Print a year's key dates and exit
|
||||
#[arg(long)]
|
||||
year: Option<i32>,
|
||||
|
||||
/// List every jurisdiction and calendar
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
|
||||
/// Remember a jurisdiction and exit
|
||||
#[arg(long, value_name = "JURISDICTION")]
|
||||
set_default: Option<String>,
|
||||
|
||||
/// Fetch a supplementary commemoration set
|
||||
#[arg(long, value_name = "NAME")]
|
||||
update_menaion: Option<String>,
|
||||
|
||||
/// List the supplementary sets that can be fetched
|
||||
#[arg(long)]
|
||||
list_menaia: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
fn wrap(text: &str, width: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split_whitespace() {
|
||||
if !line.is_empty() && line.chars().count() + word.chars().count() + 1 > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
}
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
}
|
||||
if !line.is_empty() {
|
||||
out.push(line);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn list_jurisdictions() {
|
||||
println!("\n Calendars, a reckoning without naming a church:\n");
|
||||
for j in CALENDARS {
|
||||
println!(" {:20} {}", j.key, j.name);
|
||||
for line in wrap(j.note, 56) {
|
||||
println!(" {:20} {}", "", line);
|
||||
}
|
||||
}
|
||||
println!("\n Jurisdictions:\n");
|
||||
let width = JURISDICTIONS.iter().map(|j| j.key.len()).max().unwrap_or(12);
|
||||
for j in JURISDICTIONS {
|
||||
let cal = match j.calendar() {
|
||||
jurisdictions::Calendar::Old => "old calendar",
|
||||
jurisdictions::Calendar::New => "new calendar",
|
||||
jurisdictions::Calendar::Western => "gregorian",
|
||||
};
|
||||
let rule = rules::get(j.rule).name;
|
||||
println!(" {:width$} {}", j.key, j.name);
|
||||
println!(" {:width$} {cal}; {rule} fasting rule", "");
|
||||
for line in wrap(j.note, 66) {
|
||||
println!(" {:width$} {line}", "");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
fn elide(text: &str, width: usize) -> String {
|
||||
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(' ') {
|
||||
Some(_) => match cut.trim_end().rfind(' ') {
|
||||
Some(i) => &cut[..i],
|
||||
None => &cut,
|
||||
},
|
||||
None => &cut,
|
||||
};
|
||||
format!("{}\u{2026}", head.trim_end_matches([' ', ',', ';']))
|
||||
}
|
||||
|
||||
fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
|
||||
let rule = rules::get(jur.rule);
|
||||
if let [d] = days {
|
||||
let mut head = format!(" {} {}", d.weekday.name(), d.date.long());
|
||||
let civil_md = format!("{} {}", d.date.day(), typikon::calendars::MONTHS[(d.date.month() - 1) as usize]);
|
||||
if d.church_date != civil_md {
|
||||
head.push_str(&format!(" \u{2014} {} in the church calendar", d.church_date));
|
||||
}
|
||||
println!("\n{head}");
|
||||
let from = match source {
|
||||
Source::Flag => String::new(),
|
||||
Source::Environment => " (from the environment)".into(),
|
||||
Source::Config => " (from config)".into(),
|
||||
Source::Default => String::new(),
|
||||
};
|
||||
println!(" {} \u{b7} {}{from}", jur.name, rule.title);
|
||||
println!();
|
||||
println!(" {} {}", d.emoji, d.title());
|
||||
if let Some(c) = &d.cycle {
|
||||
println!(" {c}");
|
||||
}
|
||||
println!();
|
||||
let mark = if d.is_obligatory() { " \u{2014} obligatory" } else { "" };
|
||||
println!(" {}{mark}", d.fast_label);
|
||||
if !d.reason.is_empty() {
|
||||
println!(" {}", d.reason);
|
||||
}
|
||||
if let Some(s) = d.season {
|
||||
println!(" {}", s.name());
|
||||
}
|
||||
let rest: Vec<_> = d.commemorations.iter().filter(|c| c.as_str() != d.title()).collect();
|
||||
if !rest.is_empty() {
|
||||
println!();
|
||||
for c in rest {
|
||||
println!(" \u{b7} {c}");
|
||||
}
|
||||
}
|
||||
if verbose {
|
||||
println!();
|
||||
println!(" Pascha {} \u{b7} {} days", d.pascha, d.days_to_pascha);
|
||||
}
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!("\n {} \u{b7} {}\n", jur.name, rule.title);
|
||||
// The glyph is two columns wide in a terminal and one char to Rust, which
|
||||
// is why the arithmetic below looks off by one.
|
||||
let fixed = 2 + 2 + 10 + 1 + 1 + 32;
|
||||
let cols = std::env::var("COLUMNS").ok().and_then(|c| c.parse().ok()).unwrap_or(100usize);
|
||||
let room = cols.saturating_sub(fixed + 1).max(24);
|
||||
for d in days {
|
||||
let mark = if d.is_obligatory() { "*" } else { " " };
|
||||
println!(
|
||||
" {} {:>10}{mark} {:<32}{}",
|
||||
d.emoji,
|
||||
d.date.short(),
|
||||
d.fast_label,
|
||||
elide(d.title(), room)
|
||||
);
|
||||
}
|
||||
if days.iter().any(Day::is_obligatory) {
|
||||
println!("\n * obligatory. Everything unmarked is customary and does not bind.");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn run() -> Result<i32, String> {
|
||||
let args = Args::parse();
|
||||
|
||||
if args.list {
|
||||
list_jurisdictions();
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if args.list_menaia {
|
||||
println!("\n Supplementary commemorations, fetched on request:\n");
|
||||
for s in menaia::SOURCES {
|
||||
println!(" {:14} {}", s.key, s.title);
|
||||
println!(" {:14} {}", "", s.url);
|
||||
for line in wrap(s.note, 62) {
|
||||
println!(" {:14} {line}", "");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
println!(" They are written to {}\n", typikon::menologion::data_dir().display());
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if let Some(name) = &args.update_menaion {
|
||||
let f = menaia::update(name)?;
|
||||
println!(" {} days written to {}", f.days, f.path.display());
|
||||
if let Some(s) = menaia::find(name) {
|
||||
println!(" Source: {}", s.title);
|
||||
}
|
||||
if !f.corrections.is_empty() {
|
||||
println!(" source typos corrected: {}", f.corrections.join(", "));
|
||||
}
|
||||
if !f.missing.is_empty() {
|
||||
println!(" MISSING: {}", f.missing.join(", "));
|
||||
return Ok(1);
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if let Some(name) = &args.set_default {
|
||||
let jur = jurisdictions::resolve(Some(name))
|
||||
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
|
||||
let where_ = config::write(jur.key).map_err(|e| e.to_string())?;
|
||||
println!(" {} \u{2014} written to {}", jur.name, where_.display());
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let (name, source) = match &args.jurisdiction {
|
||||
Some(j) => (Some(j.clone()), Source::Flag),
|
||||
None => config::default_jurisdiction(),
|
||||
};
|
||||
let jur = jurisdictions::resolve(name.as_deref())
|
||||
.map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?;
|
||||
|
||||
if let Some(year) = args.year {
|
||||
if !(1..=9999).contains(&year) {
|
||||
return Err(format!("{year} is not a year this calendar reaches"));
|
||||
}
|
||||
let (mv, fixed) = year_of(year, jur);
|
||||
println!("\n {year} \u{b7} {}\n", jur.name);
|
||||
for (label, d) in [
|
||||
("publican pharisee", mv.publican_pharisee),
|
||||
("meatfare", mv.meatfare),
|
||||
("cheesefare", mv.cheesefare),
|
||||
("clean monday", mv.clean_monday),
|
||||
("great fast end", mv.great_fast_end),
|
||||
("lazarus saturday", mv.lazarus_saturday),
|
||||
("palm sunday", mv.palm_sunday),
|
||||
("great friday", mv.great_friday),
|
||||
("pascha", mv.pascha),
|
||||
("ascension", mv.ascension),
|
||||
("pentecost", mv.pentecost),
|
||||
("all saints", mv.all_saints),
|
||||
("apostles start", mv.apostles_start),
|
||||
] {
|
||||
println!(" {label:20} {d} {}", d.weekday().short());
|
||||
}
|
||||
for (label, d) in fixed {
|
||||
println!(" {label:20} {d} {}", d.weekday().short());
|
||||
}
|
||||
println!();
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let start = match &args.date {
|
||||
Some(s) => Date::parse_iso(s)
|
||||
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
|
||||
None => today(),
|
||||
};
|
||||
let days = week(start, args.days, jur);
|
||||
|
||||
if args.json {
|
||||
println!("{}", to_json(&days, args.indent));
|
||||
return Ok(0);
|
||||
}
|
||||
if args.ics {
|
||||
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
print_days(&days, jur, source, args.verbose);
|
||||
|
||||
// The first run should say what it assumed, and then stop saying it.
|
||||
if source == Source::Default {
|
||||
if let Ok(p) = config::write(jur.key) {
|
||||
println!(" Reckoning by the {}, written to {}.", jur.name.to_lowercase(), p.display());
|
||||
println!(" Change it with `typikon --set-default <jurisdiction>`, or see `typikon --list`.\n");
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Today, from the system clock, without pulling in a date library for it.
|
||||
fn today() -> Date {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Seconds east of UTC, read from /etc/localtime through the `date` command
|
||||
/// only if TZ arithmetic is needed. Falls back to UTC.
|
||||
fn local_offset_secs(_now: i64) -> i64 {
|
||||
if let Ok(out) = std::process::Command::new("date").arg("+%z").output() {
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
let s = s.trim();
|
||||
if s.len() == 5 {
|
||||
if let (Ok(h), Ok(m)) = (s[1..3].parse::<i64>(), s[3..5].parse::<i64>()) {
|
||||
let mag = h * 3600 + m * 60;
|
||||
return if s.starts_with('-') { -mag } else { mag };
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn stamp() -> String {
|
||||
let out = std::process::Command::new("date")
|
||||
.args(["-u", "+%Y%m%dT%H%M%SZ"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
_ => "19700101T000000Z".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
match run() {
|
||||
Ok(code) => std::process::exit(code),
|
||||
Err(msg) => {
|
||||
eprintln!("typikon: {msg}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
//! Supplementary commemorations, fetched rather than shipped.
|
||||
//!
|
||||
//! The menologion compiled into this binary is the common Byzantine one,
|
||||
//! composed for this project and licensed with the rest of it. Some churches
|
||||
//! also keep their own saints, and those lists are usually published by a body
|
||||
//! that holds the copyright in the wording. Redistributing them would be taking
|
||||
//! someone else's work; fetching them onto your own machine, for your own use,
|
||||
//! is not.
|
||||
//!
|
||||
//! So supplementary sets are **not** in the binary. `typikon --update-menaion
|
||||
//! ruthenian` fetches one into your data directory, where the menologion loader
|
||||
//! picks it up automatically and overlays it on the common set.
|
||||
//!
|
||||
//! The fetch shells out to `curl`, deliberately. Linking an HTTP stack and a
|
||||
//! TLS stack into a calendar would be several megabytes and a standing audit
|
||||
//! surface, for one command most people run once.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
pub struct Source {
|
||||
pub key: &'static str,
|
||||
pub title: &'static str,
|
||||
pub url: &'static str,
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
pub const SOURCES: &[Source] = &[Source {
|
||||
key: "ruthenian",
|
||||
title: "Byzantine Catholic Metropolia of Pittsburgh",
|
||||
url: "https://mci.archpitt.org/liturgy/Calendar_of_Saints.html",
|
||||
note: "Published by the Metropolitan Cantor Institute. Fetched for your own \
|
||||
use; not redistributed with this program.",
|
||||
}];
|
||||
|
||||
const MONTHS: [&str; 12] = [
|
||||
"January", "February", "March", "April", "May", "June", "July", "August",
|
||||
"September", "October", "November", "December",
|
||||
];
|
||||
|
||||
pub fn find(name: &str) -> Option<&'static Source> {
|
||||
SOURCES.iter().find(|s| s.key == name)
|
||||
}
|
||||
|
||||
fn fetch(url: &str) -> Result<String, String> {
|
||||
let out = Command::new("curl")
|
||||
.args(["-sSL", "--max-time", "30", "-A", "typikon", url])
|
||||
.output()
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => {
|
||||
"curl is not on PATH, and the fetch needs it".to_string()
|
||||
}
|
||||
_ => e.to_string(),
|
||||
})?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn strip_html(html: &str) -> Vec<String> {
|
||||
let mut text = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag = String::new();
|
||||
for c in html.chars() {
|
||||
match c {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag.clear();
|
||||
}
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
let t = tag.trim_start_matches('/').to_ascii_lowercase();
|
||||
let t = t.split_whitespace().next().unwrap_or("");
|
||||
if matches!(t, "br" | "p" | "div" | "li" | "h1" | "h2" | "h3" | "h4") {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push('\n');
|
||||
}
|
||||
_ if in_tag => tag.push(c),
|
||||
_ => text.push(c),
|
||||
}
|
||||
}
|
||||
for (from, to) in [
|
||||
(" ", " "), ("&", "&"), ("’", "'"),
|
||||
("’", "'"), ("—", "\u{2014}"), (""", "\""),
|
||||
] {
|
||||
text = text.replace(from, to);
|
||||
}
|
||||
text.lines()
|
||||
.map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn month_index(word: &str) -> Option<u32> {
|
||||
MONTHS.iter().position(|m| *m == word).map(|i| i as u32 + 1)
|
||||
}
|
||||
|
||||
/// Split a line into (month, day, rest) when it opens a dated entry.
|
||||
fn dated(line: &str) -> Option<(u32, u32, String)> {
|
||||
let mut it = line.splitn(3, ' ');
|
||||
let month = month_index(it.next()?)?;
|
||||
let day: u32 = it.next()?.trim_end_matches(['.', ',']).parse().ok()?;
|
||||
Some((month, day, it.next().unwrap_or("").trim().to_string()))
|
||||
}
|
||||
|
||||
fn is_heading(line: &str) -> bool {
|
||||
month_index(line.trim_end_matches('.')).is_some()
|
||||
}
|
||||
|
||||
pub struct Fetched {
|
||||
pub path: std::path::PathBuf,
|
||||
pub days: usize,
|
||||
pub corrections: Vec<String>,
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCI publishes prose, not a table, so entries split on the date headings.
|
||||
///
|
||||
/// The page has at least one typo: 14 October is written "October 1", which
|
||||
/// silently overwrites the real entry for that day and takes the Protection of
|
||||
/// the Theotokos with it. A date that goes backwards inside a month is the
|
||||
/// giveaway, and is corrected to follow the previous one.
|
||||
pub fn update(name: &str) -> Result<Fetched, String> {
|
||||
let source = find(name).ok_or_else(|| {
|
||||
let known: Vec<_> = SOURCES.iter().map(|s| s.key).collect();
|
||||
format!("unknown menaion: {name:?}; try one of {}", known.join(", "))
|
||||
})?;
|
||||
|
||||
let lines = strip_html(&fetch(source.url)?);
|
||||
|
||||
// MCI writes the festal cycle inline, as a commemoration among the saints.
|
||||
// It repeats for days at a time and is not a saint, so it becomes the day's
|
||||
// cycle and leaves the list alone.
|
||||
let festal = [
|
||||
("pre-festive", "forefeast"), ("fore-feast", "forefeast"),
|
||||
("post-festive", "afterfeast"), ("after-feast", "afterfeast"),
|
||||
("leave-taking", "leave-taking"),
|
||||
];
|
||||
|
||||
let mut out: std::collections::BTreeMap<String, serde_json::Value> = Default::default();
|
||||
let mut corrections = Vec::new();
|
||||
let mut cur: Option<(u32, u32)> = None;
|
||||
let mut buf: Vec<String> = Vec::new();
|
||||
let mut prev: Option<(u32, u32)> = None;
|
||||
|
||||
let flush = |cur: &Option<(u32, u32)>, buf: &mut Vec<String>,
|
||||
out: &mut std::collections::BTreeMap<String, serde_json::Value>| {
|
||||
let Some((m, d)) = *cur else { buf.clear(); return };
|
||||
let mut items: Vec<String> = Vec::new();
|
||||
let mut cycle: Option<String> = None;
|
||||
for piece in buf.iter() {
|
||||
for part in split_sentences(piece) {
|
||||
let part = part.trim_matches([' ', '.', ';']).to_string();
|
||||
if part.len() <= 3 || dated(&part).is_some() || is_heading(&part) {
|
||||
continue;
|
||||
}
|
||||
let lower = part.to_ascii_lowercase();
|
||||
if let Some((_, word)) = festal.iter().find(|(p, _)| lower.starts_with(p)) {
|
||||
if cycle.is_none() {
|
||||
let rest = part
|
||||
.split_once(' ')
|
||||
.map(|(_, tail)| tail)
|
||||
.unwrap_or("")
|
||||
.trim_start_matches("Day ")
|
||||
.trim_start_matches("of ")
|
||||
.trim_matches([' ', '.', ','])
|
||||
.to_string();
|
||||
cycle = Some(if rest.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{word} of {rest}")
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
items.push(part);
|
||||
}
|
||||
}
|
||||
buf.clear();
|
||||
if items.is_empty() && cycle.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut entry = serde_json::Map::new();
|
||||
if !items.is_empty() {
|
||||
entry.insert("commemorations".into(), serde_json::json!(items));
|
||||
}
|
||||
if let Some(c) = cycle {
|
||||
entry.insert("cycle".into(), serde_json::json!(c));
|
||||
}
|
||||
out.insert(format!("{m:02}-{d:02}"), serde_json::Value::Object(entry));
|
||||
};
|
||||
|
||||
for line in &lines {
|
||||
if is_heading(line) {
|
||||
continue;
|
||||
}
|
||||
if let Some((month, mut day, rest)) = dated(line) {
|
||||
flush(&cur, &mut buf, &mut out);
|
||||
if let Some((pm, pd)) = prev {
|
||||
if pm == month && day <= pd {
|
||||
corrections.push(format!("{month:02}-{day:02} -> {month:02}-{:02}", pd + 1));
|
||||
day = pd + 1;
|
||||
}
|
||||
}
|
||||
prev = Some((month, day));
|
||||
cur = Some((month, day));
|
||||
if !rest.is_empty() {
|
||||
buf.push(rest);
|
||||
}
|
||||
} else if cur.is_some() {
|
||||
buf.push(line.clone());
|
||||
}
|
||||
}
|
||||
flush(&cur, &mut buf, &mut out);
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for m in 1..=12u32 {
|
||||
for d in 1..=crate::calendars::days_in_month(2024, m) {
|
||||
let key = format!("{m:02}-{d:02}");
|
||||
if !out.contains_key(&key) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is a complete calendar published by that church, so it stands in for
|
||||
// the common commemorations rather than piling on top of them.
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert(
|
||||
"_meta".into(),
|
||||
serde_json::json!({"mode": "replace", "source": source.url, "title": source.title}),
|
||||
);
|
||||
for (k, v) in &out {
|
||||
payload.insert(k.clone(), v.clone());
|
||||
}
|
||||
|
||||
let dir = crate::menologion::data_dir();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{name}.json"));
|
||||
let text = serde_json::to_string_pretty(&serde_json::Value::Object(payload))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
|
||||
Ok(Fetched { path, days: out.len(), corrections, missing })
|
||||
}
|
||||
|
||||
/// Split on sentence ends that begin a new capitalised clause, the way the MCI
|
||||
/// page separates commemorations inside one paragraph.
|
||||
fn split_sentences(text: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0usize;
|
||||
let mut i = 0usize;
|
||||
while i + 2 < chars.len() {
|
||||
if chars[i] == '.' && chars[i + 1] == ' ' {
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && chars[j] == ' ' {
|
||||
j += 1;
|
||||
}
|
||||
if j < chars.len() && (chars[j].is_ascii_uppercase() || chars[j] == '(') {
|
||||
parts.push(chars[start..=i].iter().collect());
|
||||
start = j;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
parts.push(chars[start..].iter().collect());
|
||||
parts
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! The fixed calendar, and the movable feasts that outrank it.
|
||||
//!
|
||||
//! The menologion ships once, compiled into the binary, and serves every
|
||||
//! jurisdiction because it is indexed by **ecclesiastical** date. A church on
|
||||
//! the old calendar and one on the new both keep the Nativity on 25 December;
|
||||
//! they disagree about which morning that is, and that disagreement lives in
|
||||
//! the calendar conversion, not here.
|
||||
//!
|
||||
//! Supplementary sets, a local church's own saints, are fetched rather than
|
||||
//! shipped, because they are usually published by a body that holds the
|
||||
//! copyright in the wording. They overlay the common set at runtime.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::calendars::Date;
|
||||
use crate::paschalion::Movables;
|
||||
|
||||
/// The degrees of solemnity the menologion marks, and the order they rank in.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Rank {
|
||||
Polyeleos,
|
||||
Vigil,
|
||||
Great,
|
||||
}
|
||||
|
||||
impl Rank {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Rank::Polyeleos => "polyeleos",
|
||||
Rank::Vigil => "vigil",
|
||||
Rank::Great => "great",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a feast of this rank names the day, as against merely being
|
||||
/// commemorated on it.
|
||||
pub fn names_the_day(self) -> bool {
|
||||
matches!(self, Rank::Great | Rank::Vigil)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct Entry {
|
||||
#[serde(default)]
|
||||
pub commemorations: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rank: Option<Rank>,
|
||||
/// Forefeast, afterfeast or leave-taking.
|
||||
#[serde(default)]
|
||||
pub cycle: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OverlayMode {
|
||||
/// The set's commemorations join the common ones. Right for a local set
|
||||
/// that adds a few saints of its own.
|
||||
#[default]
|
||||
Add,
|
||||
/// The set's commemorations stand in for them, because it is itself a
|
||||
/// complete calendar published by that church.
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
struct Meta {
|
||||
#[serde(default)]
|
||||
mode: OverlayMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Menologion {
|
||||
entries: BTreeMap<String, Entry>,
|
||||
mode: OverlayMode,
|
||||
}
|
||||
|
||||
/// The common Byzantine menologion, compiled in. No file to find, no path to
|
||||
/// get wrong, and the binary answers for every day of the year on its own.
|
||||
const COMMON: &str = include_str!("../data/menologion.json");
|
||||
|
||||
fn parse(text: &str) -> Result<Menologion, String> {
|
||||
let raw: BTreeMap<String, serde_json::Value> =
|
||||
serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut entries = BTreeMap::new();
|
||||
let mut mode = OverlayMode::Add;
|
||||
for (key, value) in raw {
|
||||
if key == "_meta" {
|
||||
let meta: Meta = serde_json::from_value(value).unwrap_or_default();
|
||||
mode = meta.mode;
|
||||
continue;
|
||||
}
|
||||
let entry: Entry = serde_json::from_value(value).map_err(|e| format!("{key}: {e}"))?;
|
||||
entries.insert(key, entry);
|
||||
}
|
||||
Ok(Menologion { entries, mode })
|
||||
}
|
||||
|
||||
impl Menologion {
|
||||
pub fn common() -> &'static Menologion {
|
||||
use std::sync::OnceLock;
|
||||
static ONCE: OnceLock<Menologion> = OnceLock::new();
|
||||
ONCE.get_or_init(|| parse(COMMON).expect("the shipped menologion parses"))
|
||||
}
|
||||
|
||||
pub fn get(&self, month: u32, day: u32) -> Option<&Entry> {
|
||||
self.entries.get(&format!("{month:02}-{day:02}"))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Lay another set over this one. Rank and cycle survive from the common
|
||||
/// set unless the overlay gives its own, since a published list of saints
|
||||
/// usually carries neither and the fasting rules need the rank.
|
||||
pub fn overlay(&self, other: &Menologion) -> Menologion {
|
||||
let mut merged = self.entries.clone();
|
||||
for (key, add) in &other.entries {
|
||||
let base = merged.entry(key.clone()).or_default();
|
||||
match other.mode {
|
||||
OverlayMode::Replace => {
|
||||
if !add.commemorations.is_empty() {
|
||||
base.commemorations = add.commemorations.clone();
|
||||
}
|
||||
}
|
||||
OverlayMode::Add => {
|
||||
for c in &add.commemorations {
|
||||
if !base.commemorations.contains(c) {
|
||||
base.commemorations.push(c.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if add.rank.is_some() {
|
||||
base.rank = add.rank;
|
||||
}
|
||||
if add.cycle.is_some() {
|
||||
base.cycle = add.cycle.clone();
|
||||
}
|
||||
}
|
||||
Menologion { entries: merged, mode: self.mode }
|
||||
}
|
||||
|
||||
pub fn load_file(path: &std::path::Path) -> Result<Menologion, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
parse(&text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where fetched supplementary sets live.
|
||||
pub fn data_dir() -> std::path::PathBuf {
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
let home = std::env::var_os("HOME").unwrap_or_default();
|
||||
std::path::Path::new(&home).join(".local/share")
|
||||
});
|
||||
base.join("typikon")
|
||||
}
|
||||
|
||||
/// The common menologion with a jurisdiction's supplementary sets overlaid.
|
||||
/// A missing set is not an error: it simply has not been fetched.
|
||||
pub fn for_jurisdiction(menaia: &[&str]) -> Menologion {
|
||||
let mut m = Menologion::common().clone();
|
||||
for name in menaia {
|
||||
let path = data_dir().join(format!("{name}.json"));
|
||||
if let Ok(extra) = Menologion::load_file(&path) {
|
||||
m = m.overlay(&extra);
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The movable feasts, named by their offset from Pascha. These outrank
|
||||
/// whatever the fixed calendar holds for the day.
|
||||
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
|
||||
let table: [(Date, &'static str, Option<Rank>); 24] = [
|
||||
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
|
||||
(mv.palm_sunday, "The Entry of our Lord into Jerusalem", Some(Rank::Great)),
|
||||
(mv.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
|
||||
(mv.pentecost, "Holy Pentecost \u{2014} the Descent of the Holy Spirit", Some(Rank::Great)),
|
||||
(mv.lazarus_saturday, "Lazarus Saturday", Some(Rank::Vigil)),
|
||||
(mv.great_thursday, "Great and Holy Thursday", Some(Rank::Vigil)),
|
||||
(mv.great_friday, "Great and Holy Friday", Some(Rank::Vigil)),
|
||||
(mv.holy_saturday, "Great and Holy Saturday", Some(Rank::Vigil)),
|
||||
(mv.thomas_sunday, "Thomas Sunday \u{2014} the Antipascha", Some(Rank::Polyeleos)),
|
||||
(mv.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
|
||||
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
|
||||
(mv.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)),
|
||||
(mv.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
|
||||
(mv.prodigal_son, "The Sunday of the Prodigal Son", None),
|
||||
(mv.meatfare, "Meatfare Sunday \u{2014} the Sunday of the Last Judgement", None),
|
||||
(mv.cheesefare, "Cheesefare Sunday \u{2014} the Expulsion of Adam, Forgiveness Sunday", None),
|
||||
(mv.orthodoxy_sunday, "The Sunday of Orthodoxy", Some(Rank::Polyeleos)),
|
||||
(mv.gregory_palamas, "The Sunday of St Gregory Palamas", None),
|
||||
(mv.cross_veneration, "The Sunday of the Veneration of the Cross", Some(Rank::Polyeleos)),
|
||||
(mv.john_climacus, "The Sunday of St John Climacus", None),
|
||||
(mv.mary_of_egypt, "The Sunday of St Mary of Egypt", None),
|
||||
(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),
|
||||
];
|
||||
table
|
||||
.iter()
|
||||
.find(|(d, _, _)| *d == civil)
|
||||
.map(|(_, title, rank)| (*title, *rank))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Pascha, and the movable cycle that hangs off it.
|
||||
//!
|
||||
//! Two reckonings are in use. The **Julian Paschalion** is what nearly every
|
||||
//! Orthodox Church keeps: the old computus, worked in the Julian calendar and
|
||||
//! then read off onto a civil date. The **Gregorian Paschalion** is the Western
|
||||
//! one, kept by the Finnish Orthodox Church and by most Byzantine Catholics.
|
||||
//!
|
||||
//! Neither is a variant of the other. They are separate tables that happen to
|
||||
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
|
||||
//! much as five weeks.
|
||||
|
||||
use crate::calendars::{Date, JulianDate};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Paschalion {
|
||||
Julian,
|
||||
Gregorian,
|
||||
}
|
||||
|
||||
/// Pascha by the Julian Paschalion, as a civil (Gregorian) date.
|
||||
///
|
||||
/// Meeus' Julian algorithm yields a date in the *Julian* calendar, which is
|
||||
/// then read onto the civil one. The type system enforces that step here: the
|
||||
/// algorithm builds a `JulianDate`, and only `to_civil` produces a `Date`.
|
||||
pub fn julian_pascha(year: i32) -> Date {
|
||||
let a = year % 4;
|
||||
let b = year % 7;
|
||||
let c = year % 19;
|
||||
let d = (19 * c + 15) % 30;
|
||||
let e = (2 * a + 4 * b - d + 34) % 7;
|
||||
let month = ((d + e + 114) / 31) as u32;
|
||||
let day = (((d + e + 114) % 31) + 1) as u32;
|
||||
JulianDate { year, month, day }.to_civil()
|
||||
}
|
||||
|
||||
/// Pascha by the Gregorian Paschalion, the Western computus.
|
||||
pub fn gregorian_pascha(year: i32) -> Date {
|
||||
let a = year % 19;
|
||||
let b = year / 100;
|
||||
let c = year % 100;
|
||||
let d = b / 4;
|
||||
let e = b % 4;
|
||||
let g = (8 * b + 13) / 25;
|
||||
let h = (19 * a + b - d - g + 15) % 30;
|
||||
let i = c / 4;
|
||||
let k = c % 4;
|
||||
let l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
let m = (a + 11 * h + 19 * l) / 433;
|
||||
let month = ((h + l - 7 * m + 90) / 25) as u32;
|
||||
let day = ((h + l - 7 * m + 33 * month as i32 + 19) % 32) as u32;
|
||||
Date::from_ymd(year, month, day).expect("computus yields a real date")
|
||||
}
|
||||
|
||||
pub fn pascha(year: i32, p: Paschalion) -> Date {
|
||||
match p {
|
||||
Paschalion::Julian => julian_pascha(year),
|
||||
Paschalion::Gregorian => gregorian_pascha(year),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every movable date the calendar needs, keyed off Pascha.
|
||||
///
|
||||
/// All of these are counted in real days from Pascha, so they are civil dates
|
||||
/// already and need no calendar conversion: the distance from Pascha to
|
||||
/// Pentecost is fifty days in every reckoning.
|
||||
///
|
||||
/// A struct rather than a map, so a typo in a key is a compile error instead
|
||||
/// of a silent `None` at the far end of the program.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Movables {
|
||||
pub pascha: Date,
|
||||
|
||||
// the Triodion, counted backwards
|
||||
pub publican_pharisee: Date,
|
||||
pub prodigal_son: Date,
|
||||
pub meatfare: Date,
|
||||
pub cheesefare: Date,
|
||||
pub cheesefare_week_start: Date,
|
||||
pub clean_monday: Date,
|
||||
pub orthodoxy_sunday: Date,
|
||||
pub gregory_palamas: Date,
|
||||
pub cross_veneration: Date,
|
||||
pub john_climacus: Date,
|
||||
pub mary_of_egypt: Date,
|
||||
/// The Forty Days end here, on the Friday before Lazarus Saturday.
|
||||
pub great_fast_end: Date,
|
||||
pub lazarus_saturday: Date,
|
||||
pub palm_sunday: Date,
|
||||
pub great_week_start: Date,
|
||||
pub great_thursday: Date,
|
||||
pub great_friday: Date,
|
||||
pub holy_saturday: Date,
|
||||
|
||||
// the Pentecostarion, counted forwards
|
||||
pub bright_week_end: Date,
|
||||
pub thomas_sunday: Date,
|
||||
pub myrrhbearers: Date,
|
||||
pub midfeast: Date,
|
||||
pub ascension: Date,
|
||||
pub fathers_first_council: Date,
|
||||
pub pentecost: Date,
|
||||
pub pentecost_week_end: Date,
|
||||
pub all_saints: Date,
|
||||
/// The Apostles' Fast begins the Monday after All Saints. Its end is a
|
||||
/// fixed ecclesiastical date, so the jurisdiction resolves that.
|
||||
pub apostles_start: Date,
|
||||
}
|
||||
|
||||
pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
|
||||
let d = pascha(year, p);
|
||||
Movables {
|
||||
pascha: d,
|
||||
publican_pharisee: d - 70,
|
||||
prodigal_son: d - 63,
|
||||
meatfare: d - 56,
|
||||
cheesefare_week_start: d - 55,
|
||||
cheesefare: d - 49,
|
||||
clean_monday: d - 48,
|
||||
orthodoxy_sunday: d - 42,
|
||||
gregory_palamas: d - 35,
|
||||
cross_veneration: d - 28,
|
||||
john_climacus: d - 21,
|
||||
mary_of_egypt: d - 14,
|
||||
great_fast_end: d - 9,
|
||||
lazarus_saturday: d - 8,
|
||||
palm_sunday: d - 7,
|
||||
great_week_start: d - 6,
|
||||
great_thursday: d - 3,
|
||||
great_friday: d - 2,
|
||||
holy_saturday: d - 1,
|
||||
bright_week_end: d + 6,
|
||||
thomas_sunday: d + 7,
|
||||
myrrhbearers: d + 14,
|
||||
midfeast: d + 24,
|
||||
ascension: d + 39,
|
||||
fathers_first_council: d + 42,
|
||||
pentecost: d + 49,
|
||||
pentecost_week_end: d + 55,
|
||||
all_saints: d + 56,
|
||||
apostles_start: d + 57,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Fasting disciplines. One module per tradition.
|
||||
|
||||
pub mod orthodox;
|
||||
pub mod ruthenian;
|
||||
|
||||
use crate::calendars::{Date, Weekday};
|
||||
use crate::fasting::{Fast, Level};
|
||||
use crate::jurisdictions::{Jurisdiction, RuleKind};
|
||||
use crate::menologion::Rank;
|
||||
use crate::paschalion::Movables;
|
||||
use crate::seasons::Season;
|
||||
|
||||
/// What a fasting rule is handed. Deliberately small.
|
||||
pub struct Context<'a> {
|
||||
pub civil: Date,
|
||||
/// The ecclesiastical (month, day).
|
||||
pub fixed: (u32, u32),
|
||||
/// The ecclesiastical year, for resolving a fixed date back to a civil one.
|
||||
pub fixed_year: i32,
|
||||
pub weekday: Weekday,
|
||||
pub mv: &'a Movables,
|
||||
pub season: Option<Season>,
|
||||
pub feast: String,
|
||||
pub rank: Option<Rank>,
|
||||
pub jurisdiction: &'a Jurisdiction,
|
||||
}
|
||||
|
||||
impl Context<'_> {
|
||||
/// The civil date of an ecclesiastical date in this day's church year.
|
||||
pub fn civil_of_fixed(&self, month: u32, day: u32) -> Date {
|
||||
self.jurisdiction.civil_of_fixed(self.fixed_year, month, day)
|
||||
}
|
||||
|
||||
pub fn is_weekend(&self) -> bool {
|
||||
self.weekday.is_weekend()
|
||||
}
|
||||
|
||||
/// Is this day within `days` of the given start, inclusive?
|
||||
pub fn within(&self, start: Date, days: i32) -> bool {
|
||||
start <= self.civil && self.civil <= start + days
|
||||
}
|
||||
}
|
||||
|
||||
/// How a tradition names the levels it uses, and the glyph it shows them with.
|
||||
pub struct Vocabulary {
|
||||
pub label: fn(Level) -> &'static str,
|
||||
pub emoji: fn(Level) -> &'static str,
|
||||
}
|
||||
|
||||
pub struct Rule {
|
||||
pub name: &'static str,
|
||||
pub title: &'static str,
|
||||
pub vocabulary: Vocabulary,
|
||||
pub fast_for: fn(&Context) -> Fast,
|
||||
/// Whether this tradition legislates the fast as precept at all.
|
||||
pub has_obligation: bool,
|
||||
}
|
||||
|
||||
pub fn get(kind: RuleKind) -> &'static Rule {
|
||||
match kind {
|
||||
RuleKind::Orthodox => &orthodox::RULE,
|
||||
RuleKind::Ruthenian => &ruthenian::RULE,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! The Typikon's fast, as the Orthodox Church keeps it.
|
||||
//!
|
||||
//! This is the discipline printed in the Typikon and reproduced, with small
|
||||
//! variations, by every Orthodox jurisdiction's published calendar. It binds no
|
||||
//! one in the canonical sense: the Orthodox Church legislates fasting as
|
||||
//! tradition rather than as precept, so every day here is reported as
|
||||
//! customary. That is not a softening. It is the difference between a rule of
|
||||
//! 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.
|
||||
//!
|
||||
//! Where usages differ, this follows the common published rule and says so in
|
||||
//! the reason. The known divergences:
|
||||
//!
|
||||
//! - **Great Thursday** is given wine and oil here. Some typika keep xerophagy.
|
||||
//! - **The Exaltation of the Cross** and the **Beheading of the Forerunner**
|
||||
//! are given wine and oil. Several jurisdictions print them as strict.
|
||||
//! - **Lazarus Saturday** is given wine and oil; caviar is the traditional
|
||||
//! addition, which this vocabulary has no level for.
|
||||
//! - **Great Week** is kept at xerophagy on its weekdays whatever saint falls
|
||||
//! in it, because the days of the Passion outrank the menologion.
|
||||
//! - **Monastic practice**, and the Holy Mountain in particular, is stricter
|
||||
//! than any of this throughout.
|
||||
//!
|
||||
//! A calendar cannot settle those. Your bishop and your spiritual father can.
|
||||
|
||||
use super::{Context, Rule, Vocabulary};
|
||||
use crate::calendars::Weekday;
|
||||
use crate::fasting::{Fast, Level};
|
||||
use crate::menologion::Rank;
|
||||
use crate::seasons::Season;
|
||||
|
||||
fn label(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Strict => "no food until evening",
|
||||
Level::Xerophagy => "xerophagy \u{2014} no oil or wine",
|
||||
Level::WineOil => "wine and oil permitted",
|
||||
Level::Fish => "fish, wine and oil",
|
||||
Level::Dairy => "dairy and eggs; no meat",
|
||||
Level::None => "no fast",
|
||||
}
|
||||
}
|
||||
|
||||
fn emoji(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Strict => "\u{26d4}",
|
||||
Level::Xerophagy => "\u{1f96c}",
|
||||
Level::WineOil => "\u{1fad2}",
|
||||
Level::Fish => "\u{1f41f}",
|
||||
Level::Dairy => "\u{1f9c0}",
|
||||
Level::None => "\u{1f356}",
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a feast lifts a fast day.
|
||||
fn relaxed_by(rank: Option<Rank>) -> Option<Level> {
|
||||
match rank {
|
||||
Some(Rank::Great) => Some(Level::Fish),
|
||||
Some(Rank::Vigil) | Some(Rank::Polyeleos) => Some(Level::WineOil),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Relax `level` for the day's saint, never past `cap`, never tightening.
|
||||
fn for_feast(level: Level, ctx: &Context, cap: Level, reason: &str) -> Fast {
|
||||
let Some(target) = relaxed_by(ctx.rank) else {
|
||||
return Fast::customary(level, reason);
|
||||
};
|
||||
let lifted = level.relaxed_to(target, cap);
|
||||
if lifted == level {
|
||||
return Fast::customary(level, reason);
|
||||
}
|
||||
Fast::customary(lifted, &format!("{} \u{2014} the fast is relaxed", ctx.feast))
|
||||
}
|
||||
|
||||
pub fn fast_for(ctx: &Context) -> Fast {
|
||||
let md = ctx.fixed;
|
||||
let mv = ctx.mv;
|
||||
let weekend = ctx.is_weekend();
|
||||
|
||||
// The fast-free stretches, settled before anything else can override them.
|
||||
// A Friday inside Bright Week is not a fast day, and the ordinary Friday
|
||||
// rule further down would say otherwise.
|
||||
match ctx.season {
|
||||
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
|
||||
Some(Season::PentecostWeek) => {
|
||||
return Fast::free("the week after Pentecost \u{2014} no fasting")
|
||||
}
|
||||
Some(Season::Christmastide) => {
|
||||
return Fast::free("the Nativity to the eve of Theophany")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if ctx.within(mv.publican_pharisee, 6) {
|
||||
// Fast-free precisely because the Pharisee boasted of fasting twice a week.
|
||||
return Fast::free("fast-free week (the Publican and the Pharisee)");
|
||||
}
|
||||
|
||||
// The Great Fast and Great Week.
|
||||
if matches!(ctx.season, Some(Season::GreatFast) | Some(Season::GreatWeek)) {
|
||||
let season = ctx.season.unwrap();
|
||||
// The Annunciation is tested before the strict days, not after. It can
|
||||
// land on Great Friday or Great Saturday, and when it does the feast
|
||||
// still lifts the total fast to wine and oil: it does not vanish.
|
||||
if md == (3, 25) {
|
||||
if ctx.civil == mv.great_friday || ctx.civil == mv.holy_saturday {
|
||||
return Fast::customary(
|
||||
Level::WineOil,
|
||||
"the Annunciation, on a day of the total fast",
|
||||
);
|
||||
}
|
||||
return Fast::customary(Level::Fish, "the Annunciation");
|
||||
}
|
||||
if ctx.civil == mv.palm_sunday {
|
||||
return Fast::customary(Level::Fish, "Palm Sunday");
|
||||
}
|
||||
if ctx.civil == mv.lazarus_saturday {
|
||||
return Fast::customary(Level::WineOil, "Lazarus Saturday");
|
||||
}
|
||||
if ctx.civil == mv.clean_monday {
|
||||
return Fast::customary(Level::Strict, "the first day of the Great Fast");
|
||||
}
|
||||
if ctx.civil == mv.great_friday {
|
||||
return Fast::customary(Level::Strict, "Great and Holy Friday");
|
||||
}
|
||||
if ctx.civil == mv.holy_saturday {
|
||||
// The one Saturday of the year kept without oil.
|
||||
return Fast::customary(Level::Xerophagy, "Great and Holy Saturday");
|
||||
}
|
||||
if ctx.civil == mv.great_thursday {
|
||||
return Fast::customary(Level::WineOil, "Great and Holy Thursday");
|
||||
}
|
||||
if season == Season::GreatWeek {
|
||||
return Fast::customary(
|
||||
Level::Xerophagy,
|
||||
&format!("a weekday of the {}", season.name()),
|
||||
);
|
||||
}
|
||||
if weekend {
|
||||
return Fast::customary(
|
||||
Level::WineOil,
|
||||
&format!("Saturday and Sunday of the {}", season.name()),
|
||||
);
|
||||
}
|
||||
return for_feast(
|
||||
Level::Xerophagy,
|
||||
ctx,
|
||||
Level::WineOil,
|
||||
&format!("a weekday of the {}", season.name()),
|
||||
);
|
||||
}
|
||||
|
||||
// Cheesefare: meat is set aside for good, everything else stays,
|
||||
// Wednesday and Friday included.
|
||||
if ctx.season == Some(Season::CheesefareWeek) {
|
||||
return Fast::customary(Level::Dairy, "Cheesefare Week");
|
||||
}
|
||||
if ctx.civil == mv.meatfare {
|
||||
return Fast::free("Meatfare Sunday \u{2014} the last day of meat");
|
||||
}
|
||||
|
||||
// The Nativity Fast, which tightens for its last five days.
|
||||
if ctx.season == Some(Season::NativityFast) {
|
||||
if md == (12, 24) {
|
||||
// The eve is relaxed to wine and oil on a Saturday or Sunday,
|
||||
// because the Liturgy of St Basil is served with Vespers.
|
||||
if weekend {
|
||||
return Fast::customary(Level::WineOil, "the eve of the Nativity, on a weekend");
|
||||
}
|
||||
return Fast::customary(Level::Xerophagy, "the eve of the Nativity");
|
||||
}
|
||||
if md == (11, 21) {
|
||||
return Fast::customary(Level::Fish, "the Entrance of the Theotokos");
|
||||
}
|
||||
if md >= (12, 20) {
|
||||
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) || weekend {
|
||||
return Fast::customary(Level::WineOil, "the forefeast of the Nativity");
|
||||
}
|
||||
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "the forefeast of the Nativity");
|
||||
}
|
||||
if weekend {
|
||||
return Fast::customary(Level::Fish, "Saturday and Sunday of the Nativity Fast");
|
||||
}
|
||||
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
|
||||
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Nativity Fast");
|
||||
}
|
||||
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Nativity Fast");
|
||||
}
|
||||
|
||||
// The Apostles' Fast, kept the same way.
|
||||
if ctx.season == Some(Season::Apostles) {
|
||||
if weekend {
|
||||
return Fast::customary(Level::Fish, "Saturday and Sunday of the Apostles' Fast");
|
||||
}
|
||||
if matches!(ctx.weekday, Weekday::Tuesday | Weekday::Thursday) {
|
||||
return for_feast(Level::WineOil, ctx, Level::Fish, "Tuesday and Thursday of the Apostles' Fast");
|
||||
}
|
||||
return for_feast(Level::Xerophagy, ctx, Level::Fish, "Monday, Wednesday and Friday of the Apostles' Fast");
|
||||
}
|
||||
|
||||
// The Dormition Fast, kept like the Great Fast.
|
||||
if ctx.season == Some(Season::Dormition) {
|
||||
if md == (8, 6) {
|
||||
return Fast::customary(Level::Fish, "the Transfiguration");
|
||||
}
|
||||
if weekend {
|
||||
return Fast::customary(Level::WineOil, "Saturday and Sunday of the Dormition Fast");
|
||||
}
|
||||
return for_feast(Level::Xerophagy, ctx, Level::WineOil, "a weekday of the Dormition Fast");
|
||||
}
|
||||
|
||||
// The three fixed fast days outside any season.
|
||||
if md == (1, 5) {
|
||||
if weekend {
|
||||
return Fast::customary(Level::WineOil, "the eve of Theophany, on a weekend");
|
||||
}
|
||||
return Fast::customary(Level::Xerophagy, "the eve of Theophany");
|
||||
}
|
||||
if md == (9, 14) {
|
||||
return Fast::customary(Level::WineOil, "the Exaltation of the Holy Cross");
|
||||
}
|
||||
if md == (8, 29) {
|
||||
return Fast::customary(Level::WineOil, "the Beheading of the Forerunner");
|
||||
}
|
||||
|
||||
// Ordinary Wednesdays and Fridays, which a feast can lift.
|
||||
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
|
||||
if let Some(target) = relaxed_by(ctx.rank) {
|
||||
return Fast::customary(target, &format!("{} \u{2014} the fast is relaxed", ctx.feast));
|
||||
}
|
||||
return Fast::customary(Level::Xerophagy, "Wednesday and Friday");
|
||||
}
|
||||
|
||||
Fast::new(Level::None, "", None)
|
||||
}
|
||||
|
||||
pub static RULE: Rule = Rule {
|
||||
name: "orthodox",
|
||||
title: "the Typikon",
|
||||
vocabulary: Vocabulary { label, emoji },
|
||||
fast_for,
|
||||
// The Orthodox Church does not legislate the fast as precept, so nothing
|
||||
// here is reported as binding. See the module docs.
|
||||
has_obligation: false,
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Byzantine Catholic particular law, alongside the tradition it sits on.
|
||||
//!
|
||||
//! The Eastern Catholic churches differ from the Orthodox here in a way worth
|
||||
//! being exact about: they have *law* on fasting, and it requires very much
|
||||
//! less than the Typikon describes. Under current Ruthenian particular law the
|
||||
//! Church binds only:
|
||||
//!
|
||||
//! - strict abstinence, no meat, fish, eggs or dairy, on the first day of the
|
||||
//! Great Fast and on Great and Holy Friday;
|
||||
//! - abstinence from meat on the Wednesdays and Fridays of the Great Fast.
|
||||
//!
|
||||
//! Everything else, the Apostles', Dormition and Nativity Fasts, the fuller
|
||||
//! keeping of the Great Fast, ordinary Fridays through the year, is encouraged
|
||||
//! and traditional and does **not** bind. So this module reports two things at
|
||||
//! once: the level at which the day is kept by tradition, and the minimum that
|
||||
//! actually obliges. A calendar that says "obligatory" where nothing obliges is
|
||||
//! worse than no calendar; one that reports only the binding minimum makes the
|
||||
//! obligatory days look laxer than the voluntary ones beside them.
|
||||
//!
|
||||
//! The three degrees this law knows map exactly onto the shared vocabulary: its
|
||||
//! strict abstinence is `WineOil`, since it does not regulate oil or wine, and
|
||||
//! its simple abstinence is `Dairy`, meat alone being set aside.
|
||||
//!
|
||||
//! **A known reading.** "The Wednesdays and Fridays of the Great Fast" is taken
|
||||
//! here to mean the Forty Days, not Great and Holy Week, which the calendar
|
||||
//! counts as its own season. Great and Holy Friday binds in its own right, so
|
||||
//! only Great and Holy Wednesday turns on the reading. If your eparchy reads it
|
||||
//! the other way, that Wednesday binds too.
|
||||
//!
|
||||
//! Source: the Byzantine Catholic Metropolitan Church of Pittsburgh. Other
|
||||
//! Eastern Catholic churches legislate their own; where this library maps them
|
||||
//! onto this ruleset it is an approximation, and the jurisdiction entry says so.
|
||||
|
||||
use super::{Context, Rule, Vocabulary};
|
||||
use crate::calendars::{Date, Weekday};
|
||||
use crate::fasting::{Fast, Level};
|
||||
use crate::menologion::Rank;
|
||||
use crate::seasons::Season;
|
||||
|
||||
fn label(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::WineOil => "no meat, fish, eggs or dairy",
|
||||
Level::Dairy => "no meat",
|
||||
Level::None => "no fast",
|
||||
// Levels this law makes no distinction at, kept legible if ever emitted.
|
||||
Level::Strict => "no food until evening",
|
||||
Level::Xerophagy => "no meat, fish, eggs or dairy",
|
||||
Level::Fish => "no meat",
|
||||
}
|
||||
}
|
||||
|
||||
fn emoji(level: Level) -> &'static str {
|
||||
match level {
|
||||
Level::Strict | Level::Xerophagy | Level::WineOil => "\u{1f96c}",
|
||||
Level::Fish | Level::Dairy => "\u{1f41f}",
|
||||
Level::None => "\u{1f356}",
|
||||
}
|
||||
}
|
||||
|
||||
/// Vigil fasts are kept on the preceding Friday when they land on a weekend.
|
||||
///
|
||||
/// "If the vigils of Christmas or Theophany fall on Saturday or Sunday, the
|
||||
/// fasting and abstinence is done on the previous Friday."
|
||||
fn off_weekend(day: Date) -> Date {
|
||||
match day.weekday() {
|
||||
Weekday::Saturday => day - 1,
|
||||
Weekday::Sunday => day - 2,
|
||||
_ => day,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fast_for(ctx: &Context) -> Fast {
|
||||
let mv = ctx.mv;
|
||||
|
||||
// The two days the Church binds strictly.
|
||||
if ctx.civil == mv.clean_monday {
|
||||
return Fast::binding(Level::WineOil, "the first day of the Great Fast", Level::WineOil);
|
||||
}
|
||||
if ctx.civil == mv.great_friday {
|
||||
return Fast::binding(Level::WineOil, "Great and Holy Friday", Level::WineOil);
|
||||
}
|
||||
|
||||
// And the days it binds to abstinence from meat. Kept strictly by
|
||||
// tradition; binding only as far as meat.
|
||||
if ctx.season == Some(Season::GreatFast)
|
||||
&& matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday)
|
||||
{
|
||||
return Fast::binding(
|
||||
Level::WineOil,
|
||||
"Wednesday and Friday of the Great Fast",
|
||||
Level::Dairy,
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed vigils and the Exaltation, moved off a weekend where the rule says.
|
||||
for (when, label) in [
|
||||
(ctx.civil_of_fixed(9, 14), "the Exaltation of the Holy Cross"),
|
||||
(off_weekend(ctx.civil_of_fixed(12, 24)), "the vigil of the Nativity"),
|
||||
(off_weekend(ctx.civil_of_fixed(1, 5)), "the vigil of Theophany"),
|
||||
] {
|
||||
if ctx.civil == when {
|
||||
return Fast::customary(Level::WineOil, label);
|
||||
}
|
||||
}
|
||||
|
||||
// The fast-free stretches, which have to be settled before the ordinary
|
||||
// Wednesday and Friday rule below or they get overridden by it. Christmas
|
||||
// Day 2026 is a Friday, and telling you to abstain from meat on it would be
|
||||
// plainly wrong.
|
||||
match ctx.season {
|
||||
Some(Season::BrightWeek) => return Fast::free("Bright Week \u{2014} no fasting"),
|
||||
Some(Season::PentecostWeek) => {
|
||||
return Fast::free("the week after Pentecost \u{2014} no fasting")
|
||||
}
|
||||
Some(Season::Christmastide) => return Fast::free("Christmastide \u{2014} no fasting"),
|
||||
_ => {}
|
||||
}
|
||||
if ctx.within(mv.publican_pharisee, 6) {
|
||||
return Fast::free("fast-free week (the Publican and the Pharisee)");
|
||||
}
|
||||
|
||||
// The rest of the Great Fast and Great Week.
|
||||
if let Some(season @ (Season::GreatFast | Season::GreatWeek)) = ctx.season {
|
||||
if ctx.is_weekend() {
|
||||
return Fast::customary(Level::Dairy, season.name());
|
||||
}
|
||||
return Fast::customary(Level::WineOil, season.name());
|
||||
}
|
||||
|
||||
if ctx.season == Some(Season::CheesefareWeek) {
|
||||
return Fast::customary(Level::Dairy, "Cheesefare Week");
|
||||
}
|
||||
|
||||
// The three voluntary seasons.
|
||||
if let Some(season @ (Season::Apostles | Season::Dormition | Season::NativityFast)) =
|
||||
ctx.season
|
||||
{
|
||||
return Fast::customary(Level::Dairy, season.name());
|
||||
}
|
||||
|
||||
// A great feast releases the ordinary weekday abstinence.
|
||||
if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) {
|
||||
if ctx.rank == Some(Rank::Great) {
|
||||
return Fast::free(&format!("{} \u{2014} the fast is released", ctx.feast));
|
||||
}
|
||||
return Fast::customary(
|
||||
Level::Dairy,
|
||||
if ctx.weekday == Weekday::Friday { "Friday" } else { "Wednesday" },
|
||||
);
|
||||
}
|
||||
|
||||
Fast::new(Level::None, "", None)
|
||||
}
|
||||
|
||||
pub static RULE: Rule = Rule {
|
||||
name: "ruthenian",
|
||||
title: "Ruthenian particular law",
|
||||
vocabulary: Vocabulary { label, emoji },
|
||||
fast_for,
|
||||
has_obligation: true,
|
||||
};
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
//! Which season of the year a day falls in.
|
||||
//!
|
||||
//! The movable seasons are counted from Pascha and so are compared as civil
|
||||
//! dates. The fixed ones, the Nativity and Dormition Fasts and the fast-free
|
||||
//! days after the Nativity, are compared in **ecclesiastical** space, against
|
||||
//! the date the Church calls the day. That is the whole trick that lets one set
|
||||
//! of rules serve the old and new calendars at once: the Nativity Fast begins
|
||||
//! on 15 November in both, and only the mornings differ.
|
||||
//!
|
||||
//! The Great Fast here runs from Clean Monday through Palm Sunday. The Forty
|
||||
//! Days proper end on the Friday before Lazarus Saturday, which is what the
|
||||
//! fasting rules count from, but a season is a label for a reader, and a Palm
|
||||
//! Sunday belonging to no season at all is worse than one belonging loosely to
|
||||
//! the Fast.
|
||||
|
||||
use crate::calendars::{fixed_between, Date};
|
||||
use crate::paschalion::Movables;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Season {
|
||||
GreatFast,
|
||||
GreatWeek,
|
||||
BrightWeek,
|
||||
PreLenten,
|
||||
CheesefareWeek,
|
||||
Apostles,
|
||||
Dormition,
|
||||
NativityFast,
|
||||
PentecostWeek,
|
||||
Christmastide,
|
||||
}
|
||||
|
||||
impl Season {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Season::GreatFast => "Great Fast",
|
||||
Season::GreatWeek => "Great and Holy Week",
|
||||
Season::BrightWeek => "Bright Week",
|
||||
Season::PreLenten => "Triodion",
|
||||
Season::CheesefareWeek => "Cheesefare Week",
|
||||
Season::Apostles => "Apostles' Fast",
|
||||
Season::Dormition => "Dormition Fast",
|
||||
Season::NativityFast => "Nativity Fast",
|
||||
Season::PentecostWeek => "Week after Pentecost",
|
||||
Season::Christmastide => "Christmastide",
|
||||
}
|
||||
}
|
||||
|
||||
/// The seasons in which a fast is kept, as distinct from the ones that are
|
||||
/// merely named.
|
||||
pub fn is_fasting_season(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Season::GreatFast
|
||||
| Season::GreatWeek
|
||||
| Season::CheesefareWeek
|
||||
| Season::Apostles
|
||||
| Season::Dormition
|
||||
| Season::NativityFast
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The season a day belongs to, or `None` for ordinary time.
|
||||
///
|
||||
/// `fixed_md` is the ecclesiastical (month, day). `apostles_end` is the civil
|
||||
/// date of 28 June reckoned in this jurisdiction's menologion, the eve of Ss
|
||||
/// Peter and Paul, where the Apostles' Fast stops.
|
||||
pub fn season_for(
|
||||
civil: Date,
|
||||
fixed_md: (u32, u32),
|
||||
mv: &Movables,
|
||||
apostles_end: Date,
|
||||
) -> Option<Season> {
|
||||
// Movable first: Great Week outranks the Great Fast it follows, and Bright
|
||||
// Week outranks everything.
|
||||
if mv.pascha <= civil && civil <= mv.bright_week_end {
|
||||
return Some(Season::BrightWeek);
|
||||
}
|
||||
if mv.great_week_start <= civil && civil <= mv.holy_saturday {
|
||||
return Some(Season::GreatWeek);
|
||||
}
|
||||
if mv.clean_monday <= civil && civil <= mv.palm_sunday {
|
||||
return Some(Season::GreatFast);
|
||||
}
|
||||
if mv.cheesefare_week_start <= civil && civil <= mv.cheesefare {
|
||||
return Some(Season::CheesefareWeek);
|
||||
}
|
||||
if mv.publican_pharisee <= civil && civil < mv.cheesefare_week_start {
|
||||
return Some(Season::PreLenten);
|
||||
}
|
||||
if mv.pentecost <= civil && civil <= mv.pentecost_week_end {
|
||||
return Some(Season::PentecostWeek);
|
||||
}
|
||||
// The Apostles' Fast can be empty in a year with a late Pascha, which this
|
||||
// comparison handles without a special case.
|
||||
if mv.apostles_start <= civil && civil <= apostles_end {
|
||||
return Some(Season::Apostles);
|
||||
}
|
||||
|
||||
// Fixed seasons, in ecclesiastical space.
|
||||
if fixed_between(fixed_md, (8, 1), (8, 14)) {
|
||||
return Some(Season::Dormition);
|
||||
}
|
||||
if fixed_between(fixed_md, (11, 15), (12, 24)) {
|
||||
return Some(Season::NativityFast);
|
||||
}
|
||||
if fixed_between(fixed_md, (12, 25), (1, 4)) {
|
||||
return Some(Season::Christmastide);
|
||||
}
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user