From 56cc1a3d921dc026e7d6afb006df57490dc0d790 Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 18 Sep 2026 11:28:40 -0400 Subject: [PATCH] Greek and Slavic usage, and the marks the books print Both keep the same Typikon and read it a little differently, and until now this library silently picked one reading and called it the Typikon. Two differences are modelled, each one visible in the output and named in the day's reason: - The Protection of the Theotokos is a great feast in Slavic usage. Greek practice ranks it lower, and the Church of Greece keeps it on 28 October besides. This has teeth: rank is what lifts a Wednesday or Friday fast. - Greek practice admits fish on any day of the Nativity Fast but Wednesday and Friday until 17 December; Slavic practice keeps fish to Saturdays and Sundays throughout. Every jurisdiction carries the usage its books follow, and --tradition overrides it for a parish that does not match its jurisdiction's default. The difference in the calendar lives in a data overlay compiled in beside the common menologion, which is where a local church's own commemorations would go; the difference in the fast lives in the rule, because that is where it is. Each rank also now carries the mark the books print beside it, circled cross, cross and Jerusalem cross for great, vigil and polyeleos. Unicode has no glyph for the polyeleos sign, a cross over a half-circle, so the Jerusalem cross stands in as digital calendars generally settle on. --- README.md | 20 +++++++++ data/tradition-greek.json | 7 +++ data/tradition-slavic.json | 10 +++++ examples/dump.rs | 2 +- src/day.rs | 12 ++++- src/export/csv.rs | 4 +- src/jurisdictions.rs | 92 +++++++++++++++++++++++++++----------- src/main.rs | 26 +++++++++-- src/menologion.rs | 39 ++++++++++++++-- src/rules/mod.rs | 1 + src/rules/orthodox.rs | 13 ++++++ tests/typikon.rs | 53 ++++++++++++++++++++++ 12 files changed, 241 insertions(+), 38 deletions(-) create mode 100644 data/tradition-greek.json create mode 100644 data/tradition-slavic.json diff --git a/README.md b/README.md index 40330c8..dcffc5c 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,22 @@ 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, not a compromise but exactly what the 1923 congress decided. +### Greek or Slavic usage + +Both keep the same Typikon and read it a little differently. Two of those +differences are modelled, and each is visible in the output: + +| | Slavic | Greek | +|---|---|---| +| The Protection, 1 October | great feast | lower rank; Greece keeps it on 28 October besides | +| Fish in the Nativity Fast | Saturdays and Sundays | any day but Wednesday and Friday, until 17 December | + +Every jurisdiction carries the usage its books follow. `--tradition slavic` or +`--tradition greek` overrides it, which is what you want for a parish that does +not match its jurisdiction's default. + +### Which calendar? + Pick a reckoning without naming a church: ```console @@ -137,6 +153,10 @@ $ typikon --format ics --days 365 > orthodox.ics $ typikon --year 2027 # a year's key dates, to check against yours ``` +Every day of vigil rank or above carries the mark its books print beside it: +⊕ for a great feast, ✚ for a vigil, ☩ for a polyeleos. It is the `sign` +field in the JSON and a column in the delimited formats. + `--no-emoji` drops the glyphs from every format, for a terminal or a bar without them. `--list-fields` prints the columns `csv` and `tsv` can take, and `--fields` picks a subset in the order you name them. `--json` and `--ics` diff --git a/data/tradition-greek.json b/data/tradition-greek.json new file mode 100644 index 0000000..cfcf1bd --- /dev/null +++ b/data/tradition-greek.json @@ -0,0 +1,7 @@ +{ + "_meta": { + "mode": "add", + "title": "Greek usage", + "note": "Where Greek books differ from Slavic ones. Nothing overrides the common menologion yet; the fasting difference lives in the rule, not the calendar." + } +} diff --git a/data/tradition-slavic.json b/data/tradition-slavic.json new file mode 100644 index 0000000..445d5ab --- /dev/null +++ b/data/tradition-slavic.json @@ -0,0 +1,10 @@ +{ + "_meta": { + "mode": "add", + "title": "Slavic usage", + "note": "Where Slavic books differ from Greek ones. Ranks stated here override the common menologion; local commemorations would be added the same way." + }, + "10-01": { + "rank": "great" + } +} diff --git a/examples/dump.rs b/examples/dump.rs index 94c1e43..275ec1f 100644 --- a/examples/dump.rs +++ b/examples/dump.rs @@ -10,7 +10,7 @@ fn main() { let start = Date::parse_iso(&args[1]).expect("start date"); let days: i32 = args[2].parse().expect("days"); for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) { - let men = menologion::for_jurisdiction(jur.menaia); + let men = menologion::for_jurisdiction(jur.tradition, jur.menaia); for n in 0..days { let d = describe_with(start + n, jur, &men); println!("{}", serde_json::to_string(&d.to_json()).unwrap()); diff --git a/src/day.rs b/src/day.rs index 3a16e15..f655ed9 100644 --- a/src/day.rs +++ b/src/day.rs @@ -27,6 +27,7 @@ pub struct Day { pub cycle: Option, pub feast: Option, pub rank: Option, + pub tradition: &'static str, pub commemorations: Vec, pub fast: Level, pub fast_label: &'static str, @@ -69,6 +70,9 @@ pub struct DayJson<'a> { pub cycle: Option<&'a str>, pub feast: Option<&'a str>, pub rank: Option<&'a str>, + /// The mark the books print beside a day of this rank. + pub sign: Option<&'a str>, + pub tradition: &'a str, pub title: &'a str, pub commemorations: &'a [String], pub fast: &'a str, @@ -94,6 +98,8 @@ impl Day { cycle: self.cycle.as_deref(), feast: self.feast.as_deref(), rank: self.rank.map(|r| r.key()), + sign: self.rank.map(|r| r.sign()), + tradition: self.tradition, title: self.title(), commemorations: &self.commemorations, fast: self.fast.key(), @@ -114,7 +120,7 @@ impl Day { /// Reckon one civil date in one jurisdiction. pub fn describe(date: Date, jur: &'static Jurisdiction) -> Day { - let men = menologion::for_jurisdiction(jur.menaia); + let men = menologion::for_jurisdiction(jur.tradition, jur.menaia); describe_with(date, jur, &men) } @@ -176,6 +182,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) - .unwrap_or_default(), rank, jurisdiction: jur, + tradition: jur.tradition, }; let Fast { level, reason, obligation, binds } = (rule.fast_for)(&ctx); @@ -190,6 +197,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) - cycle: entry.cycle, feast, rank, + tradition: jur.tradition.key(), commemorations, fast: level, fast_label: (rule.vocabulary.label)(level), @@ -205,7 +213,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) - /// `days` consecutive days from `start`. pub fn week(start: Date, days: i32, jur: &'static Jurisdiction) -> Vec { - let men = menologion::for_jurisdiction(jur.menaia); + let men = menologion::for_jurisdiction(jur.tradition, jur.menaia); (0..days.max(1)) .map(|n| describe_with(start + n, jur, &men)) .collect() diff --git a/src/export/csv.rs b/src/export/csv.rs index b1dd251..8b45f83 100644 --- a/src/export/csv.rs +++ b/src/export/csv.rs @@ -8,7 +8,7 @@ use crate::day::Day; pub const COLUMNS: &[&str] = &[ "date", "weekday", "jurisdiction", "calendar", "church_date", "season", - "cycle", "feast", "rank", "title", "fast", "fast_label", "emoji", + "cycle", "feast", "rank", "sign", "tradition", "title", "fast", "fast_label", "emoji", "reason", "obligation", "binds", "pascha", "days_to_pascha", "julian_offset", "commemorations", ]; @@ -35,6 +35,8 @@ fn cell(day: &Day, column: &str) -> String { "cycle" => day.cycle.clone().unwrap_or_default(), "feast" => day.feast.clone().unwrap_or_default(), "rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(), + "sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(), + "tradition" => day.tradition.to_string(), "title" => day.title().to_string(), "fast" => day.fast.key().to_string(), "fast_label" => day.fast_label.to_string(), diff --git a/src/jurisdictions.rs b/src/jurisdictions.rs index 6bcbdae..ff89a3c 100644 --- a/src/jurisdictions.rs +++ b/src/jurisdictions.rs @@ -30,6 +30,44 @@ pub enum MenologionKind { RevisedJulian, } +/// Greek or Slavic usage. Both keep the same Typikon; they read it a little +/// differently, and the differences are small, real and printed in every +/// calendar that takes a side. +/// +/// Two are modelled here: +/// +/// - **The Protection of the Theotokos**, 1 October, is a great feast in +/// Slavic usage. Greek practice ranks it lower, and the Church of Greece +/// keeps it on 28 October besides. +/// - **Fish in the Nativity Fast.** Greek practice permits fish on any day but +/// Wednesday and Friday until 17 December; Slavic practice keeps fish to +/// Saturdays and Sundays throughout. +/// +/// The split is coarse: a church is assigned the usage its books follow, and +/// no jurisdiction is wholly one thing. Override it with `--tradition`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Tradition { + Slavic, + Greek, +} + +impl Tradition { + pub fn key(self) -> &'static str { + match self { + Tradition::Slavic => "slavic", + Tradition::Greek => "greek", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "slavic" | "russian" | "slav" => Some(Tradition::Slavic), + "greek" | "byzantine" => Some(Tradition::Greek), + _ => None, + } + } +} + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum RuleKind { Orthodox, @@ -61,6 +99,7 @@ pub struct Jurisdiction { pub paschalion: Paschalion, pub menologion: MenologionKind, pub rule: RuleKind, + pub tradition: Tradition, /// Supplementary commemoration sets to overlay, fetched not shipped. pub menaia: &'static [&'static str], pub note: &'static str, @@ -101,59 +140,60 @@ impl Jurisdiction { use MenologionKind::{Julian as JulMen, RevisedJulian as RevJul}; use Paschalion::{Gregorian as GregP, Julian as JulP}; use RuleKind::{Orthodox as Orth, Ruthenian as Ruth}; +use Tradition::{Greek as Gk, Slavic as Sl}; 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, + Jurisdiction { key: "old", name: "Old Calendar", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, 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, + Jurisdiction { key: "new", name: "New Calendar", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, 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, + Jurisdiction { key: "western", name: "Gregorian", paschalion: GregP, menologion: RevJul, rule: Orth, tradition: Gk, 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, + Jurisdiction { key: "moscow", name: "Russian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "rocor", name: "Russian Orthodox Church Outside Russia", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "serbian", name: "Serbian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "georgian", name: "Georgian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "jerusalem", name: "Patriarchate of Jerusalem", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "athos", name: "The Holy Mountain", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Gk, 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, + Jurisdiction { key: "polish", name: "Polish Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, 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: "" }, + Jurisdiction { key: "macedonian", name: "Macedonian Orthodox Church", paschalion: JulP, menologion: JulMen, rule: Orth, tradition: Sl, 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, + Jurisdiction { key: "constantinople", name: "Ecumenical Patriarchate", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "goarch", name: "Greek Orthodox Archdiocese of America", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "greece", name: "Church of Greece", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "antiochian", name: "Antiochian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "oca", name: "Orthodox Church in America", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Sl, 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, + Jurisdiction { key: "romanian", name: "Romanian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "bulgarian", name: "Bulgarian Orthodox Church", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Sl, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "cyprus", name: "Church of Cyprus", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "albanian", name: "Orthodox Church of Albania", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Gk, menaia: NO_MENAIA, note: "" }, + Jurisdiction { key: "ukrainian", name: "Orthodox Church of Ukraine", paschalion: JulP, menologion: RevJul, rule: Orth, tradition: Sl, 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, + Jurisdiction { key: "finnish", name: "Finnish Orthodox Church", paschalion: GregP, menologion: RevJul, rule: Orth, tradition: Gk, 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, + Jurisdiction { key: "ruthenian", name: "Byzantine Catholic Metropolia of Pittsburgh", paschalion: GregP, menologion: RevJul, rule: Ruth, tradition: Sl, 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, + Jurisdiction { key: "ugcc", name: "Ukrainian Greek Catholic Church", paschalion: JulP, menologion: RevJul, rule: Ruth, tradition: Sl, 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, + Jurisdiction { key: "melkite", name: "Melkite Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, tradition: Gk, 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: "" }, + Jurisdiction { key: "romanian-catholic", name: "Romanian Greek Catholic Church", paschalion: GregP, menologion: RevJul, rule: Ruth, tradition: Gk, menaia: NO_MENAIA, note: "" }, ]; /// A jurisdiction key, a calendar name, or `None` for the default. diff --git a/src/main.rs b/src/main.rs index b888ca9..cae2670 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,6 +33,10 @@ struct Args { #[arg(short = 'j', long, visible_alias = "calendar", short_alias = 'c')] jurisdiction: Option, + /// Greek or Slavic usage, overriding the jurisdiction's own + #[arg(short = 't', long)] + tradition: Option, + /// YYYY-MM-DD (default: today) #[arg(short, long)] date: Option, @@ -167,12 +171,13 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) { Source::Config => " (from config)".into(), Source::Default => String::new(), }; - println!(" {} \u{b7} {}{from}", jur.name, rule.title); + println!(" {} \u{b7} {} \u{b7} {} usage{from}", jur.name, rule.title, jur.tradition.key()); println!(); + let sign = d.rank.map(|r| format!("{} ", r.sign())).unwrap_or_default(); if d.emoji.is_empty() { - println!(" {}", d.title()); + println!(" {sign}{}", d.title()); } else { - println!(" {} {}", d.emoji, d.title()); + println!(" {} {sign}{}", d.emoji, d.title()); } if let Some(c) = &d.cycle { println!(" {c}"); @@ -284,9 +289,22 @@ fn run() -> Result { Some(j) => (Some(j.clone()), Source::Flag), None => config::default_jurisdiction(), }; - let jur = jurisdictions::resolve(name.as_deref()) + let base = jurisdictions::resolve(name.as_deref()) .map_err(|n| format!("unknown jurisdiction {n:?}; try --list"))?; + // A tradition given on the command line overrides the one the jurisdiction + // carries. Leaked as a 'static because a Jurisdiction is otherwise a + // compile-time constant, and one override per run is not worth threading + // a lifetime through the whole library for. + let jur: &'static Jurisdiction = match &args.tradition { + None => base, + Some(t) => { + let tradition = jurisdictions::Tradition::parse(t) + .ok_or_else(|| format!("unknown tradition {t:?}; try slavic or greek"))?; + Box::leak(Box::new(Jurisdiction { tradition, ..*base })) + } + }; + if let Some(year) = args.year { if !(1..=9999).contains(&year) { return Err(format!("{year} is not a year this calendar reaches")); diff --git a/src/menologion.rs b/src/menologion.rs index 3a840af..f021325 100644 --- a/src/menologion.rs +++ b/src/menologion.rs @@ -15,6 +15,7 @@ use std::collections::BTreeMap; use serde::Deserialize; use crate::calendars::Date; +use crate::jurisdictions::Tradition; use crate::paschalion::Movables; /// The degrees of solemnity the menologion marks, and the order they rank in. @@ -40,6 +41,20 @@ impl Rank { pub fn names_the_day(self) -> bool { matches!(self, Rank::Great | Rank::Vigil) } + + /// The mark the liturgical books print beside the day. + /// + /// In the Slavic books these are printed in red: a cross in a circle for a + /// great feast, a plain cross for a vigil, a cross over a half-circle for + /// a polyeleos. Unicode has no glyph for the last, so the Jerusalem cross + /// stands in for it, which is what digital calendars generally settle on. + pub fn sign(self) -> &'static str { + match self { + Rank::Great => "\u{2295}", + Rank::Vigil => "\u{271a}", + Rank::Polyeleos => "\u{2629}", + } + } } #[derive(Clone, Debug, Default, Deserialize)] @@ -81,6 +96,11 @@ pub struct Menologion { /// get wrong, and the binary answers for every day of the year on its own. const COMMON: &str = include_str!("../data/menologion.json"); +/// Where the two usages part company. Compiled in beside the common set, and +/// laid over it according to the jurisdiction's tradition. +const SLAVIC: &str = include_str!("../data/tradition-slavic.json"); +const GREEK: &str = include_str!("../data/tradition-greek.json"); + fn parse(text: &str) -> Result { let raw: BTreeMap = serde_json::from_str(text).map_err(|e| e.to_string())?; @@ -165,10 +185,21 @@ pub fn data_dir() -> std::path::PathBuf { 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(); +fn tradition_overlay(t: Tradition) -> &'static Menologion { + use std::sync::OnceLock; + static SL: OnceLock = OnceLock::new(); + static GK: OnceLock = OnceLock::new(); + match t { + Tradition::Slavic => SL.get_or_init(|| parse(SLAVIC).expect("slavic usage parses")), + Tradition::Greek => GK.get_or_init(|| parse(GREEK).expect("greek usage parses")), + } +} + +/// The common menologion, the jurisdiction's usage laid over it, then any +/// supplementary sets. A missing fetched set is not an error: it simply has +/// not been fetched. +pub fn for_jurisdiction(tradition: Tradition, menaia: &[&str]) -> Menologion { + let mut m = Menologion::common().overlay(tradition_overlay(tradition)); for name in menaia { let path = data_dir().join(format!("{name}.json")); if let Ok(extra) = Menologion::load_file(&path) { diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 1832807..ee2cbac 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -23,6 +23,7 @@ pub struct Context<'a> { pub feast: String, pub rank: Option, pub jurisdiction: &'a Jurisdiction, + pub tradition: crate::jurisdictions::Tradition, } impl Context<'_> { diff --git a/src/rules/orthodox.rs b/src/rules/orthodox.rs index 53e2149..c1d0b77 100644 --- a/src/rules/orthodox.rs +++ b/src/rules/orthodox.rs @@ -24,6 +24,8 @@ //! 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. +//! - **Fish in the Nativity Fast** follows the usage, Greek or Slavic. See +//! `Tradition`. //! - **Monastic practice**, and the Holy Mountain in particular, is stricter //! than any of this throughout. //! @@ -32,6 +34,7 @@ use super::{Context, Rule, Vocabulary}; use crate::calendars::Weekday; use crate::fasting::{Fast, Level}; +use crate::jurisdictions::Tradition; use crate::menologion::Rank; use crate::seasons::Season; @@ -183,6 +186,16 @@ pub fn fast_for(ctx: &Context) -> Fast { } return for_feast(Level::Xerophagy, ctx, Level::WineOil, "the forefeast of the Nativity"); } + // Greek practice permits fish on any day but Wednesday and Friday + // until 17 December; Slavic practice keeps it to Saturdays and + // Sundays. This is the one place the two usages part on the fast. + if ctx.tradition == Tradition::Greek && md <= (12, 17) { + if matches!(ctx.weekday, Weekday::Wednesday | Weekday::Friday) { + return for_feast(Level::Xerophagy, ctx, Level::Fish, + "Wednesday and Friday of the Nativity Fast"); + } + return Fast::customary(Level::Fish, "the Nativity Fast, Greek usage"); + } if weekend { return Fast::customary(Level::Fish, "Saturday and Sunday of the Nativity Fast"); } diff --git a/tests/typikon.rs b/tests/typikon.rs index 0a71cc3..23580a8 100644 --- a/tests/typikon.rs +++ b/tests/typikon.rs @@ -465,3 +465,56 @@ fn every_advertised_column_actually_renders() { assert_eq!(lines[0].split('\t').count(), COLUMNS.len()); assert_eq!(lines[1].split('\t').count(), COLUMNS.len()); } + +// ───────────────────────── Greek and Slavic usage ───────────────────────── + +#[test] +fn the_protection_is_a_great_feast_in_slavic_usage_only() { + use typikon::jurisdictions::{Jurisdiction, Tradition}; + let base = *resolve(Some("goarch")).unwrap(); + let slavic: &'static Jurisdiction = + Box::leak(Box::new(Jurisdiction { tradition: Tradition::Slavic, ..base })); + let greek: &'static Jurisdiction = + Box::leak(Box::new(Jurisdiction { tradition: Tradition::Greek, ..base })); + assert_eq!(describe(d("2026-10-01"), slavic).rank, Some(Rank::Great)); + assert_eq!(describe(d("2026-10-01"), greek).rank, Some(Rank::Vigil)); +} + +#[test] +fn greek_usage_admits_fish_in_the_nativity_fast_where_slavic_does_not() { + // A Tuesday before 17 December. + assert_eq!(at("2026-12-01", "rocor").fast, Level::WineOil); + assert_eq!(at("2026-12-01", "goarch").fast, Level::Fish); + // Wednesday is a fast day in both. + assert!(at("2026-12-02", "goarch").fast <= Level::WineOil); + // And after the 17th the Greek relaxation stops. + assert_eq!(at("2026-12-22", "goarch").fast, Level::WineOil); +} + +#[test] +fn a_jurisdiction_carries_the_usage_its_books_follow() { + use typikon::jurisdictions::Tradition; + for key in ["moscow", "rocor", "serbian", "oca", "ruthenian", "ukrainian"] { + assert_eq!(resolve(Some(key)).unwrap().tradition, Tradition::Slavic, "{key}"); + } + for key in ["goarch", "greece", "constantinople", "antiochian", "cyprus"] { + assert_eq!(resolve(Some(key)).unwrap().tradition, Tradition::Greek, "{key}"); + } +} + +#[test] +fn every_rank_has_a_mark_and_they_are_distinct() { + let signs: Vec<&str> = [Rank::Great, Rank::Vigil, Rank::Polyeleos] + .iter().map(|r| r.sign()).collect(); + assert!(signs.iter().all(|s| !s.is_empty())); + assert_eq!(signs.iter().collect::>().len(), 3); +} + +#[test] +fn tradition_parses_the_names_people_use() { + use typikon::jurisdictions::Tradition; + assert_eq!(Tradition::parse("Slavic"), Some(Tradition::Slavic)); + assert_eq!(Tradition::parse("russian"), Some(Tradition::Slavic)); + assert_eq!(Tradition::parse("GREEK"), Some(Tradition::Greek)); + assert_eq!(Tradition::parse("coptic"), None); +}