From d115616eff8139fa9190200a41023dbbd4270a2b Mon Sep 17 00:00:00 2001 From: funkpower Date: Fri, 18 Sep 2026 11:13:10 -0400 Subject: [PATCH] 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. --- .gitignore | 12 +- Cargo.lock | 249 +++++++++++++++ Cargo.toml | 30 ++ README.md | 63 ++-- {typikon/data => data}/LICENSE | 2 +- {typikon/data => data}/menologion.json | 0 examples/dump.rs | 19 ++ pyproject.toml | 43 --- src/calendars.rs | 262 ++++++++++++++++ src/config.rs | 77 +++++ src/day.rs | 224 ++++++++++++++ src/export/ics.rs | 130 ++++++++ src/export/json.rs | 38 +++ src/export/mod.rs | 5 + src/fasting.rs | 134 ++++++++ src/jurisdictions.rs | 170 +++++++++++ src/lib.rs | 11 + src/main.rs | 350 +++++++++++++++++++++ src/menaia.rs | 272 +++++++++++++++++ src/menologion.rs | 215 +++++++++++++ src/paschalion.rs | 142 +++++++++ src/rules/mod.rs | 64 ++++ src/rules/orthodox.rs | 250 +++++++++++++++ src/rules/ruthenian.rs | 161 ++++++++++ src/seasons.rs | 112 +++++++ tests/test_typikon.py | 368 ---------------------- tests/typikon.rs | 408 +++++++++++++++++++++++++ typikon/__init__.py | 31 -- typikon/calendars.py | 125 -------- typikon/cli.py | 237 -------------- typikon/config.py | 54 ---- typikon/day.py | 193 ------------ typikon/export/__init__.py | 4 - typikon/export/ics.py | 104 ------- typikon/export/jsonout.py | 19 -- typikon/fasting.py | 82 ----- typikon/jurisdictions.py | 151 --------- typikon/menaia.py | 171 ----------- typikon/menologion.py | 137 --------- typikon/paschalion.py | 110 ------- typikon/rules/__init__.py | 19 -- typikon/rules/orthodox.py | 205 ------------- typikon/rules/ruthenian.py | 126 -------- typikon/seasons.py | 75 ----- 44 files changed, 3367 insertions(+), 2287 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml rename {typikon/data => data}/LICENSE (89%) rename {typikon/data => data}/menologion.json (100%) create mode 100644 examples/dump.rs delete mode 100644 pyproject.toml create mode 100644 src/calendars.rs create mode 100644 src/config.rs create mode 100644 src/day.rs create mode 100644 src/export/ics.rs create mode 100644 src/export/json.rs create mode 100644 src/export/mod.rs create mode 100644 src/fasting.rs create mode 100644 src/jurisdictions.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/menaia.rs create mode 100644 src/menologion.rs create mode 100644 src/paschalion.rs create mode 100644 src/rules/mod.rs create mode 100644 src/rules/orthodox.rs create mode 100644 src/rules/ruthenian.rs create mode 100644 src/seasons.rs delete mode 100644 tests/test_typikon.py create mode 100644 tests/typikon.rs delete mode 100644 typikon/__init__.py delete mode 100644 typikon/calendars.py delete mode 100644 typikon/cli.py delete mode 100644 typikon/config.py delete mode 100644 typikon/day.py delete mode 100644 typikon/export/__init__.py delete mode 100644 typikon/export/ics.py delete mode 100644 typikon/export/jsonout.py delete mode 100644 typikon/fasting.py delete mode 100644 typikon/jurisdictions.py delete mode 100644 typikon/menaia.py delete mode 100644 typikon/menologion.py delete mode 100644 typikon/paschalion.py delete mode 100644 typikon/rules/__init__.py delete mode 100644 typikon/rules/orthodox.py delete mode 100644 typikon/rules/ruthenian.py delete mode 100644 typikon/seasons.py diff --git a/.gitignore b/.gitignore index 06dff85..95cb949 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,4 @@ -__pycache__/ -*.py[cod] +/target +**/*.rs.bk .venv/ -build/ -dist/ *.egg-info/ -.pytest_cache/ - -# Supplementary commemoration sets are fetched, never committed — they are -# other people's work. See typikon/menaia.py. -typikon/data/*.json -!typikon/data/menologion.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5cf0c00 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,249 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typikon" +version = "0.2.0" +dependencies = [ + "clap", + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dd40704 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "typikon" +version = "0.2.0" +edition = "2021" +rust-version = "1.74" +description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched" +license = "GPL-2.0-only" +repository = "https://git.pumpin.online/funkpower/typikon" +readme = "README.md" +keywords = ["orthodox", "liturgical", "calendar", "byzantine", "pascha"] +categories = ["command-line-utilities", "date-and-time"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } + +[profile.release] +strip = true +lto = true +codegen-units = 1 +panic = "abort" + +[[bin]] +name = "typikon" +path = "src/main.rs" + +[lib] +name = "typikon" +path = "src/lib.rs" diff --git a/README.md b/README.md index 226fb71..0cd42ce 100644 --- a/README.md +++ b/README.md @@ -23,18 +23,23 @@ $ typikon ## Install +One static binary, no runtime to install first. + ```console -$ pipx install git+https://git.pumpin.online/funkpower/typikon +$ cargo install --git https://git.pumpin.online/funkpower/typikon ``` -Or run it straight out of a checkout. It has no dependencies and needs only -Python 3.9. +Or build it from a checkout: ```console $ git clone https://git.pumpin.online/funkpower/typikon -$ cd typikon && python -m typikon.cli +$ cd typikon && cargo build --release +$ install -Dm755 target/release/typikon ~/.local/bin/typikon ``` +The menologion is compiled in, so the binary answers for every day of the year +on its own: no data file to find, no network, nothing to go stale. + ## Which calendar? Three things get conflated constantly, and keeping them apart is most of what @@ -126,20 +131,30 @@ neighbour. ## As a library -```python -import typikon +```rust +use typikon::calendars::Date; +use typikon::day::describe; +use typikon::jurisdictions::resolve; -d = typikon.today("rocor") -d.church_date # '5 September' -d.fast # 'xerophagy' -d.title # 'The Holy Prophet Zachariah, father of the Forerunner' -d.is_obligatory # False +let rocor = resolve(Some("rocor"))?; +let day = describe(Date::from_ymd(2026, 9, 18).unwrap(), rocor); -typikon.julian_pascha(2027) # datetime.date(2027, 5, 2) -typikon.gregorian_pascha(2027) # datetime.date(2027, 3, 28) +day.church_date; // "5 September" +day.fast; // Level::Xerophagy +day.title(); // "The Holy Prophet Zachariah, father of the Forerunner" +day.is_obligatory(); // false -for day in typikon.week(date, 7, "antiochian"): - ... +typikon::paschalion::julian_pascha(2027); // 2027-05-02 +typikon::paschalion::gregorian_pascha(2027); // 2027-03-28 +``` + +The levels are an ordered enum, strictest first, so `a < b` reads as "a is +stricter than b" and a feast can only ever relax a fast: + +```rust +Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil) // WineOil: no saint + // brings fish into Lent +Level::Fish.relaxed_to(Level::WineOil, Level::WineOil) // Fish: never tightens ``` ## The menologion @@ -150,7 +165,7 @@ church both keep the Nativity on 25 December; they disagree about which morning that is, and that disagreement lives in the calendar conversion, not in the data. -`typikon/data/menologion.json` covers all 366 days with the principal +`data/menologion.json`, compiled into the binary, covers all 366 days with the principal commemorations of the common Byzantine tradition, ranked so the fasting rules know when a feast lifts a Wednesday. It was composed for this project and is under the same licence as the code. It is a working set, not a complete @@ -165,14 +180,18 @@ $ typikon --list-menaia $ typikon --update-menaion ruthenian ``` -It lands beside the packaged menologion and is overlaid automatically. +It lands in `~/.local/share/typikon/` and is overlaid automatically. ## Accuracy -`typikon` is pinned by 60 tests against published calendars: the paschalion -for six years by both reckonings, the Julian drift across the 2100 boundary, -the fast on every day that is not open to interpretation, and a full year in -every jurisdiction. +`typikon` is pinned against published calendars: the paschalion for six years +by both reckonings and its invariants across two centuries, 146,000 calendar +round-trips from 1800 to 2200, the fast on every day that is not open to +interpretation, and every jurisdiction over twenty years. + +It was ported from a Python implementation, and the port was checked by +running both over 199,446 day-records, 26 jurisdictions across 21 years, and +requiring every field of every record to match. It is a calendar, not an authority. If it disagrees with the one your parish prints, your parish is right. Please open an issue. @@ -187,4 +206,4 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above. ## Licence GPL-2.0-only. See [LICENSE](LICENSE). The menologion is under it too; -`typikon/data/LICENSE` says so and notes what the fetched sets are not. +`data/LICENSE` says so and notes what the fetched sets are not. diff --git a/typikon/data/LICENSE b/data/LICENSE similarity index 89% rename from typikon/data/LICENSE rename to data/LICENSE index 9b54a5e..d321cc1 100644 --- a/typikon/data/LICENSE +++ b/data/LICENSE @@ -9,4 +9,4 @@ what you build on it stays as free as what you were given. Supplementary sets fetched by `typikon --update-menaion` are NOT covered by this and are NOT distributed here. They belong to whoever published them; the -fetcher puts a copy on your machine for your own use. See typikon/menaia.py. +fetcher puts a copy on your machine for your own use. See src/menaia.rs. diff --git a/typikon/data/menologion.json b/data/menologion.json similarity index 100% rename from typikon/data/menologion.json rename to data/menologion.json diff --git a/examples/dump.rs b/examples/dump.rs new file mode 100644 index 0000000..94c1e43 --- /dev/null +++ b/examples/dump.rs @@ -0,0 +1,19 @@ +// Dump every day of a range, every jurisdiction, as one JSON object per line, +// for differential testing against the Python implementation. +use typikon::calendars::Date; +use typikon::day::describe_with; +use typikon::jurisdictions::{CALENDARS, JURISDICTIONS}; +use typikon::menologion; + +fn main() { + let args: Vec = std::env::args().collect(); + 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); + 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/pyproject.toml b/pyproject.toml deleted file mode 100644 index bf17424..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[build-system] -requires = ["setuptools>=64"] -build-backend = "setuptools.build_meta" - -[project] -name = "typikon" -version = "0.1.0" -description = "The Orthodox liturgical day — feasts, seasons and the fast, computed rather than fetched" -readme = "README.md" -license = { text = "GPL-2.0-only" } -requires-python = ">=3.9" -authors = [{ name = "funkpower", email = "funkpower@pumpin.online" }] -keywords = ["orthodox", "liturgical", "calendar", "byzantine", "pascha", - "menologion", "typikon", "fasting"] -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", - "Programming Language :: Python :: 3", - "Topic :: Religion", - "Topic :: Utilities", -] -dependencies = [] - -[project.optional-dependencies] -dev = ["pytest>=7"] - -[project.urls] -Homepage = "https://git.pumpin.online/funkpower/typikon" -Source = "https://git.pumpin.online/funkpower/typikon" - -[project.scripts] -typikon = "typikon.cli:main" - -[tool.setuptools.packages.find] -include = ["typikon*"] - -[tool.setuptools.package-data] -typikon = ["data/*.json", "data/LICENSE"] - -[tool.pytest.ini_options] -testpaths = ["tests"] diff --git a/src/calendars.rs b/src/calendars.rs new file mode 100644 index 0000000..82fab89 --- /dev/null +++ b/src/calendars.rs @@ -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 { + 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 { + 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 for Date { + type Output = Date; + fn add(self, days: i32) -> Date { + Date(self.0 + days) + } +} + +impl std::ops::Sub 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 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 + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e26f82a --- /dev/null +++ b/src/config.rs @@ -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 { + 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 { + 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, 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), + } +} diff --git a/src/day.rs b/src/day.rs new file mode 100644 index 0000000..3a16e15 --- /dev/null +++ b/src/day.rs @@ -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, + pub cycle: Option, + pub feast: Option, + pub rank: Option, + pub commemorations: Vec, + pub fast: Level, + pub fast_label: &'static str, + pub emoji: &'static str, + pub reason: String, + pub obligation: Option, + 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 = 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 { + 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) +} diff --git a/src/export/ics.rs b/src/export/ics.rs new file mode 100644 index 0000000..7e10977 --- /dev/null +++ b/src/export/ics.rs @@ -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 = 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 = 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 = 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::() + 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 +} diff --git a/src/export/json.rs b/src/export/json.rs new file mode 100644 index 0000000..69ceee4 --- /dev/null +++ b/src/export/json.rs @@ -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>, +} + +pub fn to_json(days: &[Day], indent: Option) -> String { + match days { + [one] => encode(&one.to_json(), indent), + many => encode( + &Days { days: many.iter().map(Day::to_json).collect() }, + indent, + ), + } +} + +fn encode(value: &T, indent: Option) -> 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") + } + } +} diff --git a/src/export/mod.rs b/src/export/mod.rs new file mode 100644 index 0000000..96a7510 --- /dev/null +++ b/src/export/mod.rs @@ -0,0 +1,5 @@ +pub mod ics; +pub mod json; + +pub use ics::to_ics; +pub use json::to_json; diff --git a/src/fasting.rs b/src/fasting.rs new file mode 100644 index 0000000..3a70958 --- /dev/null +++ b/src/fasting.rs @@ -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, + /// 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) -> 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, + } + } +} diff --git a/src/jurisdictions.rs b/src/jurisdictions.rs new file mode 100644 index 0000000..6bcbdae --- /dev/null +++ b/src/jurisdictions.rs @@ -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()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8d08a38 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f0f90bf --- /dev/null +++ b/src/main.rs @@ -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, + + /// YYYY-MM-DD (default: today) + #[arg(short, long)] + date: Option, + + /// 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, + + /// Print a year's key dates and exit + #[arg(long)] + year: Option, + + /// List every jurisdiction and calendar + #[arg(long)] + list: bool, + + /// Remember a jurisdiction and exit + #[arg(long, value_name = "JURISDICTION")] + set_default: Option, + + /// Fetch a supplementary commemoration set + #[arg(long, value_name = "NAME")] + update_menaion: Option, + + /// 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 { + 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 { + 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 `, 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::(), s[3..5].parse::()) { + 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); + } + } +} diff --git a/src/menaia.rs b/src/menaia.rs new file mode 100644 index 0000000..b234579 --- /dev/null +++ b/src/menaia.rs @@ -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 { + 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 { + 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::>().join(" ")) + .filter(|l| !l.is_empty()) + .collect() +} + +fn month_index(word: &str) -> Option { + 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, + pub missing: Vec, +} + +/// 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 { + 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 = Default::default(); + let mut corrections = Vec::new(); + let mut cur: Option<(u32, u32)> = None; + let mut buf: Vec = Vec::new(); + let mut prev: Option<(u32, u32)> = None; + + let flush = |cur: &Option<(u32, u32)>, buf: &mut Vec, + out: &mut std::collections::BTreeMap| { + let Some((m, d)) = *cur else { buf.clear(); return }; + let mut items: Vec = Vec::new(); + let mut cycle: Option = 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 { + let chars: Vec = 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 +} diff --git a/src/menologion.rs b/src/menologion.rs new file mode 100644 index 0000000..3a840af --- /dev/null +++ b/src/menologion.rs @@ -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, + #[serde(default)] + pub rank: Option, + /// Forefeast, afterfeast or leave-taking. + #[serde(default)] + pub cycle: Option, +} + +#[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, + 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 { + let raw: BTreeMap = + 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 = 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 { + 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)> { + let table: [(Date, &'static str, Option); 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)) +} diff --git a/src/paschalion.rs b/src/paschalion.rs new file mode 100644 index 0000000..4132c66 --- /dev/null +++ b/src/paschalion.rs @@ -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, + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs new file mode 100644 index 0000000..1832807 --- /dev/null +++ b/src/rules/mod.rs @@ -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, + pub feast: String, + pub rank: Option, + 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, + } +} diff --git a/src/rules/orthodox.rs b/src/rules/orthodox.rs new file mode 100644 index 0000000..53e2149 --- /dev/null +++ b/src/rules/orthodox.rs @@ -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) -> Option { + 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, +}; diff --git a/src/rules/ruthenian.rs b/src/rules/ruthenian.rs new file mode 100644 index 0000000..2319071 --- /dev/null +++ b/src/rules/ruthenian.rs @@ -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, +}; diff --git a/src/seasons.rs b/src/seasons.rs new file mode 100644 index 0000000..275140c --- /dev/null +++ b/src/seasons.rs @@ -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 { + // 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 +} diff --git a/tests/test_typikon.py b/tests/test_typikon.py deleted file mode 100644 index a965e99..0000000 --- a/tests/test_typikon.py +++ /dev/null @@ -1,368 +0,0 @@ -"""Pinned against published calendars and against dates whose answer is not -open to interpretation.""" - -import datetime as dt - -import pytest - -from typikon import describe, gregorian_pascha, julian_pascha, resolve -from typikon.calendars import from_julian, julian_offset, to_julian -from typikon.day import week, year_of -from typikon.export import to_ics, to_json -from typikon.fasting import (DAIRY, FISH, NONE, OBLIGATORY, STRICT, WINE_OIL, - XEROPHAGY, stricter) - - -# ───────────────────────────── the paschalion ───────────────────────────── - -@pytest.mark.parametrize("year,expected", [ - (2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"), - (2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"), -]) -def test_julian_pascha(year, expected): - assert julian_pascha(year).isoformat() == expected - - -@pytest.mark.parametrize("year,expected", [ - (2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"), - (2027, "2027-03-28"), (2028, "2028-04-16"), -]) -def test_gregorian_pascha(year, expected): - assert gregorian_pascha(year).isoformat() == expected - - -def test_the_two_paschalions_agree_in_2025_and_differ_in_2026(): - assert julian_pascha(2025) == gregorian_pascha(2025) - assert (julian_pascha(2026) - gregorian_pascha(2026)).days == 7 - - -# ──────────────────────────── calendar drift ────────────────────────────── - -def test_julian_lags_thirteen_days_now_and_fourteen_after_2100(): - assert julian_offset(dt.date(2026, 9, 18)) == 13 - assert to_julian(dt.date(2026, 9, 18)) == (2026, 9, 5) - # Julian 2100 is a leap year and Gregorian 2100 is not, so the gap - # widens the day after the Julian 29 February — 15 March by the civil - # calendar. Nothing here hardcodes it. - assert julian_offset(dt.date(2100, 3, 14)) == 13 - assert julian_offset(dt.date(2100, 3, 15)) == 14 - - -def test_old_calendar_nativity_falls_on_7_january(): - assert from_julian(2026, 12, 25) == dt.date(2027, 1, 7) - - -def test_old_and_new_disagree_about_the_day_not_the_date(): - old = describe(dt.date(2027, 1, 7), "rocor") - new = describe(dt.date(2026, 12, 25), "goarch") - assert old.church_date == new.church_date == "25 December" - assert old.feast == new.feast - assert old.rank == new.rank == "great" - - -# ───────────────────────── the Orthodox fast ────────────────────────────── - -def test_great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil(): - # 2026: Orthodox Pascha 12 April, so Clean Monday is 23 February. - assert describe(dt.date(2026, 2, 25), "new").fast == XEROPHAGY # Wednesday - assert describe(dt.date(2026, 2, 28), "new").fast == WINE_OIL # Saturday - assert describe(dt.date(2026, 3, 1), "new").fast == WINE_OIL # Sunday - - -def test_the_two_strictest_days_of_the_year(): - assert describe(dt.date(2026, 2, 23), "new").fast == STRICT # Clean Monday - assert describe(dt.date(2026, 4, 10), "new").fast == STRICT # Great Friday - # The one Saturday kept without oil. - assert describe(dt.date(2026, 4, 11), "new").fast == XEROPHAGY - - -def test_annunciation_and_palm_sunday_admit_fish_inside_the_great_fast(): - ann = describe(dt.date(2026, 3, 25), "new") - assert ann.season == "Great Fast" and ann.fast == FISH - assert describe(dt.date(2026, 4, 5), "new").fast == FISH # Palm Sunday - - -def test_cheesefare_week_keeps_dairy_on_wednesday_and_friday(): - d = describe(dt.date(2026, 2, 18), "new") # Wednesday - assert d.season == "Cheesefare Week" and d.fast == DAIRY - - -def test_the_fast_free_stretches(): - assert describe(dt.date(2026, 4, 17), "new").fast == NONE # Bright Friday - assert describe(dt.date(2026, 1, 2), "new").fast == NONE # Christmastide - assert describe(dt.date(2026, 6, 3), "new").fast == NONE # week after Pentecost - # The week after the Publican and the Pharisee, kept fast-free precisely - # because the Pharisee boasted of fasting twice a week. - assert describe(dt.date(2026, 2, 6), "new").fast == NONE - - -def test_nativity_fast_tightens_for_its_last_five_days(): - # A Saturday before 20 December admits fish; one after it does not. - assert describe(dt.date(2026, 12, 12), "new").fast == FISH - assert describe(dt.date(2026, 12, 26), "new").season != "Nativity Fast" - late = describe(dt.date(2026, 12, 19), "new") - assert late.fast == FISH - assert describe(dt.date(2026, 12, 22), "new").fast == WINE_OIL # Tuesday - assert describe(dt.date(2026, 12, 24), "new").fast == XEROPHAGY # the eve - - -def test_a_great_feast_lifts_an_ordinary_friday_to_fish(): - d = describe(dt.date(2026, 8, 6), "goarch") # Transfiguration - assert d.weekday == "Thursday" and d.fast == FISH - # 2026-01-01, the Circumcision, is a Thursday; take a great feast on a - # Wednesday instead — the Meeting of our Lord, 2 February 2028. - m = describe(dt.date(2028, 2, 2), "goarch") - assert m.weekday == "Wednesday" and m.rank == "great" and m.fast == FISH - - -def test_ordinary_wednesday_and_friday_are_xerophagy(): - d = describe(dt.date(2026, 9, 4), "new") # a Friday - assert d.season is None and d.rank is None and d.fast == XEROPHAGY - - -def test_a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil(): - # 9 October 2026 is a Friday and the Apostle James son of Alphaeus. The - # Typikon gives wine and oil for a saint of this rank, which is why the - # published calendars mark so many Fridays that way. - d = describe(dt.date(2026, 10, 9), "new") - assert d.weekday == "Friday" and d.rank == "polyeleos" - assert d.fast == WINE_OIL - - -def test_the_orthodox_rule_never_reports_an_obligation(): - days = week(dt.date(2026, 1, 1), 365, "new") - assert not any(d.obligation == OBLIGATORY for d in days) - - -# ──────────────────── Byzantine Catholic particular law ─────────────────── - -def test_ruthenian_pascha_is_the_gregorian_one(): - assert describe(dt.date(2026, 6, 1), "ruthenian").pascha == dt.date(2026, 4, 5) - - -def test_ruthenian_clean_monday_is_16_february_2026(): - # As published by the Metropolitan Cantor Institute. - assert describe(dt.date(2026, 2, 16), "ruthenian").reason.startswith( - "the first day") - - -def test_only_four_kinds_of_day_bind_under_ruthenian_law(): - obligatory = [d for d in week(dt.date(2026, 1, 1), 365, "ruthenian") - if d.obligation == OBLIGATORY] - # Clean Monday, Great Friday, and the Wednesdays and Fridays of the Fast. - assert dt.date(2026, 2, 16) in [d.date for d in obligatory] - assert dt.date(2026, 4, 3) in [d.date for d in obligatory] - assert all(d.season in ("Great Fast", "Great and Holy Week") - for d in obligatory) - - -def test_the_voluntary_seasons_never_report_as_binding(): - for when in (dt.date(2026, 11, 20), dt.date(2026, 8, 5)): - assert describe(when, "ruthenian").obligation == "customary" - - -def test_christmas_on_a_friday_does_not_ask_you_to_abstain(): - d = describe(dt.date(2026, 12, 25), "ruthenian") - assert d.weekday == "Friday" and d.fast == NONE - - -def test_ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat(): - assert describe(dt.date(2026, 2, 16), "ruthenian").binds == WINE_OIL - assert describe(dt.date(2026, 4, 3), "ruthenian").binds == WINE_OIL - assert describe(dt.date(2026, 2, 18), "ruthenian").binds == DAIRY - assert describe(dt.date(2026, 10, 9), "ruthenian").binds == NONE - - -# ──────────────────────────── the menologion ────────────────────────────── - -def test_every_day_of_a_leap_year_has_a_commemoration(): - for d in week(dt.date(2024, 1, 1), 366, "new"): - assert d.commemorations, d.date - - -def test_the_twelve_great_feasts_are_all_present(): - fixed = [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25), - (8, 6), (8, 15)] - for month, day in fixed: - d = describe(dt.date(2026, month, day), "goarch") - assert d.rank == "great", (month, day) - for when in (dt.date(2026, 4, 5), dt.date(2026, 5, 21), dt.date(2026, 5, 31)): - assert describe(when, "goarch").rank == "great", when - - -def test_a_movable_feast_outranks_the_fixed_calendar(): - d = describe(dt.date(2026, 4, 12), "new") - assert "Pascha" in d.feast - - -# ──────────────────────────────── output ────────────────────────────────── - -def test_json_round_trips(): - import json - days = week(dt.date(2026, 4, 10), 3, "new") - parsed = json.loads(to_json(days)) - assert len(parsed["days"]) == 3 - assert parsed["days"][0]["fast"] == STRICT - - -def test_ics_is_well_formed_and_folds_long_lines(): - text = to_ics(week(dt.date(2026, 4, 10), 7, "new")) - assert text.startswith("BEGIN:VCALENDAR") - assert text.rstrip().endswith("END:VCALENDAR") - assert text.count("BEGIN:VEVENT") == text.count("END:VEVENT") - for line in text.split("\r\n"): - assert len(line.encode()) < 200 - - -# ─────────────────────────── every jurisdiction ─────────────────────────── - -@pytest.mark.parametrize("key", list(__import__("typikon").JURISDICTIONS)) -def test_every_jurisdiction_reckons_a_whole_year_without_raising(key): - days = week(dt.date(2026, 1, 1), 365, key) - assert len(days) == 365 - assert all(d.fast for d in days) - - -def test_a_calendar_name_resolves_without_naming_a_church(): - assert resolve("old").menologion == "julian" - assert resolve("new").menologion == "revised_julian" - assert resolve("western").paschalion == "gregorian" - assert resolve(None).key == "new" - - -# ─────────────────── regressions, each from a real defect ────────────────── - -def test_the_annunciation_is_never_lost_to_great_friday(): - """It can fall on Great Friday or Great Saturday, and when it does the - feast lifts the total fast rather than disappearing into it.""" - for when, jurisdiction in ((dt.date(2016, 3, 25), "western"), - (dt.date(2005, 3, 25), "western"), - (dt.date(2029, 4, 7), "rocor"), - (dt.date(2018, 4, 7), "rocor")): - d = describe(when, jurisdiction) - assert d.church_date == "25 March", when - assert "Annunciation" in d.feast, when - assert d.rank == "great", when - assert d.fast == WINE_OIL, (when, d.fast) - - -def test_kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free(): - assert describe(dt.date(2035, 3, 25), "western").fast == NONE - assert describe(dt.date(2075, 4, 7), "rocor").fast == NONE - - -def test_palm_sunday_and_lazarus_saturday_are_still_the_fast(): - """Both fall between the Forty Days and Great Week. Leaving that gap - outside every season made the Byzantine Catholic rule report no fast at - all on Palm Sunday.""" - for jurisdiction in ("ruthenian", "ugcc", "melkite", "goarch", "rocor"): - for year in (2025, 2026, 2027): - mv = year_of(year, jurisdiction) - for when in (mv["lazarus_saturday"], mv["palm_sunday"]): - d = describe(when, jurisdiction) - assert d.season is not None, (jurisdiction, when) - assert d.fast != NONE, (jurisdiction, when, d.fast) - assert d.reason, (jurisdiction, when) - - -def test_a_ranked_saint_relaxes_a_weekday_of_a_fasting_season(): - """The rank is why the printed calendars mark so many Wednesdays in the - Nativity Fast for wine and oil.""" - # The Nativity of the Forerunner, a great feast inside the Apostles' Fast. - assert describe(dt.date(2020, 6, 24), "goarch").fast == FISH - # The Forty Martyrs of Sebaste, the textbook relaxation of a Lenten weekday. - assert describe(dt.date(2022, 3, 9), "goarch").fast == WINE_OIL - # St Nicholas, a vigil saint on a Wednesday of the Nativity Fast. - assert describe(dt.date(2023, 12, 6), "goarch").fast == WINE_OIL - - -def test_no_saint_brings_fish_into_the_great_fast_or_lifts_great_week(): - for d in week(dt.date(2022, 3, 7), 40, "goarch"): - if d.season == "Great Fast" and d.church_date != "25 March": - assert not stricter(FISH, d.fast) or d.fast != FISH, d.date - if d.season == "Great and Holy Week" and d.church_date != "25 March": - assert d.fast in (STRICT, XEROPHAGY, WINE_OIL), (d.date, d.fast) - - -def test_the_eves_relax_when_they_fall_on_a_weekend(): - """The Liturgy of St Basil is served with Vespers, so the eve is kept at - wine and oil rather than xerophagy.""" - assert describe(dt.date(2022, 12, 24), "goarch").fast == WINE_OIL - assert describe(dt.date(2023, 12, 24), "goarch").fast == WINE_OIL - assert describe(dt.date(2025, 1, 5), "goarch").fast == WINE_OIL - # A weekday eve is still xerophagy. - assert describe(dt.date(2024, 12, 24), "goarch").fast == XEROPHAGY - - -def test_the_apostles_fast_is_empty_when_pascha_is_late(): - """A late Pascha can leave the fast with no days at all, which is correct - rather than a bug -- and the old calendar never has that problem.""" - goarch = [d for d in week(dt.date(2024, 5, 1), 120, "goarch") - if d.season == "Apostles' Fast"] - rocor = [d for d in week(dt.date(2024, 5, 1), 120, "rocor") - if d.season == "Apostles' Fast"] - assert goarch == [] - assert len(rocor) > 0 - - -# ──────────────────────── invariants, not samples ────────────────────────── - -def test_the_paschalion_invariants_hold_for_two_centuries(): - for year in range(1900, 2101): - j, g = julian_pascha(year), gregorian_pascha(year) - assert j.weekday() == 6, year - assert g.weekday() == 6, year - assert dt.date(year, 4, 4) <= j <= dt.date(year, 5, 8), (year, j) - assert dt.date(year, 3, 22) <= g <= dt.date(year, 4, 25), (year, g) - - -def test_every_civil_date_round_trips_through_the_julian_calendar(): - day, last = dt.date(1800, 1, 1), dt.date(2200, 12, 31) - while day <= last: - y, m, d = to_julian(day) - assert from_julian(y, m, d) == day, day - day += dt.timedelta(1) - - -def test_the_julian_offset_widens_the_day_after_a_julian_leap_day(): - for before, after in ((dt.date(1900, 3, 13), dt.date(1900, 3, 14)), - (dt.date(2100, 3, 14), dt.date(2100, 3, 15)), - (dt.date(2200, 3, 15), dt.date(2200, 3, 16))): - assert julian_offset(after) == julian_offset(before) + 1, after - - -def test_ics_content_lines_stay_within_seventy_five_octets(): - for jurisdiction in ("goarch", "rocor", "ruthenian"): - text = to_ics(week(dt.date(2026, 1, 1), 365, jurisdiction)) - for line in text.split("\r\n"): - assert len(line.encode()) <= 75, (jurisdiction, line) - - -def test_every_jurisdiction_holds_together_for_twenty_years(): - """The regression net. Replaces a loop that asserted only truthiness and - so could not fail: "none" is a truthy string.""" - from typikon import JURISDICTIONS - from typikon.fasting import LEVELS - for key in JURISDICTIONS: - for d in week(dt.date(2020, 1, 1), 366 * 21 // 4, key): - assert d.fast in LEVELS, (key, d.date, d.fast) - assert d.binds in LEVELS, (key, d.date, d.binds) - assert d.commemorations, (key, d.date) - assert not stricter(d.binds, d.fast), (key, d.date) - assert d.title, (key, d.date) - - -# ──────────────────────────────── the CLI ────────────────────────────────── - -def test_the_cli_rejects_a_malformed_date_instead_of_traversing_a_traceback(): - from typikon.cli import main - assert main(["-d", "tomorrow"]) == 2 - assert main(["-d", "2026-02-30"]) == 2 - assert main(["--year", "99999"]) == 2 - assert main(["--jurisdiction", "atlantis"]) == 2 - - -def test_the_cli_elides_on_a_word(): - from typikon.cli import _elide - assert _elide("The Radiant and Life-giving Resurrection", 24) == "The Radiant and…" - assert _elide("Short", 24) == "Short" diff --git a/tests/typikon.rs b/tests/typikon.rs new file mode 100644 index 0000000..e0b8feb --- /dev/null +++ b/tests/typikon.rs @@ -0,0 +1,408 @@ +//! Pinned against published calendars and against dates whose answer is not +//! open to interpretation. + +use typikon::calendars::{Date, JulianDate, Weekday}; +use typikon::day::{describe, week, year_of}; +use typikon::export::to_ics; +use typikon::fasting::{Level, Obligation}; +use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS}; +use typikon::menologion::Rank; +use typikon::paschalion::{gregorian_pascha, julian_pascha, Paschalion}; +use typikon::seasons::Season; + +fn d(s: &str) -> Date { + Date::parse_iso(s).expect("a real date") +} + +fn at(s: &str, j: &str) -> typikon::day::Day { + describe(d(s), resolve(Some(j)).expect("a real jurisdiction")) +} + +// ───────────────────────────── the paschalion ───────────────────────────── + +#[test] +fn julian_pascha_matches_the_published_dates() { + for (year, expected) in [ + (2024, "2024-05-05"), (2025, "2025-04-20"), (2026, "2026-04-12"), + (2027, "2027-05-02"), (2028, "2028-04-16"), (2030, "2030-04-28"), + ] { + assert_eq!(julian_pascha(year).iso(), expected, "julian pascha {year}"); + } +} + +#[test] +fn gregorian_pascha_matches_the_published_dates() { + for (year, expected) in [ + (2024, "2024-03-31"), (2025, "2025-04-20"), (2026, "2026-04-05"), + (2027, "2027-03-28"), (2028, "2028-04-16"), + ] { + assert_eq!(gregorian_pascha(year).iso(), expected, "gregorian pascha {year}"); + } +} + +#[test] +fn the_two_paschalions_agree_in_2025_and_differ_by_a_week_in_2026() { + assert_eq!(julian_pascha(2025), gregorian_pascha(2025)); + assert_eq!(julian_pascha(2026) - gregorian_pascha(2026), 7); +} + +#[test] +fn the_paschalion_invariants_hold_for_two_centuries() { + for year in 1900..=2100 { + let (j, g) = (julian_pascha(year), gregorian_pascha(year)); + assert_eq!(j.weekday(), Weekday::Sunday, "julian {year}"); + assert_eq!(g.weekday(), Weekday::Sunday, "gregorian {year}"); + assert!( + Date::from_ymd(year, 4, 4).unwrap() <= j && j <= Date::from_ymd(year, 5, 8).unwrap(), + "orthodox pascha {year} out of range: {j}" + ); + assert!( + Date::from_ymd(year, 3, 22).unwrap() <= g && g <= Date::from_ymd(year, 4, 25).unwrap(), + "western pascha {year} out of range: {g}" + ); + } +} + +// ──────────────────────────── calendar drift ────────────────────────────── + +#[test] +fn every_civil_date_round_trips_through_the_julian_calendar() { + let mut day = d("1800-01-01"); + let last = d("2200-12-31"); + while day <= last { + let j = day.to_julian(); + assert_eq!(j.to_civil(), day, "round trip {day}"); + day = day + 1; + } +} + +#[test] +fn julian_lags_thirteen_days_now() { + assert_eq!(d("2026-09-18").julian_offset(), 13); + let j = d("2026-09-18").to_julian(); + assert_eq!((j.year, j.month, j.day), (2026, 9, 5)); +} + +#[test] +fn the_julian_offset_widens_the_day_after_a_julian_leap_day() { + for (before, after) in [ + ("1900-03-13", "1900-03-14"), + ("2100-03-14", "2100-03-15"), + ("2200-03-15", "2200-03-16"), + ] { + assert_eq!( + d(after).julian_offset(), + d(before).julian_offset() + 1, + "offset should widen at {after}" + ); + } +} + +#[test] +fn old_calendar_nativity_falls_on_7_january() { + assert_eq!( + JulianDate { year: 2026, month: 12, day: 25 }.to_civil().iso(), + "2027-01-07" + ); +} + +#[test] +fn old_and_new_disagree_about_the_day_not_the_date() { + let old = at("2027-01-07", "rocor"); + let new = at("2026-12-25", "goarch"); + assert_eq!(old.church_date, "25 December"); + assert_eq!(new.church_date, "25 December"); + assert_eq!(old.feast, new.feast); + assert_eq!(old.rank, Some(Rank::Great)); + assert_eq!(new.rank, Some(Rank::Great)); +} + +// ───────────────────────── the Orthodox fast ────────────────────────────── + +#[test] +fn great_fast_weekdays_are_xerophagy_and_weekends_wine_and_oil() { + assert_eq!(at("2026-02-25", "new").fast, Level::Xerophagy); + assert_eq!(at("2026-02-28", "new").fast, Level::WineOil); + assert_eq!(at("2026-03-01", "new").fast, Level::WineOil); +} + +#[test] +fn the_two_strictest_days_of_the_year() { + assert_eq!(at("2026-02-23", "new").fast, Level::Strict); + assert_eq!(at("2026-04-10", "new").fast, Level::Strict); + // The one Saturday kept without oil. + assert_eq!(at("2026-04-11", "new").fast, Level::Xerophagy); +} + +#[test] +fn annunciation_and_palm_sunday_admit_fish_inside_the_great_fast() { + let a = at("2026-03-25", "new"); + assert_eq!(a.season, Some(Season::GreatFast)); + assert_eq!(a.fast, Level::Fish); + assert_eq!(at("2026-04-05", "new").fast, Level::Fish); +} + +#[test] +fn the_annunciation_is_never_lost_to_great_friday() { + for (when, j) in [ + ("2016-03-25", "western"), ("2005-03-25", "western"), + ("2029-04-07", "rocor"), ("2018-04-07", "rocor"), + ] { + let day = at(when, j); + assert_eq!(day.church_date, "25 March", "{when}"); + assert!(day.feast.as_deref().unwrap_or("").contains("Annunciation"), "{when}"); + assert_eq!(day.rank, Some(Rank::Great), "{when}"); + assert_eq!(day.fast, Level::WineOil, "{when}"); + } +} + +#[test] +fn kyriopascha_and_the_annunciation_in_bright_week_stay_fast_free() { + assert_eq!(at("2035-03-25", "western").fast, Level::None); + assert_eq!(at("2075-04-07", "rocor").fast, Level::None); +} + +#[test] +fn palm_sunday_and_lazarus_saturday_are_still_the_fast() { + for j in ["ruthenian", "ugcc", "melkite", "goarch", "rocor"] { + for year in [2025, 2026, 2027] { + let jur = resolve(Some(j)).unwrap(); + let (mv, _) = year_of(year, jur); + for when in [mv.lazarus_saturday, mv.palm_sunday] { + let day = describe(when, jur); + assert!(day.season.is_some(), "{j} {when}: no season"); + assert_ne!(day.fast, Level::None, "{j} {when}"); + assert!(!day.reason.is_empty(), "{j} {when}: no reason"); + } + } + } +} + +#[test] +fn a_ranked_saint_relaxes_a_weekday_of_a_fasting_season() { + // The Nativity of the Forerunner, a great feast inside the Apostles' Fast. + assert_eq!(at("2020-06-24", "goarch").fast, Level::Fish); + // The Forty Martyrs of Sebaste, the textbook Lenten relaxation. + assert_eq!(at("2022-03-09", "goarch").fast, Level::WineOil); + // St Nicholas, a vigil saint on a Wednesday of the Nativity Fast. + assert_eq!(at("2023-12-06", "goarch").fast, Level::WineOil); +} + +#[test] +fn no_saint_brings_fish_into_the_great_fast_or_lifts_great_week() { + for day in week(d("2022-03-07"), 40, resolve(Some("goarch")).unwrap()) { + if day.church_date == "25 March" { + continue; + } + if day.season == Some(Season::GreatFast) { + assert!(day.fast >= Level::Strict, "{}", day.date); + assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast); + } + if day.season == Some(Season::GreatWeek) { + assert!(day.fast <= Level::WineOil, "{} got {:?}", day.date, day.fast); + } + } +} + +#[test] +fn the_eves_relax_when_they_fall_on_a_weekend() { + assert_eq!(at("2022-12-24", "goarch").fast, Level::WineOil); + assert_eq!(at("2023-12-24", "goarch").fast, Level::WineOil); + assert_eq!(at("2025-01-05", "goarch").fast, Level::WineOil); + // A weekday eve is still xerophagy. + assert_eq!(at("2024-12-24", "goarch").fast, Level::Xerophagy); +} + +#[test] +fn cheesefare_week_keeps_dairy_on_wednesday_and_friday() { + let day = at("2026-02-18", "new"); + assert_eq!(day.season, Some(Season::CheesefareWeek)); + assert_eq!(day.fast, Level::Dairy); +} + +#[test] +fn the_fast_free_stretches() { + assert_eq!(at("2026-04-17", "new").fast, Level::None); // Bright Friday + assert_eq!(at("2026-01-02", "new").fast, Level::None); // Christmastide + assert_eq!(at("2026-06-03", "new").fast, Level::None); // after Pentecost + // Fast-free precisely because the Pharisee boasted of fasting twice a week. + assert_eq!(at("2026-02-06", "new").fast, Level::None); +} + +#[test] +fn nativity_fast_tightens_for_its_last_five_days() { + assert_eq!(at("2026-12-12", "new").fast, Level::Fish); + assert_eq!(at("2026-12-19", "new").fast, Level::Fish); + assert_eq!(at("2026-12-22", "new").fast, Level::WineOil); + assert_eq!(at("2026-12-24", "new").fast, Level::Xerophagy); +} + +#[test] +fn ordinary_wednesday_and_friday_are_xerophagy() { + let day = at("2026-09-04", "new"); + assert!(day.season.is_none()); + assert!(day.rank.is_none()); + assert_eq!(day.fast, Level::Xerophagy); +} + +#[test] +fn a_polyeleos_saint_lifts_an_ordinary_friday_to_wine_and_oil() { + let day = at("2026-10-09", "new"); + assert_eq!(day.weekday, Weekday::Friday); + assert_eq!(day.rank, Some(Rank::Polyeleos)); + assert_eq!(day.fast, Level::WineOil); +} + +#[test] +fn the_apostles_fast_is_empty_when_pascha_is_late() { + let goarch: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("goarch")).unwrap()) + .into_iter().filter(|x| x.season == Some(Season::Apostles)).collect(); + let rocor: Vec<_> = week(d("2024-05-01"), 120, resolve(Some("rocor")).unwrap()) + .into_iter().filter(|x| x.season == Some(Season::Apostles)).collect(); + assert!(goarch.is_empty(), "goarch 2024 should have no Apostles' Fast"); + assert!(!rocor.is_empty(), "rocor 2024 should still have one"); +} + +#[test] +fn the_orthodox_rule_never_reports_an_obligation() { + for day in week(d("2026-01-01"), 365, resolve(Some("new")).unwrap()) { + assert_ne!(day.obligation, Some(Obligation::Obligatory), "{}", day.date); + } +} + +// ──────────────────── Byzantine Catholic particular law ─────────────────── + +#[test] +fn ruthenian_pascha_is_the_gregorian_one() { + assert_eq!(at("2026-06-01", "ruthenian").pascha.iso(), "2026-04-05"); +} + +#[test] +fn ruthenian_clean_monday_is_16_february_2026() { + assert!(at("2026-02-16", "ruthenian").reason.starts_with("the first day")); +} + +#[test] +fn only_the_great_fast_binds_under_ruthenian_law() { + let obligatory: Vec<_> = week(d("2026-01-01"), 365, resolve(Some("ruthenian")).unwrap()) + .into_iter().filter(|x| x.is_obligatory()).collect(); + let dates: Vec = obligatory.iter().map(|x| x.date.iso()).collect(); + assert!(dates.contains(&"2026-02-16".to_string())); + assert!(dates.contains(&"2026-04-03".to_string())); + for day in &obligatory { + assert!( + matches!(day.season, Some(Season::GreatFast) | Some(Season::GreatWeek)), + "{} binds outside the Fast", day.date + ); + } +} + +#[test] +fn the_voluntary_seasons_never_report_as_binding() { + for when in ["2026-11-20", "2026-08-05"] { + assert_eq!(at(when, "ruthenian").obligation, Some(Obligation::Customary)); + } +} + +#[test] +fn christmas_on_a_friday_does_not_ask_you_to_abstain() { + let day = at("2026-12-25", "ruthenian"); + assert_eq!(day.weekday, Weekday::Friday); + assert_eq!(day.fast, Level::None); +} + +#[test] +fn ruthenian_strict_days_bind_strictly_and_the_rest_bind_to_meat() { + assert_eq!(at("2026-02-16", "ruthenian").binds, Level::WineOil); + assert_eq!(at("2026-04-03", "ruthenian").binds, Level::WineOil); + assert_eq!(at("2026-02-18", "ruthenian").binds, Level::Dairy); + assert_eq!(at("2026-10-09", "ruthenian").binds, Level::None); +} + +// ──────────────────────────── the menologion ────────────────────────────── + +#[test] +fn every_day_of_a_leap_year_has_a_commemoration() { + for day in week(d("2024-01-01"), 366, resolve(Some("new")).unwrap()) { + assert!(!day.commemorations.is_empty(), "{}", day.date); + } +} + +#[test] +fn the_great_feasts_are_all_present() { + for (month, day) in [(9, 8), (9, 14), (11, 21), (12, 25), (1, 6), (2, 2), (3, 25), (8, 6), (8, 15)] { + let x = describe(Date::from_ymd(2026, month, day).unwrap(), resolve(Some("goarch")).unwrap()); + assert_eq!(x.rank, Some(Rank::Great), "{month}-{day}"); + } + for when in ["2026-04-05", "2026-05-21", "2026-05-31"] { + assert_eq!(at(when, "goarch").rank, Some(Rank::Great), "{when}"); + } +} + +#[test] +fn a_movable_feast_outranks_the_fixed_calendar() { + assert!(at("2026-04-12", "new").feast.as_deref().unwrap_or("").contains("Pascha")); +} + +// ──────────────────────────────── output ────────────────────────────────── + +#[test] +fn ics_content_lines_stay_within_seventy_five_octets() { + for j in ["goarch", "rocor", "ruthenian"] { + let days = week(d("2026-01-01"), 365, resolve(Some(j)).unwrap()); + let text = to_ics(&days, "Typikon", "20260101T000000Z"); + for line in text.split("\r\n") { + assert!(line.len() <= 75, "{j}: {} octets: {line}", line.len()); + } + } +} + +#[test] +fn ics_is_well_formed() { + let days = week(d("2026-04-10"), 7, resolve(Some("new")).unwrap()); + let text = to_ics(&days, "Typikon", "20260101T000000Z"); + assert!(text.starts_with("BEGIN:VCALENDAR")); + assert!(text.trim_end().ends_with("END:VCALENDAR")); + assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count()); +} + +// ─────────────────────────── every jurisdiction ─────────────────────────── + +#[test] +fn every_jurisdiction_holds_together_for_twenty_years() { + for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) { + for day in week(d("2020-01-01"), 366 * 21 / 4, jur) { + assert!(!day.commemorations.is_empty(), "{} {}", jur.key, day.date); + assert!(!day.title().is_empty(), "{} {}", jur.key, day.date); + // `binds` may never be stricter than the level it sits under. + assert!(day.binds >= day.fast, "{} {}: binds {:?} > fast {:?}", + jur.key, day.date, day.binds, day.fast); + } + } +} + +#[test] +fn a_calendar_name_resolves_without_naming_a_church() { + assert_eq!(resolve(Some("old")).unwrap().menologion, MenologionKind::Julian); + assert_eq!(resolve(Some("new")).unwrap().menologion, MenologionKind::RevisedJulian); + assert_eq!(resolve(Some("western")).unwrap().paschalion, Paschalion::Gregorian); + assert_eq!(resolve(None).unwrap().key, "new"); + assert!(resolve(Some("atlantis")).is_err()); + assert_eq!(resolve(Some("rocor")).unwrap().calendar(), Calendar::Old); +} + +// ─────────────────────── the levels are an ordered set ──────────────────── + +#[test] +fn a_feast_can_only_relax_a_fast_never_tighten_it() { + // Strictest to laxest, so `<` means stricter. + assert!(Level::Strict < Level::Xerophagy); + assert!(Level::Xerophagy < Level::WineOil); + assert!(Level::Dairy < Level::None); + // Relaxing to fish inside the Great Fast stops at wine and oil. + assert_eq!(Level::Xerophagy.relaxed_to(Level::Fish, Level::WineOil), Level::WineOil); + // A cap stricter than the day never tightens the day. + assert_eq!(Level::Fish.relaxed_to(Level::WineOil, Level::WineOil), Level::Fish); + // No rank means no change. + assert_eq!(Level::Xerophagy.relaxed_to(Level::Xerophagy, Level::Fish), Level::Xerophagy); +} diff --git a/typikon/__init__.py b/typikon/__init__.py deleted file mode 100644 index 09677de..0000000 --- a/typikon/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""typikon — the Orthodox liturgical day, computed rather than fetched. - - >>> import typikon - >>> d = typikon.today("rocor") - >>> d.church_date, d.fast, d.title - -Everything is derived from the paschalion and the fixed calendar, so it needs -no network, cannot go stale, and answers for any year you ask about. -""" - -import datetime as dt - -from .day import Day, describe, week, year_of -from .fasting import (DAIRY, FISH, LEVELS, NONE, STRICT, WINE_OIL, XEROPHAGY, - Fast) -from .jurisdictions import CALENDARS, JURISDICTIONS, Jurisdiction, resolve -from .paschalion import gregorian_pascha, julian_pascha, movable_cycle, pascha - -__version__ = "0.1.0" - -__all__ = [ - "Day", "Fast", "Jurisdiction", - "describe", "today", "week", "year_of", "resolve", - "pascha", "julian_pascha", "gregorian_pascha", "movable_cycle", - "JURISDICTIONS", "CALENDARS", "LEVELS", - "STRICT", "XEROPHAGY", "WINE_OIL", "FISH", "DAIRY", "NONE", -] - - -def today(jurisdiction=None): - return describe(dt.date.today(), jurisdiction) diff --git a/typikon/calendars.py b/typikon/calendars.py deleted file mode 100644 index 5981d42..0000000 --- a/typikon/calendars.py +++ /dev/null @@ -1,125 +0,0 @@ -"""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 that 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. So -the fixed calendar is stored once, indexed by ecclesiastical date, and a -jurisdiction decides how a civil date maps onto it. - -The drift is computed, never hardcoded, so nothing here breaks in 2100. -""" - -import datetime as dt - -__all__ = [ - "gregorian_to_jdn", "julian_to_jdn", "jdn_to_gregorian", "jdn_to_julian", - "to_julian", "from_julian", "julian_offset", -] - - -def gregorian_to_jdn(year, month, day): - """Julian Day Number for a proleptic Gregorian date.""" - a = (14 - month) // 12 - y = year + 4800 - a - m = month + 12 * a - 3 - return (day + (153 * m + 2) // 5 + 365 * y - + y // 4 - y // 100 + y // 400 - 32045) - - -def julian_to_jdn(year, month, day): - """Julian Day Number for a proleptic Julian date.""" - a = (14 - month) // 12 - y = year + 4800 - a - m = month + 12 * a - 3 - return day + (153 * m + 2) // 5 + 365 * y + y // 4 - 32083 - - -def jdn_to_gregorian(jdn): - a = jdn + 32044 - b, c = divmod(a, 146097) - d = (4 * c + 3) // 146097 - e = c - (146097 * d) // 4 - f = (4 * e + 3) // 1461 - g = e - (1461 * f) // 4 - h = (5 * g + 2) // 153 - day = g - (153 * h + 2) // 5 + 1 - month = h + 3 - 12 * (h // 10) - year = 400 * b + 100 * d + f - 4800 + h // 10 - return year, month, day - - -def jdn_to_julian(jdn): - c = jdn + 32082 - d = (4 * c + 3) // 1461 - e = c - (1461 * d) // 4 - m = (5 * e + 2) // 153 - day = e - (153 * m + 2) // 5 + 1 - month = m + 3 - 12 * (m // 10) - year = d - 4800 + m // 10 - return year, month, day - - -def to_julian(date): - """The Julian calendar date of a civil (Gregorian) date. - - Returns a plain (year, month, day) triple rather than a ``date``, because - 29 February in the Julian calendar is a real day that ``datetime`` will not - hold in a Gregorian common year. - """ - return jdn_to_julian(gregorian_to_jdn(date.year, date.month, date.day)) - - -def from_julian(year, month, day): - """The civil (Gregorian) date of a Julian calendar date.""" - y, m, d = jdn_to_gregorian(julian_to_jdn(year, month, day)) - return dt.date(y, m, d) - - -def julian_offset(date): - """How many days the Julian reckoning currently lags the Gregorian one. - - Thirteen for our lifetimes; fourteen from 15 March 2100, the day after a - Julian 29 February the Gregorian calendar does not have. Computed rather - than assumed, so nothing here breaks at the boundary. - """ - jy, jm, jd = to_julian(date) - return (gregorian_to_jdn(date.year, date.month, date.day) - - gregorian_to_jdn(jy, jm, jd)) - - -def fixed_of_civil(date, menologion): - """The ecclesiastical (year, month, day) a civil date is reckoned as. - - On the Revised Julian menologion this is the civil date itself — the two - agree for every date until 2800, so the distinction costs nothing and stops - mattering only long after anyone reading this. - """ - if menologion == "julian": - return to_julian(date) - return (date.year, date.month, date.day) - - -def civil_of_fixed(year, month, day, menologion): - """The civil date of an ecclesiastical date in a given ecclesiastical year.""" - if menologion == "julian": - return from_julian(year, month, day) - return dt.date(year, month, day) - - -def fixed_between(md, start, end): - """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. - """ - if start <= end: - return start <= md <= end - return md >= start or md <= end diff --git a/typikon/cli.py b/typikon/cli.py deleted file mode 100644 index 18b5bb9..0000000 --- a/typikon/cli.py +++ /dev/null @@ -1,237 +0,0 @@ -"""The command line.""" - -import argparse -import datetime as dt -import shutil -import sys - -from . import config -from .day import describe, week, year_of -from .export import to_ics, to_json -from .fasting import OBLIGATORY -from .jurisdictions import CALENDARS, JURISDICTIONS, resolve -from .rules import get as get_rule - -__version__ = "0.1.0" - - -def _list_jurisdictions(): - print("\n Calendars — a reckoning without naming a church:\n") - for j in CALENDARS.values(): - print(f" {j.key:20} {j.name}") - for line in _wrap(j.note, 56): - print(f" {'':20} {line}") - print("\n Jurisdictions:\n") - width = max(len(k) for k in JURISDICTIONS) - for j in JURISDICTIONS.values(): - cal = {"old": "old calendar", "new": "new calendar", - "western": "gregorian"}[j.calendar] - print(f" {j.key:{width}} {j.name}") - print(f" {'':{width}} {cal}; {j.rule} fasting rule") - if j.note: - for line in _wrap(j.note, 66): - print(f" {'':{width}} {line}") - print() - - -def _elide(text, width): - """Cut on a word boundary and say so, rather than stopping mid-word.""" - if len(text) <= width: - return text - cut = text[:width - 1] - if " " in cut[width // 2:]: - cut = cut[:cut.rstrip().rfind(" ")] - return cut.rstrip(" ,;") + "\u2026" - - -def _wrap(text, width): - words, line, out = text.split(), "", [] - for w in words: - if len(line) + len(w) + 1 > width: - out.append(line) - line = w - else: - line = f"{line} {w}".strip() - if line: - out.append(line) - return out - - -def _print_days(days, jur, rule, source, verbose): - if len(days) == 1: - d = days[0] - head = f" {d.weekday} {d.date.strftime('%-d %B %Y')}" - if d.church_date != d.date.strftime("%-d %B"): - head += f" — {d.church_date} in the church calendar" - print(f"\n{head}") - print(f" {jur.name} · {rule['title']}" - + (f" (from {source})" if source != "flag" else "")) - print() - if d.feast: - print(f" {d.emoji} {d.feast}") - else: - print(f" {d.emoji} {d.title}") - if d.cycle: - print(f" {d.cycle}") - print() - mark = " — obligatory" if d.obligation == OBLIGATORY else "" - print(f" {d.fast_label}{mark}") - if d.reason: - print(f" {d.reason}") - if d.season: - print(f" {d.season}") - rest = [c for c in d.commemorations if c != d.title] - if rest: - print() - for c in rest: - print(f" · {c}") - if verbose: - print() - print(f" Pascha {d.pascha} · {d.days_to_pascha} days") - print() - return - - print(f"\n {jur.name} · {rule['title']}\n") - # The glyph is two columns wide in a terminal and one character to Python, - # which is why the arithmetic below looks off by one. - fixed = 2 + 2 + 10 + 1 + 1 + 32 - room = max(24, shutil.get_terminal_size((100, 24)).columns - fixed - 1) - for d in days: - mark = "*" if d.obligation == OBLIGATORY else " " - when = d.date.strftime("%a %-d %b") - print(f" {d.emoji} {when:>10}{mark} {d.fast_label:<32}{_elide(d.title, room)}") - if any(d.obligation == OBLIGATORY for d in days): - print("\n * obligatory. Everything unmarked is customary and does not bind.") - print() - - -def main(argv=None): - p = argparse.ArgumentParser( - prog="typikon", - description="The Orthodox liturgical day — feasts, seasons and the fast.") - p.add_argument("-j", "--jurisdiction", "-c", "--calendar", dest="jurisdiction", - help="a church or a reckoning; see --list") - p.add_argument("-d", "--date", help="YYYY-MM-DD (default: today)") - p.add_argument("-n", "--days", type=int, default=1, - help="how many days from the start date, inclusive") - p.add_argument("--json", action="store_true", help="machine-readable output") - p.add_argument("--ics", action="store_true", help="an iCalendar feed") - p.add_argument("--indent", type=int, default=None, help="indent JSON output") - p.add_argument("--year", type=int, help="print a year's key dates and exit") - p.add_argument("--list", action="store_true", - help="list every jurisdiction and calendar") - p.add_argument("--set-default", metavar="JURISDICTION", - help="remember a jurisdiction and exit") - p.add_argument("--update-menaion", metavar="NAME", - help="fetch a supplementary commemoration set (see --list-menaia)") - p.add_argument("--list-menaia", action="store_true", - help="list the supplementary sets that can be fetched") - p.add_argument("-v", "--verbose", action="store_true") - p.add_argument("--version", action="version", version=f"typikon {__version__}") - args = p.parse_args(argv) - - if args.list: - _list_jurisdictions() - return 0 - - if args.list_menaia: - from .menaia import SOURCES, target_dir - print("\n Supplementary commemorations, fetched on request:\n") - for key, src in SOURCES.items(): - print(f" {key:14} {src['title']}") - print(f" {'':14} {src['url']}") - for line in _wrap(src["note"], 62): - print(f" {'':14} {line}") - print() - print(f" They are written to {target_dir()}\n") - return 0 - - if args.update_menaion: - from .menaia import update - try: - result = update(args.update_menaion) - except KeyError as exc: - print(f"typikon: {exc.args[0]}", file=sys.stderr) - return 2 - except Exception as exc: - print(f"typikon: could not fetch: {exc}", file=sys.stderr) - return 1 - print(f" {result['days']} days written to {result['path']}") - print(f" Source: {result['source']['title']}") - if result["corrections"]: - print(f" source typos corrected: {', '.join(result['corrections'])}") - if result["missing"]: - print(f" MISSING: {', '.join(result['missing'])}") - return 1 - return 0 - - if args.set_default: - try: - jur = resolve(args.set_default) - except KeyError: - print(f"typikon: unknown jurisdiction {args.set_default!r};" - " try --list", file=sys.stderr) - return 2 - where = config.write(jur.key) - print(f" {jur.name} — written to {where}") - return 0 - - if args.jurisdiction: - name, source = args.jurisdiction, "flag" - else: - name, source = config.default_jurisdiction() - - try: - jur = resolve(name) - except KeyError: - print(f"typikon: unknown jurisdiction {name!r}; try --list", file=sys.stderr) - return 2 - - if args.year is not None: - if not 1 <= args.year <= 9999: - print(f"typikon: {args.year} is not a year this calendar reaches", - file=sys.stderr) - return 2 - mv = year_of(args.year, jur) - print(f"\n {args.year} · {jur.name}\n") - for k in ("publican_pharisee", "meatfare", "cheesefare", "clean_monday", - "great_fast_end", "lazarus_saturday", "palm_sunday", - "great_friday", "pascha", "ascension", "pentecost", - "all_saints", "apostles_start", "apostles_end", - "dormition", "nativity", "theophany"): - d = mv[k] - print(f" {k.replace('_', ' '):20} {d.isoformat()} {d.strftime('%a')}") - print() - return 0 - - if args.date: - try: - start = dt.date.fromisoformat(args.date) - except ValueError: - print(f"typikon: {args.date!r} is not a date \u2014 expected YYYY-MM-DD", - file=sys.stderr) - return 2 - else: - start = dt.date.today() - days = week(start, args.days, jur) - - if args.json: - print(to_json(days, args.indent)) - return 0 - if args.ics: - sys.stdout.write(to_ics(days, name=f"{jur.name} — Typikon")) - return 0 - - _print_days(days, jur, get_rule(jur.rule), source, args.verbose) - - # The first run should say what it assumed, and then stop saying it. - if source == "default": - where = config.write(jur.key) - print(f" Reckoning by the {jur.name.lower()}, written to {where}.") - print(" Change it with `typikon --set-default `," - " or see `typikon --list`.\n") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/typikon/config.py b/typikon/config.py deleted file mode 100644 index 8d1718e..0000000 --- a/typikon/config.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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. -""" - -import os - -try: - import tomllib -except ModuleNotFoundError: # Python < 3.11 - tomllib = None - -__all__ = ["path", "read", "write", "default_jurisdiction"] - -ENV_VAR = "TYPIKON_JURISDICTION" - - -def path(): - base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") - return os.path.join(base, "typikon", "config.toml") - - -def read(): - p = path() - if not os.path.exists(p) or tomllib is None: - return {} - try: - with open(p, "rb") as fh: - return tomllib.load(fh) - except (OSError, ValueError): - return {} - - -def write(jurisdiction): - p = path() - os.makedirs(os.path.dirname(p), exist_ok=True) - with open(p, "w", encoding="utf-8") as fh: - fh.write("# Which reckoning typikon uses when no --jurisdiction is given.\n" - "# Run `typikon --list` to see every accepted value.\n" - f'jurisdiction = "{jurisdiction}"\n') - return p - - -def default_jurisdiction(): - """The environment beats the config file; both beat the built-in default.""" - env = os.environ.get(ENV_VAR) - if env: - return env, "environment" - conf = read().get("jurisdiction") - if conf: - return conf, "config" - return "new", "default" diff --git a/typikon/day.py b/typikon/day.py deleted file mode 100644 index 8e3f11e..0000000 --- a/typikon/day.py +++ /dev/null @@ -1,193 +0,0 @@ -"""A single day, fully reckoned. - -``Day`` is what everything else in the library 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. -""" - -import datetime as dt -from dataclasses import dataclass, field - -from . import menologion as _menologion -from . import rules as _rules -from .calendars import civil_of_fixed, fixed_of_civil, julian_offset -from .fasting import NONE, OBLIGATORY -from .jurisdictions import resolve -from .paschalion import movable_cycle -from .seasons import season_for - -__all__ = ["Day", "describe", "week", "year_of"] - - -@dataclass -class _Context: - """What a fasting rule is handed. Deliberately small.""" - civil: dt.date - fixed: tuple # ecclesiastical (month, day) - fixed_year: int - weekday: int # 0 = Monday - mv: dict - season: str - feast: str - rank: str - menologion_kind: str - - def civil_of_fixed(self, month, day): - """The civil date of an ecclesiastical date in this day's church year.""" - return civil_of_fixed(self.fixed_year, month, day, self.menologion_kind) - - -@dataclass -class Day: - date: dt.date - weekday: str - jurisdiction: str - calendar: str - church_date: str # the ecclesiastical date, ISO-ish - season: str = None - cycle: str = None # forefeast / afterfeast / leave-taking - feast: str = None - rank: str = None - commemorations: list = field(default_factory=list) - fast: str = NONE - fast_label: str = "" - emoji: str = "" - reason: str = "" - obligation: str = None - binds: str = NONE - pascha: dt.date = None - days_to_pascha: int = 0 - julian_offset: int = 0 - - @property - def is_obligatory(self): - return self.obligation == OBLIGATORY - - @property - def title(self): - """What to put on one line: the feast if there is one, else the day's - principal commemoration.""" - if self.feast: - return self.feast - return self.commemorations[0] if self.commemorations else "" - - def to_dict(self): - out = { - "date": self.date.isoformat(), - "weekday": self.weekday, - "jurisdiction": self.jurisdiction, - "calendar": self.calendar, - "churchDate": self.church_date, - "season": self.season, - "cycle": self.cycle, - "feast": self.feast, - "rank": self.rank, - "title": self.title, - "commemorations": self.commemorations, - "fast": self.fast, - "fastLabel": self.fast_label, - "emoji": self.emoji, - "reason": self.reason, - "obligation": self.obligation, - "binds": self.binds, - "pascha": self.pascha.isoformat() if self.pascha else None, - "daysToPascha": self.days_to_pascha, - "julianOffset": self.julian_offset, - } - return out - - -# How the degrees of solemnity order against each other, for the days when a -# movable feast and a fixed one fall together. -_RANKS = {"great": 3, "vigil": 2, "polyeleos": 1} - -_WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", - "Saturday", "Sunday") -_MONTHS = ("January", "February", "March", "April", "May", "June", "July", - "August", "September", "October", "November", "December") - - -def describe(date, jurisdiction=None): - """Reckon one civil date in one jurisdiction.""" - jur = jurisdiction if hasattr(jurisdiction, "paschalion") else resolve(jurisdiction) - rule = _rules.get(jur.rule) - men = _menologion.for_jurisdiction(jur) - - fy, fm, fd = fixed_of_civil(date, jur.menologion) - mv = movable_cycle(date.year, jur.paschalion) - apostles_end = civil_of_fixed(date.year, 6, 28, jur.menologion) - season = season_for(date, (fm, fd), mv, apostles_end) - - entry = men.get(fm, fd) - commemorations = list(entry.get("commemorations", [])) - rank = entry.get("rank") - cycle = entry.get("cycle") - - # 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. - fixed_feast = commemorations[0] if rank in ("great", "vigil") else None - mov_title, mov_rank = _menologion.movable_feast_for(date, mv) - - if mov_title and fixed_feast and _RANKS.get(rank, 0) > _RANKS.get(mov_rank, 0): - feast = fixed_feast - if mov_title not in commemorations: - commemorations.insert(1, mov_title) - elif mov_title: - feast = mov_title - if _RANKS.get(mov_rank, 0) >= _RANKS.get(rank, 0): - rank = mov_rank - if fixed_feast and fixed_feast not in commemorations: - commemorations.insert(1, fixed_feast) - else: - feast = fixed_feast - - ctx = _Context( - civil=date, fixed=(fm, fd), fixed_year=fy, weekday=date.weekday(), - mv=mv, season=season, feast=feast or (commemorations[0] if commemorations else ""), - rank=rank, menologion_kind=jur.menologion, - ) - fast = rule["fast_for"](ctx) - label, emoji = rule["vocabulary"].get(fast.level, (fast.level, "")) - - return Day( - date=date, - weekday=_WEEKDAYS[date.weekday()], - jurisdiction=jur.key, - calendar=jur.calendar, - church_date=f"{fd} {_MONTHS[fm - 1]}", - season=season, - cycle=cycle, - feast=feast, - rank=rank, - commemorations=commemorations, - fast=fast.level, - fast_label=label, - emoji=emoji, - reason=fast.reason, - obligation=fast.obligation, - binds=fast.binds, - pascha=mv["pascha"], - days_to_pascha=(mv["pascha"] - date).days, - julian_offset=julian_offset(date), - ) - - -def week(start, days=7, jurisdiction=None): - """`days` consecutive days from `start`.""" - return [describe(start + dt.timedelta(n), jurisdiction) - for n in range(max(1, days))] - - -def year_of(year, jurisdiction=None): - """Every key date of a year, for checking against a published calendar.""" - jur = jurisdiction if hasattr(jurisdiction, "paschalion") else resolve(jurisdiction) - mv = movable_cycle(year, jur.paschalion) - mv["apostles_end"] = civil_of_fixed(year, 6, 28, jur.menologion) - mv["nativity"] = civil_of_fixed(year, 12, 25, jur.menologion) - mv["theophany"] = civil_of_fixed(year, 1, 6, jur.menologion) - mv["dormition"] = civil_of_fixed(year, 8, 15, jur.menologion) - return mv diff --git a/typikon/export/__init__.py b/typikon/export/__init__.py deleted file mode 100644 index 1c51295..0000000 --- a/typikon/export/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .ics import to_ics -from .jsonout import to_json - -__all__ = ["to_json", "to_ics"] diff --git a/typikon/export/ics.py b/typikon/export/ics.py deleted file mode 100644 index b0cdff3..0000000 --- a/typikon/export/ics.py +++ /dev/null @@ -1,104 +0,0 @@ -"""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, no fast — are left -out, because a subscribed calendar that marks all 365 days is one nobody keeps. -""" - -import datetime as dt - -__all__ = ["to_ics"] - -# RFC 5545: a content line SHOULD NOT be longer than 75 **octets**, excluding -# the line break. Counting characters instead put a line with four emoji eight -# octets over, and the em-dashes three each. -_FOLD = 75 - - -_BACKSLASH = chr(92) - -# Order matters: the backslash has to be doubled before anything else -# introduces one, or the escapes escape each other. -_ESCAPES = ( - (_BACKSLASH, _BACKSLASH * 2), - (";", _BACKSLASH + ";"), - (",", _BACKSLASH + ","), - ("\n", _BACKSLASH + "n"), -) - - -def _esc(text): - out = str(text) - for char, replacement in _ESCAPES: - out = out.replace(char, replacement) - return out - - -def _fold(line): - """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. - """ - out, cur, used = [], [], 0 - for ch in line: - n = len(ch.encode("utf-8")) - if used + n > _FOLD: - out.append("".join(cur)) - cur, used = [" "], 1 - cur.append(ch) - used += n - out.append("".join(cur)) - return "\r\n".join(out) - - -def to_ics(days, name="Typikon", stamp=None): - stamp = (stamp or dt.datetime.now(dt.timezone.utc)).strftime("%Y%m%dT%H%M%SZ") - lines = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - "PRODID:-//typikon//EN", - "CALSCALE:GREGORIAN", - "METHOD:PUBLISH", - f"X-WR-CALNAME:{_esc(name)}", - ] - - for d in days: - if not d.feast and d.fast == "none": - continue - start = d.date.strftime("%Y%m%d") - end = (d.date + dt.timedelta(1)).strftime("%Y%m%d") - summary = d.title or d.fast_label - if d.emoji: - summary = f"{d.emoji} {summary}" - - body = [] - if d.fast != "none": - body.append(f"Fast: {d.fast_label}" - + (f" ({d.reason})" if d.reason else "")) - if d.obligation == "obligatory": - body.append("Obligatory.") - if d.season: - body.append(f"Season: {d.season}") - if d.cycle: - body.append(d.cycle[0].upper() + d.cycle[1:]) - body.append(f"Church date: {d.church_date}") - for c in d.commemorations: - body.append(c) - - uid = f"{start}-{d.jurisdiction}@typikon" - lines += [ - "BEGIN:VEVENT", - f"UID:{uid}", - f"DTSTAMP:{stamp}", - f"DTSTART;VALUE=DATE:{start}", - f"DTEND;VALUE=DATE:{end}", - _fold(f"SUMMARY:{_esc(summary)}"), - _fold(f"DESCRIPTION:{_esc(chr(10).join(body))}"), - "TRANSP:TRANSPARENT", - "END:VEVENT", - ] - - lines.append("END:VCALENDAR") - return "\r\n".join(lines) + "\r\n" diff --git a/typikon/export/jsonout.py b/typikon/export/jsonout.py deleted file mode 100644 index e2f6266..0000000 --- a/typikon/export/jsonout.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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": [...]}``. -""" - -import json - -__all__ = ["to_json"] - - -def to_json(days, indent=None): - if not isinstance(days, (list, tuple)): - return json.dumps(days.to_dict(), ensure_ascii=False, indent=indent) - if len(days) == 1: - return json.dumps(days[0].to_dict(), ensure_ascii=False, indent=indent) - return json.dumps({"days": [d.to_dict() for d in days]}, - ensure_ascii=False, indent=indent) diff --git a/typikon/fasting.py b/typikon/fasting.py deleted file mode 100644 index f6c94a3..0000000 --- a/typikon/fasting.py +++ /dev/null @@ -1,82 +0,0 @@ -"""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. - - strict no food until evening - xerophagy no oil, no wine; no meat, fish, eggs or dairy - wine_oil wine and oil permitted; no meat, fish, eggs or dairy - fish fish, wine and oil permitted; no meat, eggs or dairy - dairy dairy, eggs and fish permitted; no meat - none no fast - -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 ``wine_oil`` (it does not regulate oil or wine), its -"abstinence" is ``dairy`` (meat alone is 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. Reporting the -binding minimum as the day's level makes the obligatory days look laxer than -the voluntary ones beside them — true of the obligation, false of the day. So -both are carried, and a caller that wants one must say which. -""" - -from dataclasses import dataclass - -__all__ = [ - "STRICT", "XEROPHAGY", "WINE_OIL", "FISH", "DAIRY", "NONE", - "LEVELS", "stricter", "strictest", "laxest", - "OBLIGATORY", "CUSTOMARY", "Fast", -] - -STRICT = "strict" -XEROPHAGY = "xerophagy" -WINE_OIL = "wine_oil" -FISH = "fish" -DAIRY = "dairy" -NONE = "none" - -# Strictest first. Index into this is the comparison. -LEVELS = (STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE) - -OBLIGATORY = "obligatory" -CUSTOMARY = "customary" # encouraged, traditional, not binding - - -def stricter(a, b): - """True if `a` is a stricter level than `b`.""" - return LEVELS.index(a) < LEVELS.index(b) - - -def strictest(*levels): - return min(levels, key=LEVELS.index) - - -def laxest(*levels): - return max(levels, key=LEVELS.index) - - -@dataclass(frozen=True) -class Fast: - """How a day is kept, and what about it binds. - - ``level`` is the day as the tradition keeps it. ``binds`` is what the - Church actually requires, which for most jurisdictions is nothing — it is - ``none`` unless a ruleset has particular law to point at. ``reason`` names - the rule that decided it, so the answer can be argued with. - """ - level: str - reason: str = "" - obligation: str = None # OBLIGATORY, CUSTOMARY, or None for a free day - binds: str = NONE # the binding minimum, where one exists - - @property - def is_fast(self): - return self.level != NONE diff --git a/typikon/jurisdictions.py b/typikon/jurisdictions.py deleted file mode 100644 index d5c1840..0000000 --- a/typikon/jurisdictions.py +++ /dev/null @@ -1,151 +0,0 @@ -"""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 — julian or gregorian - 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 -read them on the Revised Julian, which agrees with the Gregorian for every date -until 2800. So a new-calendar church keeps Christmas 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. - -Where a church is mid-transition or its parishes differ, the entry says so in -``note`` rather than pretending to a precision that does not exist. -""" - -from dataclasses import dataclass, field - -__all__ = ["Jurisdiction", "JURISDICTIONS", "CALENDARS", "resolve", "names"] - - -@dataclass(frozen=True) -class Jurisdiction: - key: str - name: str - paschalion: str # "julian" | "gregorian" - menologion: str # "julian" | "revised_julian" - rule: str = "orthodox" # which fasting discipline - menaia: tuple = () # extra commemoration sets to overlay - note: str = "" - - @property - def calendar(self): - """The short name people actually use for this combination.""" - if self.paschalion == "gregorian" and self.menologion != "julian": - return "western" - return "old" if self.menologion == "julian" else "new" - - -# The three combinations, for anyone who wants a reckoning without naming a -# church. `--calendar new` is the default and covers most English-speaking -# Orthodox parishes. -CALENDARS = { - "old": Jurisdiction( - "old", "Old Calendar", "julian", "julian", - note="Julian throughout — the reckoning of Moscow, Serbia, Georgia, " - "Jerusalem and the Holy Mountain."), - "new": Jurisdiction( - "new", "New Calendar", "julian", "revised_julian", - note="Revised Julian fixed feasts with the Julian Paschalion — " - "Constantinople, Greece, Antioch, Romania, Bulgaria, the OCA."), - "western": Jurisdiction( - "western", "Gregorian", "gregorian", "revised_julian", - note="Gregorian throughout, Pascha included. Kept by the Finnish " - "Orthodox Church and by most Byzantine Catholics."), -} - - -_ORTHODOX = [ - # ── old calendar - ("moscow", "Russian Orthodox Church", "julian", "julian", "orthodox", (), ""), - ("rocor", "Russian Orthodox Church Outside Russia", "julian", "julian", - "orthodox", (), ""), - ("serbian", "Serbian Orthodox Church", "julian", "julian", "orthodox", (), ""), - ("georgian", "Georgian Orthodox Church", "julian", "julian", "orthodox", (), ""), - ("jerusalem", "Patriarchate of Jerusalem", "julian", "julian", "orthodox", (), ""), - ("athos", "The Holy Mountain", "julian", "julian", "orthodox", (), - "Athonite practice is stricter than the printed Typikon at many points."), - ("polish", "Polish Orthodox Church", "julian", "julian", "orthodox", (), - "Returned to the Julian menologion in 2014 after decades on the Revised " - "Julian; some parishes kept the new calendar."), - ("macedonian", "Macedonian Orthodox Church", "julian", "julian", - "orthodox", (), ""), - - # ── new calendar - ("constantinople", "Ecumenical Patriarchate", "julian", "revised_julian", - "orthodox", (), ""), - ("goarch", "Greek Orthodox Archdiocese of America", "julian", - "revised_julian", "orthodox", (), ""), - ("greece", "Church of Greece", "julian", "revised_julian", "orthodox", (), ""), - ("antiochian", "Antiochian Orthodox Church", "julian", "revised_julian", - "orthodox", (), ""), - ("oca", "Orthodox Church in America", "julian", "revised_julian", - "orthodox", (), - "Most parishes keep the new calendar; a minority, chiefly of Carpatho-" - "Russian and Alaskan background, keep the old."), - ("romanian", "Romanian Orthodox Church", "julian", "revised_julian", - "orthodox", (), ""), - ("bulgarian", "Bulgarian Orthodox Church", "julian", "revised_julian", - "orthodox", (), ""), - ("cyprus", "Church of Cyprus", "julian", "revised_julian", "orthodox", (), ""), - ("albanian", "Orthodox Church of Albania", "julian", "revised_julian", - "orthodox", (), ""), - ("ukrainian", "Orthodox Church of Ukraine", "julian", "revised_julian", - "orthodox", (), - "Moved the fixed feasts to the Revised Julian calendar on 1 September " - "2023. The Julian Paschalion is retained."), - - # ── gregorian throughout - ("finnish", "Finnish Orthodox Church", "gregorian", "revised_julian", - "orthodox", (), - "The only Orthodox Church keeping the Gregorian Paschalion."), -] - -_EASTERN_CATHOLIC = [ - ("ruthenian", "Byzantine Catholic Metropolia of Pittsburgh", "gregorian", - "revised_julian", "ruthenian", ("ruthenian",), - "Gregorian throughout. Fasting is reported under Ruthenian particular " - "law, which binds far less than the Typikon prescribes."), - ("ugcc", "Ukrainian Greek Catholic Church", "julian", "revised_julian", - "ruthenian", ("ruthenian",), - "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."), - ("melkite", "Melkite Greek Catholic Church", "gregorian", "revised_julian", - "ruthenian", (), - "Practice is not uniform: parishes in the Middle East commonly keep the " - "Julian Paschalion for the sake of the local Orthodox."), - ("romanian-catholic", "Romanian Greek Catholic Church", "gregorian", - "revised_julian", "ruthenian", (), ""), -] - -JURISDICTIONS = {} -for _k, _n, _p, _m, _r, _mn, _note in _ORTHODOX + _EASTERN_CATHOLIC: - JURISDICTIONS[_k] = Jurisdiction(_k, _n, _p, _m, _r, _mn, _note) - - -def resolve(name): - """A jurisdiction key, a calendar name, or None for the default.""" - if name is None: - return CALENDARS["new"] - key = name.strip().lower() - if key in JURISDICTIONS: - return JURISDICTIONS[key] - if key in CALENDARS: - return CALENDARS[key] - raise KeyError(name) - - -def names(): - """Everything ``resolve`` accepts, calendars first.""" - return list(CALENDARS) + list(JURISDICTIONS) diff --git a/typikon/menaia.py b/typikon/menaia.py deleted file mode 100644 index 87c9a9b..0000000 --- a/typikon/menaia.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Supplementary commemorations, fetched rather than shipped. - -The menologion this library ships is the common Byzantine one, composed from -the tradition and given away (see data/LICENSE). 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 repository. ``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. - -Each source declares where it lives and how to read it. Adding one is a dict. -""" - -import datetime as dt -import json -import os -import re -import urllib.request - -__all__ = ["SOURCES", "update", "target_dir"] - -MONTHS = ("January February March April May June July August " - "September October November December").split() - -SOURCES = { - "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 package.", - }, -} - - -def target_dir(): - """Where a fetched set is written. - - The package directory if it is writable — a checkout usually is — and the - user's data directory otherwise, so an installed copy still works. - """ - packaged = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") - if os.access(packaged, os.W_OK): - return packaged - base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share") - path = os.path.join(base, "typikon") - os.makedirs(path, exist_ok=True) - return path - - -def _strip_html(html): - txt = re.sub(r"|", "", html, flags=re.S) - txt = re.sub(r"<(br|/p|/div|/li|/h\d)[^>]*>", "\n", txt, flags=re.I) - txt = re.sub(r"<[^>]+>", "\n", txt) - for a, b in ((" ", " "), ("&", "&"), ("’", "'"), - ("’", "'"), ("—", "—")): - txt = txt.replace(a, b) - return [x for x in (re.sub(r"\s+", " ", l).strip() for l in txt.split("\n")) if x] - - -def _parse_mci(html): - """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, taking 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. - """ - lines = _strip_html(html) - idx = {m: i + 1 for i, m in enumerate(MONTHS)} - dated = re.compile(r"^(" + "|".join(MONTHS) + r")\s+(\d{1,2})\s*(.*)$") - heading = re.compile(r"^(" + "|".join(MONTHS) + r")\.?$") - - # MCI writes the festal cycle inline, as a commemoration among the saints: - # "Pre-festive Day of the Theophany". It repeats for days at a time and is - # not a saint, so it becomes the day's cycle and leaves the list alone. - festal = re.compile(r"^(Pre-festive|Post-festive|Fore-feast|After-feast|" - r"Leave-taking)\b[ ,]*(?:Day\s+)?(?:of\s+)?(.*)$", re.I) - cycle_word = {"pre-festive": "forefeast", "fore-feast": "forefeast", - "post-festive": "afterfeast", "after-feast": "afterfeast", - "leave-taking": "leave-taking"} - - out, cur, buf, corrections = {}, None, [], [] - prev_month = prev_day = None - - def flush(): - if not (cur and buf): - return - items, cycle = [], None - for piece in buf: - for part in re.split(r"(?<=\.)\s+(?=[A-Z(])", piece): - part = part.strip(" .;") - if len(part) <= 3 or dated.match(part) or heading.match(part): - continue - m = festal.match(part) - if m: - if cycle is None: - word = cycle_word[m.group(1).lower()] - rest = m.group(2).strip(" .,") - cycle = f"{word} of {rest}" if rest else word - continue - items.append(part) - entry = {} - if items: - entry["commemorations"] = items - if cycle: - entry["cycle"] = cycle - if entry: - out[cur] = entry - - for line in lines: - if heading.match(line): - continue - m = dated.match(line) - if m: - flush() - month, day = idx[m.group(1)], int(m.group(2)) - if prev_month == month and day <= prev_day: - corrections.append( - f"{month:02d}-{day:02d} -> {month:02d}-{prev_day + 1:02d}") - day = prev_day + 1 - prev_month, prev_day = month, day - cur = f"{month:02d}-{day:02d}" - buf = [m.group(3)] if m.group(3).strip() else [] - elif cur: - buf.append(line) - flush() - return out, corrections - - -_PARSERS = {"ruthenian": _parse_mci} - - -def update(name): - """Fetch a supplementary set and write it beside the packaged menologion.""" - try: - source = SOURCES[name] - except KeyError: - raise KeyError(f"unknown menaion: {name!r}; " - f"try one of {', '.join(SOURCES)}") from None - - req = urllib.request.Request(source["url"], - headers={"User-Agent": "typikon"}) - with urllib.request.urlopen(req, timeout=30) as r: - html = r.read().decode("utf-8", "replace") - - entries, corrections = _PARSERS[name](html) - - expected = set() - for month in range(1, 13): - for day in range(1, 32): - try: - dt.date(2024, month, day) - expected.add(f"{month:02d}-{day:02d}") - except ValueError: - pass - missing = sorted(expected - set(entries)) - - # This is a complete calendar published by that church, so it stands in - # for the common commemorations rather than piling on top of them. - payload = {"_meta": {"mode": "replace", "source": source["url"], - "title": source["title"]}} - payload.update(entries) - - path = os.path.join(target_dir(), f"{name}.json") - with open(path, "w", encoding="utf-8") as fh: - json.dump(payload, fh, ensure_ascii=False, indent=1, sort_keys=True) - - return {"path": path, "days": len(entries), "corrections": corrections, - "missing": missing, "source": source} diff --git a/typikon/menologion.py b/typikon/menologion.py deleted file mode 100644 index a5668ab..0000000 --- a/typikon/menologion.py +++ /dev/null @@ -1,137 +0,0 @@ -"""The fixed calendar, and the movable feasts that outrank it. - -The menologion ships once and serves every jurisdiction, because it is indexed -by **ecclesiastical** date. A church on the old calendar and a church 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 — overlay the common one -rather than replacing it, so a jurisdiction can add without forking the file. -""" - -import json -import os - -__all__ = ["Menologion", "load", "MOVABLE_FEASTS", "movable_feast_for"] - -_DATA = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data") -_cache = {} - - -class Menologion: - """A fixed calendar, plus whatever a jurisdiction overlays on it. - - ``_meta`` is a reserved key, not a day. It carries ``mode``: - - add the set's commemorations join the common ones (the default, - right for a local set that adds a few of its own saints) - replace the set's commemorations stand in for them, because it is - itself a complete calendar published by that church - - Either way the common set's ``rank`` and ``cycle`` survive unless the - overlay gives its own, since a published list of saints usually carries - neither and the fasting rules need the rank. - """ - - def __init__(self, entries): - self.meta = entries.get("_meta", {}) - self.entries = {k: v for k, v in entries.items() if k != "_meta"} - - def get(self, month, day): - return self.entries.get(f"{month:02d}-{day:02d}", {}) - - def overlay(self, other): - replace = other.meta.get("mode") == "replace" - merged = {k: dict(v) for k, v in self.entries.items()} - for key, add in other.entries.items(): - if key not in merged: - merged[key] = dict(add) - continue - base = merged[key] - extra = list(add.get("commemorations", [])) - if replace: - base["commemorations"] = extra or base.get("commemorations", []) - else: - seen = list(base.get("commemorations", [])) - base["commemorations"] = seen + [c for c in extra if c not in seen] - # A supplementary set may state a rank or a cycle; it never erases one. - for field in ("rank", "cycle"): - if add.get(field): - base[field] = add[field] - return Menologion(merged) - - -def load(name="menologion"): - """Load a menologion by name from the packaged data directory.""" - if name in _cache: - return _cache[name] - path = os.path.join(_DATA, f"{name}.json") - if not os.path.exists(path): - raise FileNotFoundError(path) - with open(path, encoding="utf-8") as fh: - m = Menologion(json.load(fh)) - _cache[name] = m - return m - - -_overlaid = {} - - -def for_jurisdiction(jur): - """The common menologion with any supplementary sets overlaid. - - Cached per jurisdiction: the overlay copies all 366 days, and rebuilding it - for every date asked about made a year of a jurisdiction with a - supplementary set ten times slower than one without. - """ - key = (jur.key, jur.menaia) - if key in _overlaid: - return _overlaid[key] - m = load() - for extra in jur.menaia: - try: - m = m.overlay(load(extra)) - except FileNotFoundError: - # A supplementary set is optional; its absence is not an error. - pass - _overlaid[key] = m - return m - - -# ── the movable feasts, named by their offset from Pascha -# -# These outrank whatever the fixed calendar has for the day. The keys are -# entries in the movable cycle; the value is (title, rank). -MOVABLE_FEASTS = { - "pascha": ("The Radiant and Life-giving Resurrection of our Lord — Holy Pascha", "great"), - "palm_sunday": ("The Entry of our Lord into Jerusalem", "great"), - "ascension": ("The Ascension of our Lord, God and Saviour Jesus Christ", "great"), - "pentecost": ("Holy Pentecost — the Descent of the Holy Spirit", "great"), - "lazarus_saturday": ("Lazarus Saturday", "vigil"), - "great_thursday": ("Great and Holy Thursday", "vigil"), - "great_friday": ("Great and Holy Friday", "vigil"), - "holy_saturday": ("Great and Holy Saturday", "vigil"), - "thomas_sunday": ("Thomas Sunday — the Antipascha", "polyeleos"), - "myrrhbearers": ("The Sunday of the Holy Myrrh-bearing Women", "polyeleos"), - "midfeast": ("The Midfeast of Pentecost", "polyeleos"), - "all_saints": ("The Sunday of All Saints", "polyeleos"), - "publican_pharisee": ("The Sunday of the Publican and the Pharisee", None), - "prodigal_son": ("The Sunday of the Prodigal Son", None), - "meatfare": ("Meatfare Sunday — the Sunday of the Last Judgement", None), - "cheesefare": ("Cheesefare Sunday — the Expulsion of Adam, Forgiveness Sunday", None), - "orthodoxy_sunday": ("The Sunday of Orthodoxy", "polyeleos"), - "gregory_palamas": ("The Sunday of St Gregory Palamas", None), - "cross_veneration": ("The Sunday of the Veneration of the Cross", "polyeleos"), - "john_climacus": ("The Sunday of St John Climacus", None), - "mary_of_egypt": ("The Sunday of St Mary of Egypt", None), - "fathers_first_council": ("The Sunday of the Fathers of the First Council", None), - "clean_monday": ("Clean Monday — the first day of the Great Fast", None), -} - - -def movable_feast_for(civil, mv): - """(title, rank) if the day is a movable feast, else (None, None).""" - for key, (title, rank) in MOVABLE_FEASTS.items(): - if mv.get(key) == civil: - return title, rank - return None, None diff --git a/typikon/paschalion.py b/typikon/paschalion.py deleted file mode 100644 index 141d0c1..0000000 --- a/typikon/paschalion.py +++ /dev/null @@ -1,110 +0,0 @@ -"""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 Catholic churches. - -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. -""" - -import datetime as dt - -from .calendars import from_julian - -__all__ = ["julian_pascha", "gregorian_pascha", "pascha", "movable_cycle"] - - -def julian_pascha(year): - """Pascha by the Julian Paschalion, as a civil (Gregorian) date. - - Meeus' Julian algorithm. It yields a date in the Julian calendar, which is - then read onto the civil calendar — the step that is easy to forget and - that puts the answer thirteen days out when it is. - """ - a = year % 4 - b = year % 7 - c = year % 19 - d = (19 * c + 15) % 30 - e = (2 * a + 4 * b - d + 34) % 7 - month = (d + e + 114) // 31 - day = ((d + e + 114) % 31) + 1 - return from_julian(year, month, day) - - -def gregorian_pascha(year): - """Pascha by the Gregorian Paschalion — the Western computus.""" - a = year % 19 - b, c = divmod(year, 100) - d, e = divmod(b, 4) - g = (8 * b + 13) // 25 - h = (19 * a + b - d - g + 15) % 30 - i, k = divmod(c, 4) - ll = (32 + 2 * e + 2 * i - h - k) % 7 - m = (a + 11 * h + 19 * ll) // 433 - month = (h + ll - 7 * m + 90) // 25 - day = (h + ll - 7 * m + 33 * month + 19) % 32 - return dt.date(year, month, day) - - -def pascha(year, paschalion="julian"): - if paschalion == "julian": - return julian_pascha(year) - if paschalion == "gregorian": - return gregorian_pascha(year) - raise ValueError(f"unknown paschalion: {paschalion!r}") - - -def movable_cycle(year, paschalion="julian"): - """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. - """ - p = pascha(year, paschalion) - d = dt.timedelta - - return { - "pascha": p, - - # ── the Triodion, counted backwards - "publican_pharisee": p - d(70), - "prodigal_son": p - d(63), - "meatfare": p - d(56), # last day meat is eaten - "cheesefare": p - d(49), # Forgiveness Sunday, last day of dairy - "cheesefare_week_start": p - d(55), - "clean_monday": p - d(48), # the Great Fast begins - "orthodoxy_sunday": p - d(42), - "gregory_palamas": p - d(35), - "cross_veneration": p - d(28), - "john_climacus": p - d(21), - "mary_of_egypt": p - d(14), - "great_fast_end": p - d(9), # Friday before Lazarus Saturday - "lazarus_saturday": p - d(8), - "palm_sunday": p - d(7), - "great_week_start": p - d(6), - "great_thursday": p - d(3), - "great_friday": p - d(2), - "holy_saturday": p - d(1), - - # ── the Pentecostarion, counted forwards - "bright_week_end": p + d(6), - "thomas_sunday": p + d(7), - "myrrhbearers": p + d(14), - "midfeast": p + d(24), - "ascension": p + d(39), - "fathers_first_council": p + d(42), - "pentecost": p + d(49), - "pentecost_week_end": p + d(55), - "all_saints": p + d(56), - - # The Apostles' Fast runs from the Monday after All Saints to the eve - # of Ss Peter and Paul. A late Pascha can leave it with no days at all, - # which is correct rather than a bug — the fast is simply not kept that - # year. Its end is a fixed ecclesiastical date, so the jurisdiction - # resolves it; only the start belongs here. - "apostles_start": p + d(57), - } diff --git a/typikon/rules/__init__.py b/typikon/rules/__init__.py deleted file mode 100644 index efcb3da..0000000 --- a/typikon/rules/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Fasting disciplines. One module per tradition; ``get`` finds it by name.""" - -from . import orthodox, ruthenian - -_RULES = { - "orthodox": orthodox.RULE, - "ruthenian": ruthenian.RULE, -} - - -def get(name): - try: - return _RULES[name] - except KeyError: - raise KeyError(f"unknown fasting rule: {name!r}") from None - - -def names(): - return list(_RULES) diff --git a/typikon/rules/orthodox.py b/typikon/rules/orthodox.py deleted file mode 100644 index 27b7593..0000000 --- a/typikon/rules/orthodox.py +++ /dev/null @@ -1,205 +0,0 @@ -"""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. - -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 saint of vigil or polyeleos rank lifts the weekday discipline of a fast — to -wine and oil inside the Great Fast, as far as fish in the three lesser fasts. -That is why the printed calendars mark so many Wednesdays in the Nativity Fast -for wine and oil, and it is the rank in the menologion that decides it. - -A calendar cannot settle those. Your bishop and your spiritual father can. -""" - -import datetime as dt - -from ..fasting import (Fast, STRICT, XEROPHAGY, WINE_OIL, FISH, DAIRY, NONE, - CUSTOMARY, laxest, stricter, strictest) -from ..seasons import (GREAT_FAST, GREAT_WEEK, BRIGHT_WEEK, CHEESEFARE_WEEK, - APOSTLES, DORMITION, NATIVITY_FAST, PENTECOST_WEEK, - CHRISTMASTIDE) - -MON, TUE, WED, THU, FRI, SAT, SUN = range(7) - -VOCABULARY = { - STRICT: ("no food until evening", "⛔"), - XEROPHAGY: ("xerophagy — no oil or wine", "\U0001f96c"), - WINE_OIL: ("wine and oil permitted", "\U0001fad2"), - FISH: ("fish, wine and oil", "\U0001f41f"), - DAIRY: ("dairy and eggs; no meat", "\U0001f9c0"), - NONE: ("no fast", "\U0001f356"), -} - - -def _relaxed_by_feast(rank): - """How far a feast lifts a fast day.""" - if rank == "great": - return FISH - if rank in ("vigil", "polyeleos"): - return WINE_OIL - return None - - -def _for_feast(level, ctx, cap, reason): - """Relax `level` for the day's saint, never past `cap`, never tightening. - - This is the rule the printed calendars are applying when they mark a - Wednesday in the Nativity Fast for wine and oil: the day's rank lifts the - weekday discipline. Inside the Great Fast the lift stops at wine and oil — - no saint brings fish into Lent — and elsewhere it stops at fish. - """ - relaxed = _relaxed_by_feast(ctx.rank) - if relaxed is None: - return Fast(level, reason, CUSTOMARY) - lifted = strictest(laxest(level, relaxed), cap) - if lifted == level or stricter(lifted, level): - return Fast(level, reason, CUSTOMARY) - return Fast(lifted, f"{ctx.feast} \u2014 the fast is relaxed", CUSTOMARY) - - -def fast_for(ctx): - md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv - season = ctx.season - weekend = wd in (SAT, SUN) - - # ── 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. - if season == BRIGHT_WEEK: - return Fast(NONE, "Bright Week — no fasting", None) - if season == PENTECOST_WEEK: - return Fast(NONE, "the week after Pentecost — no fasting", None) - if season == CHRISTMASTIDE: - return Fast(NONE, "the Nativity to the eve of Theophany", None) - if mv["publican_pharisee"] <= ctx.civil <= mv["publican_pharisee"] + dt.timedelta(6): - # Fast-free precisely because the Pharisee boasted of fasting twice a week. - return Fast(NONE, "fast-free week (the Publican and the Pharisee)", None) - - # ── The Great Fast and Great Week - if season in (GREAT_FAST, GREAT_WEEK): - # 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 in (mv["great_friday"], mv["holy_saturday"]): - return Fast(WINE_OIL, - "the Annunciation, on a day of the total fast", - CUSTOMARY) - return Fast(FISH, "the Annunciation", CUSTOMARY) - if ctx.civil == mv["palm_sunday"]: - return Fast(FISH, "Palm Sunday", CUSTOMARY) - if ctx.civil == mv["lazarus_saturday"]: - return Fast(WINE_OIL, "Lazarus Saturday", CUSTOMARY) - if ctx.civil == mv["clean_monday"]: - return Fast(STRICT, "the first day of the Great Fast", CUSTOMARY) - if ctx.civil == mv["great_friday"]: - return Fast(STRICT, "Great and Holy Friday", CUSTOMARY) - if ctx.civil == mv["holy_saturday"]: - # The one Saturday of the year kept without oil. - return Fast(XEROPHAGY, "Great and Holy Saturday", CUSTOMARY) - if ctx.civil == mv["great_thursday"]: - return Fast(WINE_OIL, "Great and Holy Thursday", CUSTOMARY) - # Great Week keeps its own rank; a saint does not lift it. - if season == GREAT_WEEK: - return Fast(XEROPHAGY, f"a weekday of the {season}", CUSTOMARY) - if weekend: - return Fast(WINE_OIL, f"Saturday and Sunday of the {season}", CUSTOMARY) - return _for_feast(XEROPHAGY, ctx, WINE_OIL, f"a weekday of the {season}") - - # ── Cheesefare: meat is set aside for good, everything else stays, - # Wednesday and Friday included. - if season == CHEESEFARE_WEEK: - return Fast(DAIRY, "Cheesefare Week", CUSTOMARY) - if ctx.civil == mv["meatfare"]: - return Fast(NONE, "Meatfare Sunday — the last day of meat", None) - - # ── The Nativity Fast, which tightens for its last five days - if season == NATIVITY_FAST: - 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(WINE_OIL, "the eve of the Nativity, on a weekend", - CUSTOMARY) - return Fast(XEROPHAGY, "the eve of the Nativity", CUSTOMARY) - if md == (11, 21): - return Fast(FISH, "the Entrance of the Theotokos", CUSTOMARY) - if md >= (12, 20): - if wd in (TUE, THU) or weekend: - return Fast(WINE_OIL, "the forefeast of the Nativity", CUSTOMARY) - return _for_feast(XEROPHAGY, ctx, WINE_OIL, - "the forefeast of the Nativity") - if weekend: - return Fast(FISH, "Saturday and Sunday of the Nativity Fast", CUSTOMARY) - if wd in (TUE, THU): - return _for_feast(WINE_OIL, ctx, FISH, - "Tuesday and Thursday of the Nativity Fast") - return _for_feast(XEROPHAGY, ctx, FISH, - "Monday, Wednesday and Friday of the Nativity Fast") - - # ── The Apostles' Fast, kept the same way - if season == APOSTLES: - if weekend: - return Fast(FISH, "Saturday and Sunday of the Apostles' Fast", CUSTOMARY) - if wd in (TUE, THU): - return _for_feast(WINE_OIL, ctx, FISH, - "Tuesday and Thursday of the Apostles' Fast") - return _for_feast(XEROPHAGY, ctx, FISH, - "Monday, Wednesday and Friday of the Apostles' Fast") - - # ── The Dormition Fast, kept like the Great Fast - if season == DORMITION: - if md == (8, 6): - return Fast(FISH, "the Transfiguration", CUSTOMARY) - if weekend: - return Fast(WINE_OIL, "Saturday and Sunday of the Dormition Fast", CUSTOMARY) - return _for_feast(XEROPHAGY, ctx, WINE_OIL, - "a weekday of the Dormition Fast") - - # ── The three fixed fast days outside any season - if md == (1, 5): - if weekend: - return Fast(WINE_OIL, "the eve of Theophany, on a weekend", CUSTOMARY) - return Fast(XEROPHAGY, "the eve of Theophany", CUSTOMARY) - if md == (9, 14): - return Fast(WINE_OIL, "the Exaltation of the Holy Cross", CUSTOMARY) - if md == (8, 29): - return Fast(WINE_OIL, "the Beheading of the Forerunner", CUSTOMARY) - - # ── Ordinary Wednesdays and Fridays, which a feast can lift - if wd in (WED, FRI): - relaxed = _relaxed_by_feast(ctx.rank) - if relaxed: - return Fast(relaxed, f"{ctx.feast} — the fast is relaxed", CUSTOMARY) - return Fast(XEROPHAGY, "Wednesday and Friday", CUSTOMARY) - - return Fast(NONE, "", None) - - -RULE = { - "name": "orthodox", - "title": "the Typikon", - "vocabulary": VOCABULARY, - "fast_for": fast_for, - # The Orthodox Church does not legislate the fast as precept, so nothing - # here is reported as binding. See the module docstring. - "has_obligation": False, -} diff --git a/typikon/rules/ruthenian.py b/typikon/rules/ruthenian.py deleted file mode 100644 index 4c96c9f..0000000 --- a/typikon/rules/ruthenian.py +++ /dev/null @@ -1,126 +0,0 @@ -"""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 ``wine_oil`` (it does not regulate oil or wine) and -its simple abstinence is ``dairy`` (meat alone is set aside). - -Source: the Byzantine Catholic Metropolitan Church of Pittsburgh, whose -published rule on fasting and abstinence is the basis for the obligations -above. 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. -""" - -import datetime as dt - -from ..fasting import (Fast, WINE_OIL, DAIRY, NONE, OBLIGATORY, CUSTOMARY) -from ..seasons import (GREAT_FAST, GREAT_WEEK, BRIGHT_WEEK, CHEESEFARE_WEEK, - APOSTLES, DORMITION, NATIVITY_FAST, PENTECOST_WEEK, - CHRISTMASTIDE) - -MON, TUE, WED, THU, FRI, SAT, SUN = range(7) - -VOCABULARY = { - WINE_OIL: ("no meat, fish, eggs or dairy", "\U0001f96c"), - DAIRY: ("no meat", "\U0001f41f"), - NONE: ("no fast", "\U0001f356"), -} - - -def _off_weekend(day): - """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." - """ - if day.weekday() == SAT: - return day - dt.timedelta(1) - if day.weekday() == SUN: - return day - dt.timedelta(2) - return day - - -def fast_for(ctx): - md, wd, mv = ctx.fixed, ctx.weekday, ctx.mv - season = ctx.season - - # ── the two days the Church binds strictly - if ctx.civil == mv["clean_monday"]: - return Fast(WINE_OIL, "the first day of the Great Fast", OBLIGATORY, WINE_OIL) - if ctx.civil == mv["great_friday"]: - return Fast(WINE_OIL, "Great and Holy Friday", OBLIGATORY, WINE_OIL) - - # ── and the days it binds to abstinence from meat. Kept strictly by - # tradition; binding only as far as meat. - if season == GREAT_FAST and wd in (WED, FRI): - return Fast(WINE_OIL, "Wednesday and Friday of the Great Fast", - OBLIGATORY, 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(WINE_OIL, label, CUSTOMARY) - - # ── 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. - if season == BRIGHT_WEEK: - return Fast(NONE, "Bright Week — no fasting", None) - if season == PENTECOST_WEEK: - return Fast(NONE, "the week after Pentecost — no fasting", None) - if season == CHRISTMASTIDE: - return Fast(NONE, "Christmastide — no fasting", None) - if mv["publican_pharisee"] <= ctx.civil <= mv["publican_pharisee"] + dt.timedelta(6): - return Fast(NONE, "fast-free week (the Publican and the Pharisee)", None) - - # ── the rest of the Great Fast and Great Week - if season in (GREAT_FAST, GREAT_WEEK): - if wd in (SAT, SUN): - return Fast(DAIRY, season, CUSTOMARY) - return Fast(WINE_OIL, season, CUSTOMARY) - - if season == CHEESEFARE_WEEK: - return Fast(DAIRY, "Cheesefare Week", CUSTOMARY) - - # ── the three voluntary seasons - if season in (APOSTLES, DORMITION, NATIVITY_FAST): - return Fast(DAIRY, season, CUSTOMARY) - - # ── a great feast releases the ordinary weekday abstinence - if wd in (WED, FRI): - if ctx.rank == "great": - return Fast(NONE, f"{ctx.feast} — the fast is released", None) - return Fast(DAIRY, "Friday" if wd == FRI else "Wednesday", CUSTOMARY) - - return Fast(NONE, "", None) - - -RULE = { - "name": "ruthenian", - "title": "Ruthenian particular law", - "vocabulary": VOCABULARY, - "fast_for": fast_for, - "has_obligation": True, -} diff --git a/typikon/seasons.py b/typikon/seasons.py deleted file mode 100644 index 0f50b3d..0000000 --- a/typikon/seasons.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Which season of the year a day falls in. - -The Great Fast here runs from Clean Monday through Palm Sunday. The Forty Days -proper end on the Friday before Lazarus Saturday — ``great_fast_end`` in the -movable cycle, 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. - -The movable seasons are counted from Pascha and so are compared as civil -dates. The fixed ones — the Nativity and Dormition Fasts, 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. -""" - -from .calendars import fixed_between - -__all__ = ["GREAT_FAST", "GREAT_WEEK", "BRIGHT_WEEK", "CHEESEFARE_WEEK", - "APOSTLES", "DORMITION", "NATIVITY_FAST", "season_for", "FASTING_SEASONS"] - -GREAT_FAST = "Great Fast" -GREAT_WEEK = "Great and Holy Week" -BRIGHT_WEEK = "Bright Week" -PRE_LENTEN = "Triodion" -CHEESEFARE_WEEK = "Cheesefare Week" -APOSTLES = "Apostles' Fast" -DORMITION = "Dormition Fast" -NATIVITY_FAST = "Nativity Fast" -PENTECOST_WEEK = "Week after Pentecost" -CHRISTMASTIDE = "Christmastide" - -FASTING_SEASONS = (GREAT_FAST, GREAT_WEEK, CHEESEFARE_WEEK, - APOSTLES, DORMITION, NATIVITY_FAST) - - -def season_for(civil, fixed_md, mv, apostles_end): - """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. - """ - # Movable first: Great Week outranks the Great Fast it follows, and Bright - # Week outranks everything. - if mv["pascha"] <= civil <= mv["bright_week_end"]: - return BRIGHT_WEEK - if mv["great_week_start"] <= civil <= mv["holy_saturday"]: - return GREAT_WEEK - # Lazarus Saturday and Palm Sunday sit between the Forty Days, which end on - # the Friday before them, and Great Week, which begins after them. Leaving - # that gap unnamed left both days with no season at all, and any rule that - # keyed on the season fell through to "no fast" on Palm Sunday. - if mv["clean_monday"] <= civil <= mv["palm_sunday"]: - return GREAT_FAST - if mv["cheesefare_week_start"] <= civil <= mv["cheesefare"]: - return CHEESEFARE_WEEK - if mv["publican_pharisee"] <= civil < mv["cheesefare_week_start"]: - return PRE_LENTEN - if mv["pentecost"] <= civil <= mv["pentecost_week_end"]: - return PENTECOST_WEEK - - # The Apostles' Fast can be empty in a year with a late Pascha, which the - # comparison handles without a special case. - if mv["apostles_start"] <= civil <= apostles_end: - return APOSTLES - - # Fixed seasons, in ecclesiastical space. - if fixed_between(fixed_md, (8, 1), (8, 14)): - return DORMITION - if fixed_between(fixed_md, (11, 15), (12, 24)): - return NATIVITY_FAST - if fixed_between(fixed_md, (12, 25), (1, 4)): - return CHRISTMASTIDE - return None