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