mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0efc56312 | ||
|
|
93b597302a |
@@ -4,6 +4,75 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out.
|
|||||||
**Calendar** entries change what a day reports, whatever the version number
|
**Calendar** entries change what a day reports, whatever the version number
|
||||||
does.
|
does.
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Calendar
|
||||||
|
|
||||||
|
- **The Pentecostarion keeps all of its Sundays.** The Sunday of the Paralytic,
|
||||||
|
the Sunday of the Samaritan Woman and the Sunday of the Blind Man were absent
|
||||||
|
from the movable cycle altogether, so the third, fourth and fifth Sundays
|
||||||
|
after Thomas Sunday reported only the saint of the fixed date. Every
|
||||||
|
jurisdiction was affected. In 2027 that is 18 and 25 April and 2 May by the
|
||||||
|
Gregorian paschalion, 23 and 30 May and 6 June by the Julian one. They rank
|
||||||
|
with Thomas Sunday and the Myrrh-bearing Women, so a fixed feast of vigil
|
||||||
|
rank still names the day and the Sunday is commemorated on it: 25 April 2027
|
||||||
|
stays St Mark and gains the Samaritan Woman.
|
||||||
|
|
||||||
|
- **The Sundays reckoned from a fixed feast are kept.** Six more that the
|
||||||
|
calendar never named: the Sundays before and after the Exaltation, the Sunday
|
||||||
|
of the Holy Forefathers, the Sundays before and after the Nativity, and the
|
||||||
|
Sundays before and after the Theophany. They are found in ecclesiastical
|
||||||
|
space, so the old calendar and the new both get them on the right morning,
|
||||||
|
and they join the day's commemorations rather than displacing its saint.
|
||||||
|
|
||||||
|
Two years in the cycle leave a window without a Sunday in it, and the Typikon
|
||||||
|
says where those go: a Nativity on a Sunday puts the Sunday after it on 26
|
||||||
|
December, and a 1 January on a Sunday keeps the Sunday before the Theophany
|
||||||
|
with the Circumcision. The two remaining cases, the Theophany itself or the
|
||||||
|
Exaltation on a Sunday, go unmarked, because the books disagree about where
|
||||||
|
what belongs in them should land.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `week`: where the day falls in the count from Pentecost, worded as the books
|
||||||
|
head it — `the 32nd Sunday after Pentecost`, or `the 32nd week after
|
||||||
|
Pentecost` on the days between. Null through the Triodion and the
|
||||||
|
Pentecostarion, whose Sundays carry names instead of numbers. The count runs
|
||||||
|
on across the new year from the Pentecost that opened it, and stops at the
|
||||||
|
next Sunday of the Publican and the Pharisee. It is in the JSON, a column in
|
||||||
|
`csv` and `tsv`, a line in the single-day view, and a getter on every
|
||||||
|
binding.
|
||||||
|
|
||||||
|
- `--every-day`: one event per day in `--format ics`, ferias included. The
|
||||||
|
default still thins the calendar to the days carrying a feast or a fast,
|
||||||
|
which is right for a subscription and wrong for a reader who wants the saint
|
||||||
|
of each morning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The iCalendar description leads with the day's commemorations, one to a line,
|
||||||
|
then the fast and its reason, then the season, the week and the church date.
|
||||||
|
They were last before, after the church date, and a client showing three
|
||||||
|
lines of a popover showed everything except what the day commemorates. The
|
||||||
|
summary now carries the rank's mark beside the fasting glyph, as the text
|
||||||
|
view has always done, and the fast is stated on every event rather than only
|
||||||
|
on the days that keep one.
|
||||||
|
|
||||||
|
- `typikon::export::to_ics_with` and `Ics` are the way to reach the new option
|
||||||
|
from the library. `to_ics` is unchanged and still writes the thinned
|
||||||
|
calendar.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The published description says what the package covers, and says the same
|
||||||
|
thing in both registries. PyPI takes its summary from `pyproject.toml`, which
|
||||||
|
1.1.1 did not update, so it went out with the old one-line text while npm got
|
||||||
|
the new one. The two READMEs had drifted apart as well: npm's opened without
|
||||||
|
mentioning the Byzantine Catholic churches at all. All four now carry one
|
||||||
|
sentence, and a test fails if any of them moves without the others.
|
||||||
|
|
||||||
## 1.1.1
|
## 1.1.1
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+1
-1
@@ -404,7 +404,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# typikon
|
# typikon
|
||||||
|
|
||||||
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
than fetched. The Byzantine Catholic churches are here too, on their own
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
calendars and, where the law has been sourced, under their own particular law.
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
Everything derives from the paschalion and the fixed calendar, so it needs no
|
Everything derives from the paschalion and the fixed calendar, so it needs no
|
||||||
network, cannot go stale, and answers for any year you ask about. No API key,
|
network, cannot go stale, and answers for any year you ask about. No API key,
|
||||||
@@ -277,6 +277,16 @@ $ typikon --format json --days 7 # for a bar, a widget, a script
|
|||||||
$ typikon --format ics --days 365 > orthodox.ics
|
$ typikon --format ics --days 365 > orthodox.ics
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The calendar is thinned to the days worth marking: a subscription that fills
|
||||||
|
all 365 squares is one nobody keeps. `--every-day` is for the other kind of
|
||||||
|
reader, who wants the saint of each morning and does not mind a full year.
|
||||||
|
Either way the description carries the day's commemorations, the fast and its
|
||||||
|
reason, and where the day sits in the year.
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ typikon -j ruthenian --days 467 --ics --every-day > byzantine.ics
|
||||||
|
```
|
||||||
|
|
||||||
Delimited output, for a spreadsheet or a pipeline. RFC 4180 quoting, one header
|
Delimited output, for a spreadsheet or a pipeline. RFC 4180 quoting, one header
|
||||||
row, one row per day:
|
row, one row per day:
|
||||||
|
|
||||||
@@ -309,6 +319,11 @@ $ typikon --year 2027 -j goarch
|
|||||||
…
|
…
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Outside the Triodion and the Pentecostarion, whose Sundays carry names, `week`
|
||||||
|
says where the day falls in the count from Pentecost: `the 32nd Sunday after
|
||||||
|
Pentecost`, or `the 32nd week after Pentecost` on the days between. It is a
|
||||||
|
column in the delimited formats and a line in the single-day view.
|
||||||
|
|
||||||
Every day of vigil rank or above carries the mark its books print beside it:
|
Every day of vigil rank or above carries the mark its books print beside it:
|
||||||
⊕ for a great feast, ✚ for a vigil, ☩ for a polyeleos. It is the `sign`
|
⊕ for a great feast, ✚ for a vigil, ☩ for a polyeleos. It is the `sign`
|
||||||
field in the JSON and a column in the delimited formats.
|
field in the JSON and a column in the delimited formats.
|
||||||
@@ -332,6 +347,7 @@ $ typikon -j rocor --date 2026-03-18 --json --indent 2
|
|||||||
"churchDate": "5 March",
|
"churchDate": "5 March",
|
||||||
"season": "Great Fast",
|
"season": "Great Fast",
|
||||||
"cycle": null,
|
"cycle": null,
|
||||||
|
"week": null,
|
||||||
"feast": null,
|
"feast": null,
|
||||||
"rank": null,
|
"rank": null,
|
||||||
"sign": null,
|
"sign": null,
|
||||||
|
|||||||
+4
-1
@@ -21,13 +21,16 @@ tradition title commemorations fast fastLabel emoji reason obligation binds
|
|||||||
pascha daysToPascha julianOffset
|
pascha daysToPascha julianOffset
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`week` was added in 1.2.0.
|
||||||
|
|
||||||
`fastLabel`, `emoji` and `reason` are **presentation**: the fields are always
|
`fastLabel`, `emoji` and `reason` are **presentation**: the fields are always
|
||||||
there, and their wording is not stable. Key on `fast`, `season`, `rank`,
|
there, and their wording is not stable. Key on `fast`, `season`, `rank`,
|
||||||
`obligation` and `binds`.
|
`obligation` and `binds`.
|
||||||
|
|
||||||
Adding a field is a **minor** change, so read the object by key and ignore what
|
Adding a field is a **minor** change, so read the object by key and ignore what
|
||||||
you do not recognise. `season`, `cycle`, `feast`, `rank`, `sign` and
|
you do not recognise. `season`, `cycle`, `feast`, `rank`, `sign` and
|
||||||
`obligation` are null on ordinary days.
|
`obligation` are null on ordinary days, and `week` is null through the Triodion
|
||||||
|
and the Pentecostarion.
|
||||||
|
|
||||||
Two semantics worth stating because the names do not carry them:
|
Two semantics worth stating because the names do not carry them:
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const char *typikon_day_emoji(const typikon_day *day);
|
|||||||
const char *typikon_day_reason(const typikon_day *day);
|
const char *typikon_day_reason(const typikon_day *day);
|
||||||
const char *typikon_day_season(const typikon_day *day);
|
const char *typikon_day_season(const typikon_day *day);
|
||||||
const char *typikon_day_cycle(const typikon_day *day);
|
const char *typikon_day_cycle(const typikon_day *day);
|
||||||
|
const char *typikon_day_week(const typikon_day *day);
|
||||||
const char *typikon_day_feast(const typikon_day *day);
|
const char *typikon_day_feast(const typikon_day *day);
|
||||||
const char *typikon_day_rank(const typikon_day *day);
|
const char *typikon_day_rank(const typikon_day *day);
|
||||||
const char *typikon_day_sign(const typikon_day *day);
|
const char *typikon_day_sign(const typikon_day *day);
|
||||||
@@ -79,7 +80,7 @@ end
|
|||||||
local FIELDS = {
|
local FIELDS = {
|
||||||
"iso", "weekday", "jurisdiction", "calendar", "tradition", "church_date",
|
"iso", "weekday", "jurisdiction", "calendar", "tradition", "church_date",
|
||||||
"title", "fast", "fast_label", "emoji", "reason",
|
"title", "fast", "fast_label", "emoji", "reason",
|
||||||
"season", "cycle", "feast", "rank", "sign", "obligation", "binds",
|
"season", "cycle", "week", "feast", "rank", "sign", "obligation", "binds",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Copy the day into a plain Lua table and free it. The C day owns its strings
|
-- Copy the day into a plain Lua table and free it. The C day owns its strings
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ const char *typikon_day_reason(const typikon_day *day); /* why the day fas
|
|||||||
/* NULL where the day has no such thing, which is not an error. */
|
/* NULL where the day has no such thing, which is not an error. */
|
||||||
const char *typikon_day_season(const typikon_day *day); /* NULL in ordinary time */
|
const char *typikon_day_season(const typikon_day *day); /* NULL in ordinary time */
|
||||||
const char *typikon_day_cycle(const typikon_day *day); /* NULL outside the movable cycle */
|
const char *typikon_day_cycle(const typikon_day *day); /* NULL outside the movable cycle */
|
||||||
|
const char *typikon_day_week(const typikon_day *day); /* NULL outside the count from Pentecost */
|
||||||
const char *typikon_day_feast(const typikon_day *day); /* NULL if the day has none */
|
const char *typikon_day_feast(const typikon_day *day); /* NULL if the day has none */
|
||||||
const char *typikon_day_rank(const typikon_day *day); /* NULL if the day has no feast */
|
const char *typikon_day_rank(const typikon_day *day); /* NULL if the day has no feast */
|
||||||
const char *typikon_day_sign(const typikon_day *day); /* the mark the books print, else NULL */
|
const char *typikon_day_sign(const typikon_day *day); /* the mark the books print, else NULL */
|
||||||
|
|||||||
+4
-1
@@ -1,7 +1,10 @@
|
|||||||
# typikon
|
# typikon
|
||||||
|
|
||||||
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
The Orthodox liturgical day: feasts, seasons and the fast, computed rather
|
||||||
than fetched. No network, no data files, no service to be up.
|
than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic
|
||||||
|
churches, across the Julian, Revised Julian and Gregorian reckonings.
|
||||||
|
|
||||||
|
No network, no data files, no service to be up.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install typikon
|
npm install typikon
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "typikon"
|
name = "typikon"
|
||||||
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched"
|
description = "The Orthodox liturgical day: feasts, seasons and the fast, computed rather than fetched. Twenty Orthodox jurisdictions and four Byzantine Catholic churches, across the Julian, Revised Julian and Gregorian reckonings."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class Day:
|
|||||||
@property
|
@property
|
||||||
def cycle(self) -> str | None: ...
|
def cycle(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
|
def week(self) -> str | None: ...
|
||||||
|
@property
|
||||||
def feast(self) -> str | None: ...
|
def feast(self) -> str | None: ...
|
||||||
@property
|
@property
|
||||||
def rank(self) -> str | None: ...
|
def rank(self) -> str | None: ...
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ pub struct typikon_day {
|
|||||||
reason: CString,
|
reason: CString,
|
||||||
season: Option<CString>,
|
season: Option<CString>,
|
||||||
cycle: Option<CString>,
|
cycle: Option<CString>,
|
||||||
|
week: Option<CString>,
|
||||||
feast: Option<CString>,
|
feast: Option<CString>,
|
||||||
rank: Option<CString>,
|
rank: Option<CString>,
|
||||||
sign: Option<CString>,
|
sign: Option<CString>,
|
||||||
@@ -223,6 +224,7 @@ fn build(
|
|||||||
reason: cs(&inner.reason),
|
reason: cs(&inner.reason),
|
||||||
season: inner.season.map(|s| cs(s.name())),
|
season: inner.season.map(|s| cs(s.name())),
|
||||||
cycle: inner.cycle.as_deref().map(&cs),
|
cycle: inner.cycle.as_deref().map(&cs),
|
||||||
|
week: inner.week.as_deref().map(&cs),
|
||||||
feast: inner.feast.as_deref().map(&cs),
|
feast: inner.feast.as_deref().map(&cs),
|
||||||
rank: inner.rank.map(|r| cs(r.key())),
|
rank: inner.rank.map(|r| cs(r.key())),
|
||||||
sign: inner.rank.map(|r| cs(r.sign())),
|
sign: inner.rank.map(|r| cs(r.sign())),
|
||||||
@@ -326,6 +328,7 @@ accessor!(typikon_day_emoji, "The glyph for the fasting level.", |d| Some(&d.emo
|
|||||||
accessor!(typikon_day_reason, "Why the day fasts as it does.", |d| Some(&d.reason));
|
accessor!(typikon_day_reason, "Why the day fasts as it does.", |d| Some(&d.reason));
|
||||||
accessor!(typikon_day_season, "The season, or NULL in ordinary time.", |d| d.season.as_ref());
|
accessor!(typikon_day_season, "The season, or NULL in ordinary time.", |d| d.season.as_ref());
|
||||||
accessor!(typikon_day_cycle, "The place in the movable cycle, or NULL outside it.", |d| d.cycle.as_ref());
|
accessor!(typikon_day_cycle, "The place in the movable cycle, or NULL outside it.", |d| d.cycle.as_ref());
|
||||||
|
accessor!(typikon_day_week, "The count of weeks from Pentecost, or NULL outside it.", |d| d.week.as_ref());
|
||||||
accessor!(typikon_day_feast, "The feast, or NULL if the day has none.", |d| d.feast.as_ref());
|
accessor!(typikon_day_feast, "The feast, or NULL if the day has none.", |d| d.feast.as_ref());
|
||||||
accessor!(typikon_day_rank, "The rank of the feast, or NULL if the day has none.", |d| d.rank.as_ref());
|
accessor!(typikon_day_rank, "The rank of the feast, or NULL if the day has none.", |d| d.rank.as_ref());
|
||||||
accessor!(typikon_day_sign, "The mark the books print beside a day of this rank, or NULL.", |d| d.sign.as_ref());
|
accessor!(typikon_day_sign, "The mark the books print beside a day of this rank, or NULL.", |d| d.sign.as_ref());
|
||||||
|
|||||||
+18
-1
@@ -11,7 +11,7 @@ use crate::calendars::{Date, Weekday, MONTHS};
|
|||||||
use crate::fasting::{Fast, Level, Obligation};
|
use crate::fasting::{Fast, Level, Obligation};
|
||||||
use crate::jurisdictions::Jurisdiction;
|
use crate::jurisdictions::Jurisdiction;
|
||||||
use crate::menologion::{self, Menologion, Rank};
|
use crate::menologion::{self, Menologion, Rank};
|
||||||
use crate::paschalion::{movable_cycle, Movables};
|
use crate::paschalion::{movable_cycle, week_after_pentecost_label, Movables};
|
||||||
use crate::rules::{self, Context};
|
use crate::rules::{self, Context};
|
||||||
use crate::seasons::season_for;
|
use crate::seasons::season_for;
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@ pub struct Day {
|
|||||||
pub church_date: String,
|
pub church_date: String,
|
||||||
pub season: Option<crate::seasons::Season>,
|
pub season: Option<crate::seasons::Season>,
|
||||||
pub cycle: Option<String>,
|
pub cycle: Option<String>,
|
||||||
|
/// Where the day falls in the count of weeks from Pentecost, worded: "the
|
||||||
|
/// 32nd Sunday after Pentecost". `None` through the Triodion and the
|
||||||
|
/// Pentecostarion, whose Sundays have names instead of numbers.
|
||||||
|
pub week: Option<String>,
|
||||||
pub feast: Option<String>,
|
pub feast: Option<String>,
|
||||||
pub rank: Option<Rank>,
|
pub rank: Option<Rank>,
|
||||||
pub tradition: &'static str,
|
pub tradition: &'static str,
|
||||||
@@ -81,6 +85,7 @@ pub struct DayJson<'a> {
|
|||||||
pub church_date: &'a str,
|
pub church_date: &'a str,
|
||||||
pub season: Option<&'a str>,
|
pub season: Option<&'a str>,
|
||||||
pub cycle: Option<&'a str>,
|
pub cycle: Option<&'a str>,
|
||||||
|
pub week: Option<&'a str>,
|
||||||
pub feast: Option<&'a str>,
|
pub feast: Option<&'a str>,
|
||||||
pub rank: Option<&'a str>,
|
pub rank: Option<&'a str>,
|
||||||
/// The mark the books print beside a day of this rank.
|
/// The mark the books print beside a day of this rank.
|
||||||
@@ -109,6 +114,7 @@ impl Day {
|
|||||||
church_date: &self.church_date,
|
church_date: &self.church_date,
|
||||||
season: self.season.map(|s| s.name()),
|
season: self.season.map(|s| s.name()),
|
||||||
cycle: self.cycle.as_deref(),
|
cycle: self.cycle.as_deref(),
|
||||||
|
week: self.week.as_deref(),
|
||||||
feast: self.feast.as_deref(),
|
feast: self.feast.as_deref(),
|
||||||
rank: self.rank.map(|r| r.key()),
|
rank: self.rank.map(|r| r.key()),
|
||||||
sign: self.rank.map(|r| r.sign()),
|
sign: self.rank.map(|r| r.sign()),
|
||||||
@@ -182,6 +188,16 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
|||||||
(None, fixed) => fixed.clone(),
|
(None, fixed) => fixed.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The Sundays counted from the Exaltation, the Nativity and the Theophany
|
||||||
|
// join the day rather than naming it: the books print the saint and the
|
||||||
|
// Sunday both, and the saint keeps the title.
|
||||||
|
if let Some(sunday) = menologion::fixed_cycle_sunday((fm, fd), date.weekday()) {
|
||||||
|
let sunday = sunday.to_string();
|
||||||
|
if !commemorations.contains(&sunday) && feast.as_deref() != Some(sunday.as_str()) {
|
||||||
|
commemorations.insert(1.min(commemorations.len()), sunday);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ctx = Context {
|
let ctx = Context {
|
||||||
civil: date,
|
civil: date,
|
||||||
fixed: (fm, fd),
|
fixed: (fm, fd),
|
||||||
@@ -228,6 +244,7 @@ pub fn describe_with(date: Date, jur: &'static Jurisdiction, men: &Menologion) -
|
|||||||
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
|
church_date: format!("{} {}", fd, MONTHS[(fm - 1) as usize]),
|
||||||
season,
|
season,
|
||||||
cycle: entry.cycle,
|
cycle: entry.cycle,
|
||||||
|
week: week_after_pentecost_label(date, date.weekday(), jur.paschalion),
|
||||||
feast,
|
feast,
|
||||||
rank,
|
rank,
|
||||||
tradition: jur.tradition.key(),
|
tradition: jur.tradition.key(),
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,7 @@ use crate::day::Day;
|
|||||||
|
|
||||||
pub const COLUMNS: &[&str] = &[
|
pub const COLUMNS: &[&str] = &[
|
||||||
"date", "weekday", "jurisdiction", "calendar", "church_date", "season",
|
"date", "weekday", "jurisdiction", "calendar", "church_date", "season",
|
||||||
"cycle", "feast", "rank", "sign", "tradition", "title", "fast", "fast_label", "emoji",
|
"cycle", "week", "feast", "rank", "sign", "tradition", "title", "fast", "fast_label", "emoji",
|
||||||
"reason", "obligation", "binds", "pascha", "days_to_pascha",
|
"reason", "obligation", "binds", "pascha", "days_to_pascha",
|
||||||
"julian_offset", "commemorations",
|
"julian_offset", "commemorations",
|
||||||
];
|
];
|
||||||
@@ -33,6 +33,7 @@ fn cell(day: &Day, column: &str) -> String {
|
|||||||
"church_date" => day.church_date.clone(),
|
"church_date" => day.church_date.clone(),
|
||||||
"season" => day.season.map(|s| s.name()).unwrap_or("").to_string(),
|
"season" => day.season.map(|s| s.name()).unwrap_or("").to_string(),
|
||||||
"cycle" => day.cycle.clone().unwrap_or_default(),
|
"cycle" => day.cycle.clone().unwrap_or_default(),
|
||||||
|
"week" => day.week.clone().unwrap_or_default(),
|
||||||
"feast" => day.feast.clone().unwrap_or_default(),
|
"feast" => day.feast.clone().unwrap_or_default(),
|
||||||
"rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(),
|
"rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(),
|
||||||
"sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(),
|
"sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(),
|
||||||
|
|||||||
+74
-29
@@ -1,14 +1,29 @@
|
|||||||
//! iCalendar, so the calendar can live where the rest of your year does.
|
//! 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
|
//! Each day becomes one all-day event. The summary is the feast or the
|
||||||
//! principal commemoration; the description carries the fast, the season and
|
//! principal commemoration; the description lists every commemoration, then the
|
||||||
//! the rest of the day's commemorations. Days with nothing to say, no feast and
|
//! fast, then where the day sits in the year.
|
||||||
//! no fast, are left out, because a subscribed calendar that marks all 365 days
|
//!
|
||||||
//! is one nobody keeps.
|
//! By default the 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.
|
||||||
|
//! `Ics::every_day` is for the other kind of reader, who wants the saint of
|
||||||
|
//! each morning and does not mind a full calendar: it gives every day an event,
|
||||||
|
//! ferial ones included.
|
||||||
|
|
||||||
use crate::day::Day;
|
use crate::day::Day;
|
||||||
use crate::fasting::{Level, Obligation};
|
use crate::fasting::{Level, Obligation};
|
||||||
|
|
||||||
|
/// How to write the calendar.
|
||||||
|
pub struct Ics<'a> {
|
||||||
|
/// What a client shows the subscription as.
|
||||||
|
pub name: &'a str,
|
||||||
|
/// `DTSTAMP`, as `YYYYMMDDTHHMMSSZ`.
|
||||||
|
pub stamp: &'a str,
|
||||||
|
/// One event for every day, rather than only the days carrying a feast or
|
||||||
|
/// a fast.
|
||||||
|
pub every_day: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// A count of octets, which is what RFC 5545 measures content lines in.
|
/// 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*
|
/// A newtype because the Python this was ported from folded on *characters*
|
||||||
@@ -61,54 +76,75 @@ fn fold(line: &str) -> String {
|
|||||||
out.join("\r\n")
|
out.join("\r\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The calendar as it has always been written: a subscription, thinned to the
|
||||||
|
/// days worth marking.
|
||||||
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
||||||
|
to_ics_with(days, &Ics { name, stamp, every_day: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_ics_with(days: &[Day], opts: &Ics<'_>) -> String {
|
||||||
let mut lines: Vec<String> = vec![
|
let mut lines: Vec<String> = vec![
|
||||||
"BEGIN:VCALENDAR".into(),
|
"BEGIN:VCALENDAR".into(),
|
||||||
"VERSION:2.0".into(),
|
"VERSION:2.0".into(),
|
||||||
"PRODID:-//typikon//EN".into(),
|
"PRODID:-//typikon//EN".into(),
|
||||||
"CALSCALE:GREGORIAN".into(),
|
"CALSCALE:GREGORIAN".into(),
|
||||||
"METHOD:PUBLISH".into(),
|
"METHOD:PUBLISH".into(),
|
||||||
format!("X-WR-CALNAME:{}", escape(name)),
|
format!("X-WR-CALNAME:{}", escape(opts.name)),
|
||||||
];
|
];
|
||||||
|
|
||||||
for d in days {
|
for d in days {
|
||||||
if d.feast.is_none() && d.fast == Level::None {
|
if !opts.every_day && d.feast.is_none() && d.fast == Level::None {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let start = d.date.iso().replace('-', "");
|
let start = d.date.iso().replace('-', "");
|
||||||
let end = (d.date + 1).iso().replace('-', "");
|
let end = (d.date + 1).iso().replace('-', "");
|
||||||
let summary = if d.emoji.is_empty() {
|
// The glyph for the fast and the mark for the rank, in the order the
|
||||||
d.title().to_string()
|
// text view prints them, so one is scannable down a month view and the
|
||||||
} else {
|
// other says at a glance whether the day is kept.
|
||||||
format!("{} {}", d.emoji, d.title())
|
let sign = d.rank.map(|r| r.sign()).unwrap_or("");
|
||||||
};
|
let summary = [d.emoji, sign, d.title()]
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// Paragraphs, joined by a blank line. A calendar client shows this in
|
||||||
|
// a popover a few lines tall, and the saints are what the reader came
|
||||||
|
// for, so they go first.
|
||||||
let mut body: Vec<String> = Vec::new();
|
let mut body: Vec<String> = Vec::new();
|
||||||
if d.fast != Level::None {
|
if !d.commemorations.is_empty() {
|
||||||
body.push(if d.reason.is_empty() {
|
body.push(
|
||||||
|
d.commemorations
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("\u{2022} {c}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut fast = if d.reason.is_empty() {
|
||||||
format!("Fast: {}", d.fast_label)
|
format!("Fast: {}", d.fast_label)
|
||||||
} else {
|
} else {
|
||||||
format!("Fast: {} ({})", d.fast_label, d.reason)
|
format!("Fast: {} ({})", d.fast_label, d.reason)
|
||||||
});
|
};
|
||||||
}
|
|
||||||
if d.obligation == Some(Obligation::Obligatory) {
|
if d.obligation == Some(Obligation::Obligatory) {
|
||||||
body.push("Obligatory.".into());
|
fast.push_str("\nObligatory.");
|
||||||
|
}
|
||||||
|
body.push(fast);
|
||||||
|
|
||||||
|
let mut where_ = Vec::new();
|
||||||
|
if let Some(w) = &d.week {
|
||||||
|
where_.push(capitalised(w));
|
||||||
}
|
}
|
||||||
if let Some(s) = d.season {
|
if let Some(s) = d.season {
|
||||||
body.push(format!("Season: {}", s.name()));
|
where_.push(format!("Season: {}", s.name()));
|
||||||
}
|
}
|
||||||
if let Some(c) = &d.cycle {
|
if let Some(c) = &d.cycle {
|
||||||
let mut chars = c.chars();
|
where_.push(capitalised(c));
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
where_.push(format!("Church date: {}", d.church_date));
|
||||||
|
body.push(where_.join("\n"));
|
||||||
|
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"BEGIN:VEVENT".into(),
|
"BEGIN:VEVENT".into(),
|
||||||
@@ -123,11 +159,11 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
|||||||
d.tradition,
|
d.tradition,
|
||||||
if d.shown_at_typikon { "typikon" } else { "particular" }
|
if d.shown_at_typikon { "typikon" } else { "particular" }
|
||||||
),
|
),
|
||||||
format!("DTSTAMP:{stamp}"),
|
format!("DTSTAMP:{}", opts.stamp),
|
||||||
format!("DTSTART;VALUE=DATE:{start}"),
|
format!("DTSTART;VALUE=DATE:{start}"),
|
||||||
format!("DTEND;VALUE=DATE:{end}"),
|
format!("DTEND;VALUE=DATE:{end}"),
|
||||||
fold(&format!("SUMMARY:{}", escape(&summary))),
|
fold(&format!("SUMMARY:{}", escape(&summary))),
|
||||||
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))),
|
fold(&format!("DESCRIPTION:{}", escape(&body.join("\n\n")))),
|
||||||
"TRANSP:TRANSPARENT".into(),
|
"TRANSP:TRANSPARENT".into(),
|
||||||
"END:VEVENT".into(),
|
"END:VEVENT".into(),
|
||||||
]);
|
]);
|
||||||
@@ -138,3 +174,12 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String {
|
|||||||
s.push_str("\r\n");
|
s.push_str("\r\n");
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A phrase written to sit inside a sentence, promoted to the start of a line.
|
||||||
|
fn capitalised(phrase: &str) -> String {
|
||||||
|
let mut chars = phrase.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,5 +3,5 @@ pub mod ics;
|
|||||||
pub mod json;
|
pub mod json;
|
||||||
|
|
||||||
pub use csv::to_delimited;
|
pub use csv::to_delimited;
|
||||||
pub use ics::to_ics;
|
pub use ics::{to_ics, to_ics_with, Ics};
|
||||||
pub use json::to_json;
|
pub use json::to_json;
|
||||||
|
|||||||
+15
-2
@@ -5,7 +5,7 @@ use clap::{Parser, ValueEnum};
|
|||||||
use typikon::calendars::Date;
|
use typikon::calendars::Date;
|
||||||
use typikon::config::{self, Source};
|
use typikon::config::{self, Source};
|
||||||
use typikon::day::{week, year_of, Day};
|
use typikon::day::{week, year_of, Day};
|
||||||
use typikon::export::{to_delimited, to_ics, to_json};
|
use typikon::export::{to_delimited, to_ics_with, to_json, Ics};
|
||||||
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
|
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
|
||||||
use typikon::menaia;
|
use typikon::menaia;
|
||||||
use typikon::rules;
|
use typikon::rules;
|
||||||
@@ -67,6 +67,11 @@ struct Args {
|
|||||||
#[arg(long, conflicts_with = "format")]
|
#[arg(long, conflicts_with = "format")]
|
||||||
ics: bool,
|
ics: bool,
|
||||||
|
|
||||||
|
/// Give every day an event in ics, ferias included, rather than only the
|
||||||
|
/// days carrying a feast or a fast
|
||||||
|
#[arg(long)]
|
||||||
|
every_day: bool,
|
||||||
|
|
||||||
/// Leave the glyphs out, for a terminal or a bar without them
|
/// Leave the glyphs out, for a terminal or a bar without them
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
no_emoji: bool,
|
no_emoji: bool,
|
||||||
@@ -222,6 +227,9 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
|
|||||||
} else {
|
} else {
|
||||||
println!(" {} {sign}{}", d.emoji, d.title());
|
println!(" {} {sign}{}", d.emoji, d.title());
|
||||||
}
|
}
|
||||||
|
if let Some(w) = &d.week {
|
||||||
|
println!(" {w}");
|
||||||
|
}
|
||||||
if let Some(c) = &d.cycle {
|
if let Some(c) = &d.cycle {
|
||||||
println!(" {c}");
|
println!(" {c}");
|
||||||
}
|
}
|
||||||
@@ -443,7 +451,12 @@ fn run() -> Result<i32, String> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
Format::Ics => {
|
Format::Ics => {
|
||||||
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
|
let opts = Ics {
|
||||||
|
name: &format!("{} \u{2014} Typikon", jur.name),
|
||||||
|
stamp: &stamp(),
|
||||||
|
every_day: args.every_day,
|
||||||
|
};
|
||||||
|
print!("{}", to_ics_with(&days, &opts));
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
Format::Csv | Format::Tsv => {
|
Format::Csv | Format::Tsv => {
|
||||||
|
|||||||
+48
-2
@@ -14,7 +14,7 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::calendars::Date;
|
use crate::calendars::{Date, Weekday};
|
||||||
use crate::jurisdictions::Tradition;
|
use crate::jurisdictions::Tradition;
|
||||||
use crate::paschalion::Movables;
|
use crate::paschalion::Movables;
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ pub fn for_jurisdiction(tradition: Tradition, menaia: &[&str]) -> Menologion {
|
|||||||
/// The movable feasts, named by their offset from Pascha. These outrank
|
/// The movable feasts, named by their offset from Pascha. These outrank
|
||||||
/// whatever the fixed calendar holds for the day.
|
/// whatever the fixed calendar holds for the day.
|
||||||
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
|
pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option<Rank>)> {
|
||||||
let table: [(Date, &'static str, Option<Rank>); 24] = [
|
let table: [(Date, &'static str, Option<Rank>); 27] = [
|
||||||
(mv.pascha, "The Radiant and Life-giving Resurrection of our Lord \u{2014} Holy Pascha", Some(Rank::Great)),
|
(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.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.ascension, "The Ascension of our Lord, God and Saviour Jesus Christ", Some(Rank::Great)),
|
||||||
@@ -223,6 +223,9 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
|
|||||||
(mv.holy_saturday, "Great and Holy Saturday", 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.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.myrrhbearers, "The Sunday of the Holy Myrrh-bearing Women", Some(Rank::Polyeleos)),
|
||||||
|
(mv.paralytic, "The Sunday of the Paralytic", Some(Rank::Polyeleos)),
|
||||||
|
(mv.samaritan_woman, "The Sunday of the Samaritan Woman", Some(Rank::Polyeleos)),
|
||||||
|
(mv.blind_man, "The Sunday of the Blind Man", Some(Rank::Polyeleos)),
|
||||||
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
|
(mv.midfeast, "The Midfeast of Pentecost", Some(Rank::Polyeleos)),
|
||||||
(mv.all_saints, "The Sunday of All Saints", 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.publican_pharisee, "The Sunday of the Publican and the Pharisee", None),
|
||||||
@@ -245,3 +248,46 @@ pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Op
|
|||||||
.find(|(d, _, _)| *d == civil)
|
.find(|(d, _, _)| *d == civil)
|
||||||
.map(|(_, title, rank)| (*title, *rank))
|
.map(|(_, title, rank)| (*title, *rank))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Sundays reckoned from a fixed feast rather than from Pascha.
|
||||||
|
///
|
||||||
|
/// Six of them, hung off the Exaltation, the Nativity and the Theophany. They
|
||||||
|
/// are named by the **ecclesiastical** date, so one table serves the old
|
||||||
|
/// calendar and the new alike, and they are found by the civil weekday,
|
||||||
|
/// because a Sunday is a Sunday in both reckonings.
|
||||||
|
///
|
||||||
|
/// These are commemorations, not feasts: they carry no rank and never displace
|
||||||
|
/// the saint of the day. The books print both, and on 26 December the Synaxis
|
||||||
|
/// of the Theotokos still names the day.
|
||||||
|
///
|
||||||
|
/// Three of the windows are shorter than a week and so can miss their Sunday
|
||||||
|
/// altogether:
|
||||||
|
///
|
||||||
|
/// - The Nativity on a Sunday leaves 26–31 December without one. The Typikon
|
||||||
|
/// settles this: the Sunday after the Nativity is kept on 26 December, and
|
||||||
|
/// that is the one day here that is not a Sunday.
|
||||||
|
/// - 1 January on a Sunday leaves 2–5 January without one, and the Sunday
|
||||||
|
/// before the Theophany is kept on 1 January, with the Circumcision.
|
||||||
|
/// - The Theophany itself on a Sunday, and the Exaltation on a Sunday, leave
|
||||||
|
/// their windows empty with nowhere agreed to put what belongs in them.
|
||||||
|
/// Those two years go unmarked: an answer this calendar cannot defend is
|
||||||
|
/// worse than none.
|
||||||
|
pub fn fixed_cycle_sunday(fixed: (u32, u32), weekday: Weekday) -> Option<&'static str> {
|
||||||
|
let sunday = weekday == Weekday::Sunday;
|
||||||
|
let title = match fixed {
|
||||||
|
(9, 8..=13) if sunday => "The Sunday before the Exaltation",
|
||||||
|
(9, 15..=21) if sunday => "The Sunday after the Exaltation",
|
||||||
|
(12, 11..=17) if sunday => "The Sunday of the Holy Forefathers",
|
||||||
|
(12, 18..=24) if sunday => "The Sunday before the Nativity \u{2014} the Holy Fathers",
|
||||||
|
(12, 26..=31) if sunday => "The Sunday after the Nativity",
|
||||||
|
// 26 December falls on a Monday only in a year whose Nativity was a
|
||||||
|
// Sunday, which is exactly the year the window above is empty.
|
||||||
|
(12, 26) if weekday == Weekday::Monday => "The Sunday after the Nativity",
|
||||||
|
// 2 to 5 January is the window. 1 January is a Sunday only in a year
|
||||||
|
// where that window holds none, so the two fold into one range.
|
||||||
|
(1, 1..=5) if sunday => "The Sunday before the Theophany",
|
||||||
|
(1, 7..=13) if sunday => "The Sunday after the Theophany",
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(title)
|
||||||
|
}
|
||||||
|
|||||||
+59
-1
@@ -9,7 +9,7 @@
|
|||||||
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
|
//! agree in some years, 2025 falls on 20 April by both, and can diverge by as
|
||||||
//! much as five weeks.
|
//! much as five weeks.
|
||||||
|
|
||||||
use crate::calendars::{Date, JulianDate};
|
use crate::calendars::{Date, JulianDate, Weekday, MAX_YEAR, MIN_YEAR};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
pub enum Paschalion {
|
pub enum Paschalion {
|
||||||
@@ -95,7 +95,10 @@ pub struct Movables {
|
|||||||
pub bright_week_end: Date,
|
pub bright_week_end: Date,
|
||||||
pub thomas_sunday: Date,
|
pub thomas_sunday: Date,
|
||||||
pub myrrhbearers: Date,
|
pub myrrhbearers: Date,
|
||||||
|
pub paralytic: Date,
|
||||||
pub midfeast: Date,
|
pub midfeast: Date,
|
||||||
|
pub samaritan_woman: Date,
|
||||||
|
pub blind_man: Date,
|
||||||
pub ascension: Date,
|
pub ascension: Date,
|
||||||
pub fathers_first_council: Date,
|
pub fathers_first_council: Date,
|
||||||
pub pentecost: Date,
|
pub pentecost: Date,
|
||||||
@@ -131,7 +134,10 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
|
|||||||
bright_week_end: d + 6,
|
bright_week_end: d + 6,
|
||||||
thomas_sunday: d + 7,
|
thomas_sunday: d + 7,
|
||||||
myrrhbearers: d + 14,
|
myrrhbearers: d + 14,
|
||||||
|
paralytic: d + 21,
|
||||||
midfeast: d + 24,
|
midfeast: d + 24,
|
||||||
|
samaritan_woman: d + 28,
|
||||||
|
blind_man: d + 35,
|
||||||
ascension: d + 39,
|
ascension: d + 39,
|
||||||
fathers_first_council: d + 42,
|
fathers_first_council: d + 42,
|
||||||
pentecost: d + 49,
|
pentecost: d + 49,
|
||||||
@@ -140,3 +146,55 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables {
|
|||||||
apostles_start: d + 57,
|
apostles_start: d + 57,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a day falls in the count of weeks from Pentecost, which is how the
|
||||||
|
/// books head the long ordinary stretch of the year: "the 32nd Sunday after
|
||||||
|
/// Pentecost".
|
||||||
|
///
|
||||||
|
/// The count runs from the Monday after Pentecost to the eve of the next
|
||||||
|
/// Sunday of the Publican and the Pharisee, and it is `None` outside that,
|
||||||
|
/// through the Triodion and the Pentecostarion, where the Sundays have names
|
||||||
|
/// of their own instead of a number.
|
||||||
|
///
|
||||||
|
/// The governing Pentecost is not always the one in this day's civil year: a
|
||||||
|
/// day in January is counted from the Pentecost of the year before. That is
|
||||||
|
/// the one place this differs from `days_to_pascha`, which is always reckoned
|
||||||
|
/// against its own year's Pascha.
|
||||||
|
pub fn week_after_pentecost(date: Date, p: Paschalion) -> Option<i32> {
|
||||||
|
let year = date.year();
|
||||||
|
// A day at either end of the range this calendar answers for would need a
|
||||||
|
// Pascha outside it. One week unlabelled beats a panic.
|
||||||
|
if !(MIN_YEAR + 1..=MAX_YEAR - 1).contains(&year) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let this = movable_cycle(year, p);
|
||||||
|
let (pentecost, next_publican) = if date > this.pentecost {
|
||||||
|
(this.pentecost, movable_cycle(year + 1, p).publican_pharisee)
|
||||||
|
} else {
|
||||||
|
(movable_cycle(year - 1, p).pentecost, this.publican_pharisee)
|
||||||
|
};
|
||||||
|
if date <= pentecost || date >= next_publican {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((date - pentecost + 6) / 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same count, worded. Lowercase and article-first, like `cycle`, because
|
||||||
|
/// both are phrases a front end drops into a sentence of its own.
|
||||||
|
pub fn week_after_pentecost_label(date: Date, weekday: Weekday, p: Paschalion) -> Option<String> {
|
||||||
|
let n = week_after_pentecost(date, p)?;
|
||||||
|
let unit = if weekday == Weekday::Sunday { "Sunday" } else { "week" };
|
||||||
|
Some(format!("the {} {unit} after Pentecost", ordinal(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// English ordinals, for the one place this calendar has to count out loud.
|
||||||
|
fn ordinal(n: i32) -> String {
|
||||||
|
let suffix = match (n % 100, n % 10) {
|
||||||
|
(11..=13, _) => "th",
|
||||||
|
(_, 1) => "st",
|
||||||
|
(_, 2) => "nd",
|
||||||
|
(_, 3) => "rd",
|
||||||
|
_ => "th",
|
||||||
|
};
|
||||||
|
format!("{n}{suffix}")
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ impl PyDay {
|
|||||||
self.inner.cycle.as_deref()
|
self.inner.cycle.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[getter]
|
||||||
|
fn week(&self) -> Option<&str> {
|
||||||
|
self.inner.week.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
#[getter]
|
#[getter]
|
||||||
fn feast(&self) -> Option<&str> {
|
fn feast(&self) -> Option<&str> {
|
||||||
self.inner.feast.as_deref()
|
self.inner.feast.as_deref()
|
||||||
@@ -203,6 +208,7 @@ impl PyDay {
|
|||||||
d.set_item("church_date", j.church_date)?;
|
d.set_item("church_date", j.church_date)?;
|
||||||
d.set_item("season", j.season)?;
|
d.set_item("season", j.season)?;
|
||||||
d.set_item("cycle", j.cycle)?;
|
d.set_item("cycle", j.cycle)?;
|
||||||
|
d.set_item("week", j.week)?;
|
||||||
d.set_item("feast", j.feast)?;
|
d.set_item("feast", j.feast)?;
|
||||||
d.set_item("rank", j.rank)?;
|
d.set_item("rank", j.rank)?;
|
||||||
d.set_item("sign", j.sign)?;
|
d.set_item("sign", j.sign)?;
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface Day {
|
|||||||
season: string | null;
|
season: string | null;
|
||||||
/** The place in the movable cycle, or null outside it. */
|
/** The place in the movable cycle, or null outside it. */
|
||||||
cycle: string | null;
|
cycle: string | null;
|
||||||
|
/** "the 32nd Sunday after Pentecost", or null outside that count. */
|
||||||
|
week: string | null;
|
||||||
feast: string | null;
|
feast: string | null;
|
||||||
/** The rank of the feast, or null where there is none. */
|
/** The rank of the feast, or null where there is none. */
|
||||||
rank: string | null;
|
rank: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! The packaging metadata, which lives in three files and is published to two
|
||||||
|
//! registries.
|
||||||
|
//!
|
||||||
|
//! `Cargo.toml` describes the crate, `pyproject.toml` is what maturin puts on
|
||||||
|
//! PyPI, and `npm/package.json.in` is what npm shows. Nothing makes them agree
|
||||||
|
//! and nothing notices when they stop: a description edited in one of the three
|
||||||
|
//! reaches one registry and not the other, and the wrong one is then frozen
|
||||||
|
//! into a release, because neither registry lets published metadata be edited.
|
||||||
|
//! That has happened once already.
|
||||||
|
//!
|
||||||
|
//! The two READMEs open with the same sentence, because that paragraph is the
|
||||||
|
//! first thing shown on both registry pages and is what most people will read
|
||||||
|
//! instead of the description field.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
/// The value of a `key = "…"` line, from the first section that has one.
|
||||||
|
fn toml_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
let Some(rest) = line.strip_prefix(key) else { continue };
|
||||||
|
let Some(rest) = rest.trim_start().strip_prefix('=') else { continue };
|
||||||
|
let rest = rest.trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `{key} = \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of a `"key": "…"` line. Enough for a hand-written file with no
|
||||||
|
/// escapes in it, and the test below fails loudly rather than quietly if that
|
||||||
|
/// ever stops being true.
|
||||||
|
fn json_string(path: &str, key: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let needle = format!("\"{key}\":");
|
||||||
|
for line in text.lines() {
|
||||||
|
let Some(rest) = line.trim().strip_prefix(&needle) else { continue };
|
||||||
|
let rest = rest.trim().trim_end_matches(',').trim();
|
||||||
|
if let Some(v) = rest.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
|
||||||
|
assert!(!v.contains('\\'), "{path}: {key} has escapes this reader cannot handle");
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{path}: no `\"{key}\": \"…\"` line");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_three_descriptions_are_the_same_sentence() {
|
||||||
|
let cargo = toml_string("Cargo.toml", "description");
|
||||||
|
let pyproject = toml_string("pyproject.toml", "description");
|
||||||
|
let npm = json_string("npm/package.json.in", "description");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
cargo, pyproject,
|
||||||
|
"Cargo.toml and pyproject.toml disagree; pyproject.toml is the one PyPI shows"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cargo, npm,
|
||||||
|
"Cargo.toml and npm/package.json.in disagree; the latter is the one npm shows"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_description_says_what_the_crate_is_called() {
|
||||||
|
// Cheap guard against a description edited into something that no longer
|
||||||
|
// describes this package at all.
|
||||||
|
let d = toml_string("Cargo.toml", "description");
|
||||||
|
assert!(d.len() > 40, "description is too short to be one: {d:?}");
|
||||||
|
assert!(!d.ends_with(' '), "description has a trailing space");
|
||||||
|
assert!(d.contains("Orthodox"), "description no longer mentions Orthodox: {d:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The opening paragraph of a README, unwrapped to one line.
|
||||||
|
fn lede(path: &str) -> String {
|
||||||
|
let text = fs::read_to_string(path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
let body = text
|
||||||
|
.split("\n\n")
|
||||||
|
.find(|p| !p.trim().is_empty() && !p.trim_start().starts_with('#'))
|
||||||
|
.unwrap_or_else(|| panic!("{path}: no opening paragraph"));
|
||||||
|
body.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_readmes_open_with_the_description() {
|
||||||
|
// A registry page leads with the README, not with the description field,
|
||||||
|
// so the sentence a reader actually meets is this one. npm's README is a
|
||||||
|
// separate file from the crate's and had gone its own way: it described
|
||||||
|
// the package without mentioning that it covers Byzantine Catholic
|
||||||
|
// churches at all.
|
||||||
|
let description = toml_string("Cargo.toml", "description");
|
||||||
|
assert_eq!(
|
||||||
|
lede("README.md"),
|
||||||
|
description,
|
||||||
|
"README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lede("npm/README.md"),
|
||||||
|
description,
|
||||||
|
"npm/README.md does not open with the published description"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import sys
|
|||||||
|
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
"date", "weekday", "jurisdiction", "calendar", "churchDate", "season",
|
"date", "weekday", "jurisdiction", "calendar", "churchDate", "season",
|
||||||
"cycle", "feast", "rank", "sign", "tradition", "title", "commemorations",
|
"cycle", "week", "feast", "rank", "sign", "tradition", "title", "commemorations",
|
||||||
"fast", "fastLabel", "emoji", "reason", "obligation", "binds", "pascha",
|
"fast", "fastLabel", "emoji", "reason", "obligation", "binds", "pascha",
|
||||||
"daysToPascha", "julianOffset",
|
"daysToPascha", "julianOffset",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ int main(int argc, char **argv) {
|
|||||||
field(f, "churchDate", typikon_day_church_date(day), &first);
|
field(f, "churchDate", typikon_day_church_date(day), &first);
|
||||||
field(f, "season", typikon_day_season(day), &first);
|
field(f, "season", typikon_day_season(day), &first);
|
||||||
field(f, "cycle", typikon_day_cycle(day), &first);
|
field(f, "cycle", typikon_day_cycle(day), &first);
|
||||||
|
field(f, "week", typikon_day_week(day), &first);
|
||||||
field(f, "feast", typikon_day_feast(day), &first);
|
field(f, "feast", typikon_day_feast(day), &first);
|
||||||
field(f, "rank", typikon_day_rank(day), &first);
|
field(f, "rank", typikon_day_rank(day), &first);
|
||||||
field(f, "sign", typikon_day_sign(day), &first);
|
field(f, "sign", typikon_day_sign(day), &first);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath,
|
|||||||
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
||||||
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
||||||
cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign,
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
||||||
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ local probes = {
|
|||||||
local fields = {
|
local fields = {
|
||||||
{"date","iso"}, {"weekday","weekday"}, {"jurisdiction","jurisdiction"},
|
{"date","iso"}, {"weekday","weekday"}, {"jurisdiction","jurisdiction"},
|
||||||
{"calendar","calendar"}, {"churchDate","church_date"}, {"season","season"},
|
{"calendar","calendar"}, {"churchDate","church_date"}, {"season","season"},
|
||||||
{"cycle","cycle"}, {"feast","feast"}, {"rank","rank"}, {"sign","sign"},
|
{"cycle","cycle"}, {"week","week"}, {"feast","feast"}, {"rank","rank"}, {"sign","sign"},
|
||||||
{"tradition","tradition"}, {"title","title"}, {"fast","fast"},
|
{"tradition","tradition"}, {"title","title"}, {"fast","fast"},
|
||||||
{"fastLabel","fast_label"}, {"emoji","emoji"}, {"reason","reason"},
|
{"fastLabel","fast_label"}, {"emoji","emoji"}, {"reason","reason"},
|
||||||
{"obligation","obligation"}, {"binds","binds"},
|
{"obligation","obligation"}, {"binds","binds"},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ for date, jurisdiction, tk in json.load(open(probes_path)):
|
|||||||
"churchDate": day.church_date,
|
"churchDate": day.church_date,
|
||||||
"season": day.season,
|
"season": day.season,
|
||||||
"cycle": day.cycle,
|
"cycle": day.cycle,
|
||||||
|
"week": day.week,
|
||||||
"feast": day.feast,
|
"feast": day.feast,
|
||||||
"rank": day.rank,
|
"rank": day.rank,
|
||||||
"sign": day.sign,
|
"sign": day.sign,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath,
|
|||||||
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = {
|
||||||
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
calendar: d.calendar, churchDate: d.churchDate, season,
|
calendar: d.calendar, churchDate: d.churchDate, season,
|
||||||
cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign,
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
tradition: d.tradition, title: d.title, commemorations,
|
tradition: d.tradition, title: d.title, commemorations,
|
||||||
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function shape(d) {
|
|||||||
return {
|
return {
|
||||||
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction,
|
||||||
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
calendar: d.calendar, churchDate: d.churchDate, season: d.season,
|
||||||
cycle: d.cycle, feast: d.feast, rank: d.rank, sign: d.sign,
|
cycle: d.cycle, week: d.week, feast: d.feast, rank: d.rank, sign: d.sign,
|
||||||
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
tradition: d.tradition, title: d.title, commemorations: d.commemorations,
|
||||||
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason,
|
||||||
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
obligation: d.obligation, binds: d.binds, pascha: d.pascha,
|
||||||
|
|||||||
+196
-2
@@ -3,11 +3,11 @@
|
|||||||
|
|
||||||
use typikon::calendars::{Date, JulianDate, Weekday};
|
use typikon::calendars::{Date, JulianDate, Weekday};
|
||||||
use typikon::day::{describe, week, year_of};
|
use typikon::day::{describe, week, year_of};
|
||||||
use typikon::export::to_ics;
|
use typikon::export::{to_ics, to_ics_with, Ics};
|
||||||
use typikon::fasting::{Level, Obligation};
|
use typikon::fasting::{Level, Obligation};
|
||||||
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
|
use typikon::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS};
|
||||||
use typikon::menologion::Rank;
|
use typikon::menologion::Rank;
|
||||||
use typikon::paschalion::{gregorian_pascha, julian_pascha, Paschalion};
|
use typikon::paschalion::{gregorian_pascha, julian_pascha, week_after_pentecost, Paschalion};
|
||||||
use typikon::seasons::Season;
|
use typikon::seasons::Season;
|
||||||
|
|
||||||
fn d(s: &str) -> Date {
|
fn d(s: &str) -> Date {
|
||||||
@@ -366,6 +366,200 @@ fn ics_is_well_formed() {
|
|||||||
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
|
assert_eq!(text.matches("BEGIN:VEVENT").count(), text.matches("END:VEVENT").count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_day_gives_every_day_an_event() {
|
||||||
|
let days = week(d("2026-09-21"), 60, resolve(Some("ruthenian")).unwrap());
|
||||||
|
let thin = to_ics(&days, "Typikon", "20260101T000000Z");
|
||||||
|
let full = to_ics_with(
|
||||||
|
&days,
|
||||||
|
&Ics { name: "Typikon", stamp: "20260101T000000Z", every_day: true },
|
||||||
|
);
|
||||||
|
assert!(thin.matches("BEGIN:VEVENT").count() < 60, "the thinned calendar drops days");
|
||||||
|
assert_eq!(full.matches("BEGIN:VEVENT").count(), 60);
|
||||||
|
for line in full.split("\r\n") {
|
||||||
|
assert!(line.len() <= 75, "{} octets: {line}", line.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_event_carries_the_saints_and_the_fast() {
|
||||||
|
// A ferial Wednesday of the Great Fast: no feast, a fast that binds, and
|
||||||
|
// a saint the thinned calendar would keep but the old body did not name
|
||||||
|
// until after the church date.
|
||||||
|
let days = week(d("2027-03-17"), 1, resolve(Some("ruthenian")).unwrap());
|
||||||
|
let text = to_ics_with(
|
||||||
|
&days,
|
||||||
|
&Ics { name: "Typikon", stamp: "20260101T000000Z", every_day: true },
|
||||||
|
);
|
||||||
|
let unfolded = text.replace("\r\n ", "");
|
||||||
|
let description = unfolded
|
||||||
|
.lines()
|
||||||
|
.find(|l| l.starts_with("DESCRIPTION:"))
|
||||||
|
.expect("an event with a description");
|
||||||
|
assert!(description.contains("\u{2022} Our Venerable Father Alexis the Man of God"),
|
||||||
|
"{description}");
|
||||||
|
assert!(description.contains("Fast: no meat"), "{description}");
|
||||||
|
assert!(description.contains("Obligatory."), "{description}");
|
||||||
|
assert!(description.contains("Church date: 17 March"), "{description}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────── the Sundays of the movable year ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pentecostarion_names_all_of_its_sundays() {
|
||||||
|
// Pascha 2027 is 28 March by the Gregorian paschalion and 2 May by the
|
||||||
|
// Julian one, so the same Sundays are asked of both reckonings.
|
||||||
|
for (jur, pascha) in [("ruthenian", "2027-03-28"), ("rocor", "2027-05-02")] {
|
||||||
|
let pascha = d(pascha);
|
||||||
|
for (offset, name) in [
|
||||||
|
(7, "Thomas Sunday"),
|
||||||
|
(14, "The Sunday of the Holy Myrrh-bearing Women"),
|
||||||
|
(21, "The Sunday of the Paralytic"),
|
||||||
|
(28, "The Sunday of the Samaritan Woman"),
|
||||||
|
(35, "The Sunday of the Blind Man"),
|
||||||
|
(42, "The Sunday of the Fathers of the First Council"),
|
||||||
|
] {
|
||||||
|
let day = describe(pascha + offset, resolve(Some(jur)).unwrap());
|
||||||
|
assert_eq!(day.weekday, Weekday::Sunday, "{jur} +{offset}");
|
||||||
|
let named = day.title().starts_with(name)
|
||||||
|
|| day.commemorations.iter().any(|c| c.starts_with(name));
|
||||||
|
assert!(named, "{jur} {} ({}) does not name {name}: {:?}",
|
||||||
|
day.date, offset, day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_vigil_rank_saint_keeps_the_day_and_the_sunday_joins_it() {
|
||||||
|
// 25 April 2027 is both the Sunday of the Samaritan Woman and St Mark,
|
||||||
|
// whose feast is of vigil rank. The apostle names the day; the Sunday is
|
||||||
|
// commemorated on it.
|
||||||
|
let day = at("2027-04-25", "ruthenian");
|
||||||
|
assert_eq!(day.title(), "The Holy Apostle and Evangelist Mark");
|
||||||
|
assert_eq!(day.rank, Some(Rank::Vigil));
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == "The Sunday of the Samaritan Woman"),
|
||||||
|
"{:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_sundays_reckoned_from_a_fixed_feast_are_kept() {
|
||||||
|
for (date, jur, name) in [
|
||||||
|
("2026-09-13", "ruthenian", "The Sunday before the Exaltation"),
|
||||||
|
("2026-09-20", "ruthenian", "The Sunday after the Exaltation"),
|
||||||
|
("2026-12-13", "ruthenian", "The Sunday of the Holy Forefathers"),
|
||||||
|
("2026-12-20", "ruthenian", "The Sunday before the Nativity \u{2014} the Holy Fathers"),
|
||||||
|
("2026-12-27", "ruthenian", "The Sunday after the Nativity"),
|
||||||
|
("2027-01-03", "ruthenian", "The Sunday before the Theophany"),
|
||||||
|
("2027-01-10", "ruthenian", "The Sunday after the Theophany"),
|
||||||
|
// The old calendar keeps the same windows thirteen days later, which
|
||||||
|
// is the whole point of reckoning them in ecclesiastical space.
|
||||||
|
("2027-01-03", "moscow", "The Sunday before the Nativity \u{2014} the Holy Fathers"),
|
||||||
|
("2027-01-10", "moscow", "The Sunday after the Nativity"),
|
||||||
|
("2027-01-17", "moscow", "The Sunday before the Theophany"),
|
||||||
|
] {
|
||||||
|
let day = at(date, jur);
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == name),
|
||||||
|
"{jur} {date}: {name} missing from {:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nativity_on_a_sunday_moves_the_sunday_after_it_to_the_26th() {
|
||||||
|
// 25 December 2033 is a Sunday, so 26–31 December holds none and the
|
||||||
|
// Typikon keeps the Sunday after the Nativity on the Monday.
|
||||||
|
assert_eq!(at("2033-12-25", "ruthenian").weekday, Weekday::Sunday);
|
||||||
|
let monday = at("2033-12-26", "ruthenian");
|
||||||
|
assert_eq!(monday.weekday, Weekday::Monday);
|
||||||
|
assert!(monday.commemorations.iter().any(|c| c == "The Sunday after the Nativity"),
|
||||||
|
"{:?}", monday.commemorations);
|
||||||
|
// And it is kept once, not twice.
|
||||||
|
for n in 1..=6 {
|
||||||
|
let day = describe(d("2033-12-26") + n, resolve(Some("ruthenian")).unwrap());
|
||||||
|
assert!(!day.commemorations.iter().any(|c| c == "The Sunday after the Nativity"),
|
||||||
|
"{}", day.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_year_on_a_sunday_keeps_the_sunday_before_the_theophany_with_it() {
|
||||||
|
let day = at("2034-01-01", "ruthenian");
|
||||||
|
assert_eq!(day.weekday, Weekday::Sunday);
|
||||||
|
assert!(day.commemorations.iter().any(|c| c == "The Sunday before the Theophany"),
|
||||||
|
"{:?}", day.commemorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_sunday_of_the_year_is_named_twice() {
|
||||||
|
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
|
||||||
|
for day in week(d("2024-01-01"), 366 * 12, jur) {
|
||||||
|
let mut seen = day.commemorations.clone();
|
||||||
|
seen.sort();
|
||||||
|
let before = seen.len();
|
||||||
|
seen.dedup();
|
||||||
|
assert_eq!(seen.len(), before, "{} {}: {:?}", jur.key, day.date, day.commemorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────── the count of weeks from Pentecost ─────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_weeks_after_pentecost_are_counted_from_all_saints() {
|
||||||
|
let p = Paschalion::Gregorian;
|
||||||
|
// Pascha 2026 is 5 April, so Pentecost is 24 May and All Saints 31 May.
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-24"), p), None, "Pentecost itself");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-25"), p), Some(1), "the Monday after");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-05-31"), p), Some(1), "All Saints");
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-06-07"), p), Some(2));
|
||||||
|
assert_eq!(week_after_pentecost(d("2026-09-27"), p), Some(18));
|
||||||
|
// The count does not restart at the new year: it runs on from last
|
||||||
|
// year's Pentecost until the Triodion opens.
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-10"), p), Some(33));
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-16"), p), Some(34));
|
||||||
|
// 17 January 2027 is the Publican and the Pharisee, and the Triodion
|
||||||
|
// names its own Sundays.
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-01-17"), p), None);
|
||||||
|
assert_eq!(week_after_pentecost(d("2027-03-28"), p), None, "Pascha");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_week_is_worded_for_the_day_it_falls_on() {
|
||||||
|
assert_eq!(at("2026-09-27", "ruthenian").week.as_deref(),
|
||||||
|
Some("the 18th Sunday after Pentecost"));
|
||||||
|
assert_eq!(at("2026-09-28", "ruthenian").week.as_deref(),
|
||||||
|
Some("the 19th week after Pentecost"));
|
||||||
|
// The Pentecostarion and the Triodion carry names instead of numbers.
|
||||||
|
assert_eq!(at("2027-04-18", "ruthenian").week, None);
|
||||||
|
assert_eq!(at("2027-02-14", "ruthenian").week, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_week_count_never_skips_or_repeats_a_number() {
|
||||||
|
for jur in JURISDICTIONS.iter().chain(CALENDARS.iter()) {
|
||||||
|
// The walk starts mid-count, so the first run it sees is not a run
|
||||||
|
// that opened. Every later one is.
|
||||||
|
let mut last: Option<i32> = None;
|
||||||
|
let mut first_run = true;
|
||||||
|
for day in week(d("2025-01-01"), 366 * 6, jur) {
|
||||||
|
let Some(n) = week_after_pentecost(day.date, jur.paschalion) else {
|
||||||
|
if last.is_some() {
|
||||||
|
first_run = false;
|
||||||
|
}
|
||||||
|
last = None;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match last {
|
||||||
|
Some(prev) => assert!(n == prev || n == prev + 1,
|
||||||
|
"{} {}: {prev} then {n}", jur.key, day.date),
|
||||||
|
None if !first_run => assert_eq!(n, 1,
|
||||||
|
"{} {}: the count opens at one", jur.key, day.date),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
last = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────── every jurisdiction ───────────────────────────
|
// ─────────────────────────── every jurisdiction ───────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user