mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +00:00
rebuild the engine in Rust
Python was never the house language here: the mw-* desktop surface it came
from is 39 bash scripts and was exactly one Python script, the one this was
extracted from. What it cost was distribution, a widget dependency that
needed pipx and a Python floor, and 24ms of interpreter startup before a line
of the program ran.
The port is verified rather than asserted. Both implementations were run over
199,446 day-records, 26 jurisdictions across 21 years, with every field of
every record required to match; the JSON, the iCalendar and 480 renderings of
the text output are identical too. examples/dump.rs is the harness.
What the type system actually buys, which is less than the usual claim and
more than nothing:
- Level is an ordered enum, strictest first, so reads as 'a is
stricter' and relaxed_to() is the one place a feast can lift a fast. No
rule can get the direction backwards.
- Date is a newtype over the Julian Day Number and JulianDate is a separate
type, so a civil date and an ecclesiastical one cannot be mixed up. That
confusion is the single most common way calendar code goes wrong.
- Octets is a newtype in the iCalendar folder, because the bug it replaced
counted characters against a limit RFC 5545 states in octets.
- Movables is a struct, not a map, so a mistyped key is a compile error
rather than a None at the far end of the program.
Of the six defects an adversarial review found in the Python, exactly one had
a shape a type system would have caught. The rest were logic, and the tests
came with them.
The menologion is compiled in with include_str!, so the binary answers on its
own: 930KB, no runtime, 1.2ms to start against Python's 36ms.
This commit is contained in:
+2
-10
@@ -1,12 +1,4 @@
|
|||||||
__pycache__/
|
/target
|
||||||
*.py[cod]
|
**/*.rs.bk
|
||||||
.venv/
|
.venv/
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
*.egg-info/
|
*.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
|
## Install
|
||||||
|
|
||||||
|
One static binary, no runtime to install first.
|
||||||
|
|
||||||
```console
|
```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
|
Or build it from a checkout:
|
||||||
Python 3.9.
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ git clone https://git.pumpin.online/funkpower/typikon
|
$ 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?
|
## Which calendar?
|
||||||
|
|
||||||
Three things get conflated constantly, and keeping them apart is most of what
|
Three things get conflated constantly, and keeping them apart is most of what
|
||||||
@@ -126,20 +131,30 @@ neighbour.
|
|||||||
|
|
||||||
## As a library
|
## As a library
|
||||||
|
|
||||||
```python
|
```rust
|
||||||
import typikon
|
use typikon::calendars::Date;
|
||||||
|
use typikon::day::describe;
|
||||||
|
use typikon::jurisdictions::resolve;
|
||||||
|
|
||||||
d = typikon.today("rocor")
|
let rocor = resolve(Some("rocor"))?;
|
||||||
d.church_date # '5 September'
|
let day = describe(Date::from_ymd(2026, 9, 18).unwrap(), rocor);
|
||||||
d.fast # 'xerophagy'
|
|
||||||
d.title # 'The Holy Prophet Zachariah, father of the Forerunner'
|
|
||||||
d.is_obligatory # False
|
|
||||||
|
|
||||||
typikon.julian_pascha(2027) # datetime.date(2027, 5, 2)
|
day.church_date; // "5 September"
|
||||||
typikon.gregorian_pascha(2027) # datetime.date(2027, 3, 28)
|
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
|
## 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
|
that is, and that disagreement lives in the calendar conversion, not in the
|
||||||
data.
|
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
|
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
|
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
|
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
|
$ 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
|
## Accuracy
|
||||||
|
|
||||||
`typikon` is pinned by 60 tests against published calendars: the paschalion
|
`typikon` is pinned against published calendars: the paschalion for six years
|
||||||
for six years by both reckonings, the Julian drift across the 2100 boundary,
|
by both reckonings and its invariants across two centuries, 146,000 calendar
|
||||||
the fast on every day that is not open to interpretation, and a full year in
|
round-trips from 1800 to 2200, the fast on every day that is not open to
|
||||||
every jurisdiction.
|
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
|
It is a calendar, not an authority. If it disagrees with the one your parish
|
||||||
prints, your parish is right. Please open an issue.
|
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
|
## Licence
|
||||||
|
|
||||||
GPL-2.0-only. See [LICENSE](LICENSE). The menologion is under it too;
|
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
|
Supplementary sets fetched by `typikon --update-menaion` are NOT covered by
|
||||||
this and are NOT distributed here. They belong to whoever published them; the
|
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