diff --git a/CHANGELOG.md b/CHANGELOG.md index b3702f2..bedc825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,64 @@ Notable changes, in the terms [docs/versioning.md](docs/versioning.md) sets out. **Calendar** entries change what a day reports, whatever the version number 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 diff --git a/Cargo.lock b/Cargo.lock index a474676..498d6c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,7 +404,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "typikon" -version = "1.1.2" +version = "1.2.0" dependencies = [ "clap", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index d4e543d..16e5e56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "typikon" -version = "1.1.2" +version = "1.2.0" edition = "2021" 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." diff --git a/README.md b/README.md index f14e229..f8d91ac 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,16 @@ $ typikon --format json --days 7 # for a bar, a widget, a script $ 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 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: ⊕ 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. @@ -332,6 +347,7 @@ $ typikon -j rocor --date 2026-03-18 --json --indent 2 "churchDate": "5 March", "season": "Great Fast", "cycle": null, + "week": null, "feast": null, "rank": null, "sign": null, diff --git a/docs/versioning.md b/docs/versioning.md index 103de11..531584c 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -21,13 +21,16 @@ tradition title commemorations fast fastLabel emoji reason obligation binds pascha daysToPascha julianOffset ``` +`week` was added in 1.2.0. + `fastLabel`, `emoji` and `reason` are **presentation**: the fields are always there, and their wording is not stable. Key on `fast`, `season`, `rank`, `obligation` and `binds`. 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 -`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: diff --git a/examples/lua/typikon.lua b/examples/lua/typikon.lua index d406133..de23b49 100644 --- a/examples/lua/typikon.lua +++ b/examples/lua/typikon.lua @@ -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_season(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_rank(const typikon_day *day); const char *typikon_day_sign(const typikon_day *day); @@ -79,7 +80,7 @@ end local FIELDS = { "iso", "weekday", "jurisdiction", "calendar", "tradition", "church_date", "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 diff --git a/include/typikon.h b/include/typikon.h index 02d6fb7..0333dc9 100644 --- a/include/typikon.h +++ b/include/typikon.h @@ -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. */ 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_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_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 */ diff --git a/python/typikon/_typikon.pyi b/python/typikon/_typikon.pyi index 77778ea..7b184a3 100644 --- a/python/typikon/_typikon.pyi +++ b/python/typikon/_typikon.pyi @@ -24,6 +24,8 @@ class Day: @property def cycle(self) -> str | None: ... @property + def week(self) -> str | None: ... + @property def feast(self) -> str | None: ... @property def rank(self) -> str | None: ... diff --git a/src/capi.rs b/src/capi.rs index 355a4ac..5bf22a6 100644 --- a/src/capi.rs +++ b/src/capi.rs @@ -171,6 +171,7 @@ pub struct typikon_day { reason: CString, season: Option, cycle: Option, + week: Option, feast: Option, rank: Option, sign: Option, @@ -223,6 +224,7 @@ fn build( reason: cs(&inner.reason), season: inner.season.map(|s| cs(s.name())), cycle: inner.cycle.as_deref().map(&cs), + week: inner.week.as_deref().map(&cs), feast: inner.feast.as_deref().map(&cs), rank: inner.rank.map(|r| cs(r.key())), 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_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_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_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()); diff --git a/src/day.rs b/src/day.rs index 11eea75..e1a2370 100644 --- a/src/day.rs +++ b/src/day.rs @@ -11,7 +11,7 @@ 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::paschalion::{movable_cycle, week_after_pentecost_label, Movables}; use crate::rules::{self, Context}; use crate::seasons::season_for; @@ -25,6 +25,10 @@ pub struct Day { pub church_date: String, pub season: Option, pub cycle: Option, + /// 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, pub feast: Option, pub rank: Option, pub tradition: &'static str, @@ -81,6 +85,7 @@ pub struct DayJson<'a> { pub church_date: &'a str, pub season: Option<&'a str>, pub cycle: Option<&'a str>, + pub week: Option<&'a str>, pub feast: Option<&'a str>, pub rank: Option<&'a str>, /// The mark the books print beside a day of this rank. @@ -109,6 +114,7 @@ impl Day { church_date: &self.church_date, season: self.season.map(|s| s.name()), cycle: self.cycle.as_deref(), + week: self.week.as_deref(), feast: self.feast.as_deref(), rank: self.rank.map(|r| r.key()), 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(), }; + // 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 { civil: date, 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]), season, cycle: entry.cycle, + week: week_after_pentecost_label(date, date.weekday(), jur.paschalion), feast, rank, tradition: jur.tradition.key(), diff --git a/src/export/csv.rs b/src/export/csv.rs index d7a37cf..72e5521 100644 --- a/src/export/csv.rs +++ b/src/export/csv.rs @@ -8,7 +8,7 @@ use crate::day::Day; pub const COLUMNS: &[&str] = &[ "date", "weekday", "jurisdiction", "calendar", "church_date", "season", - "cycle", "feast", "rank", "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", "julian_offset", "commemorations", ]; @@ -33,6 +33,7 @@ fn cell(day: &Day, column: &str) -> String { "church_date" => day.church_date.clone(), "season" => day.season.map(|s| s.name()).unwrap_or("").to_string(), "cycle" => day.cycle.clone().unwrap_or_default(), + "week" => day.week.clone().unwrap_or_default(), "feast" => day.feast.clone().unwrap_or_default(), "rank" => day.rank.map(|r| r.key()).unwrap_or("").to_string(), "sign" => day.rank.map(|r| r.sign()).unwrap_or("").to_string(), diff --git a/src/export/ics.rs b/src/export/ics.rs index 4e33ee6..cbd5674 100644 --- a/src/export/ics.rs +++ b/src/export/ics.rs @@ -1,14 +1,29 @@ //! 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. +//! principal commemoration; the description lists every commemoration, then the +//! fast, then where the day sits in the year. +//! +//! 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::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 newtype because the Python this was ported from folded on *characters* @@ -61,54 +76,75 @@ fn fold(line: &str) -> String { 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 { + to_ics_with(days, &Ics { name, stamp, every_day: false }) +} + +pub fn to_ics_with(days: &[Day], opts: &Ics<'_>) -> String { let mut lines: Vec = vec![ "BEGIN:VCALENDAR".into(), "VERSION:2.0".into(), "PRODID:-//typikon//EN".into(), "CALSCALE:GREGORIAN".into(), "METHOD:PUBLISH".into(), - format!("X-WR-CALNAME:{}", escape(name)), + format!("X-WR-CALNAME:{}", escape(opts.name)), ]; 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; } 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()) - }; + // The glyph for the fast and the mark for the rank, in the order the + // text view prints them, so one is scannable down a month view and the + // other says at a glance whether the day is kept. + 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::>() + .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 = 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.commemorations.is_empty() { + body.push( + d.commemorations + .iter() + .map(|c| format!("\u{2022} {c}")) + .collect::>() + .join("\n"), + ); } + + let mut fast = 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()); + 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 { - body.push(format!("Season: {}", s.name())); + where_.push(format!("Season: {}", s.name())); } if let Some(c) = &d.cycle { - let mut chars = c.chars(); - let capped = match chars.next() { - Some(f) => f.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - }; - body.push(capped); - } - body.push(format!("Church date: {}", d.church_date)); - for c in &d.commemorations { - body.push(c.clone()); + where_.push(capitalised(c)); } + where_.push(format!("Church date: {}", d.church_date)); + body.push(where_.join("\n")); lines.extend([ "BEGIN:VEVENT".into(), @@ -123,11 +159,11 @@ pub fn to_ics(days: &[Day], name: &str, stamp: &str) -> String { d.tradition, if d.shown_at_typikon { "typikon" } else { "particular" } ), - format!("DTSTAMP:{stamp}"), + format!("DTSTAMP:{}", opts.stamp), format!("DTSTART;VALUE=DATE:{start}"), format!("DTEND;VALUE=DATE:{end}"), fold(&format!("SUMMARY:{}", escape(&summary))), - fold(&format!("DESCRIPTION:{}", escape(&body.join("\n")))), + fold(&format!("DESCRIPTION:{}", escape(&body.join("\n\n")))), "TRANSP:TRANSPARENT".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 } + +/// 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::() + chars.as_str(), + None => String::new(), + } +} diff --git a/src/export/mod.rs b/src/export/mod.rs index 19212fb..f63edcc 100644 --- a/src/export/mod.rs +++ b/src/export/mod.rs @@ -3,5 +3,5 @@ pub mod ics; pub mod json; pub use csv::to_delimited; -pub use ics::to_ics; +pub use ics::{to_ics, to_ics_with, Ics}; pub use json::to_json; diff --git a/src/main.rs b/src/main.rs index 2ab4eda..b3091c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use clap::{Parser, ValueEnum}; use typikon::calendars::Date; use typikon::config::{self, Source}; 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::menaia; use typikon::rules; @@ -67,6 +67,11 @@ struct Args { #[arg(long, conflicts_with = "format")] 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 #[arg(long)] no_emoji: bool, @@ -222,6 +227,9 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) { } else { println!(" {} {sign}{}", d.emoji, d.title()); } + if let Some(w) = &d.week { + println!(" {w}"); + } if let Some(c) = &d.cycle { println!(" {c}"); } @@ -443,7 +451,12 @@ fn run() -> Result { return Ok(0); } 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); } Format::Csv | Format::Tsv => { diff --git a/src/menologion.rs b/src/menologion.rs index 7fabaa3..5b6e473 100644 --- a/src/menologion.rs +++ b/src/menologion.rs @@ -14,7 +14,7 @@ use std::collections::BTreeMap; use serde::Deserialize; -use crate::calendars::Date; +use crate::calendars::{Date, Weekday}; use crate::jurisdictions::Tradition; 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 /// whatever the fixed calendar holds for the day. pub fn movable_feast_for(civil: Date, mv: &Movables) -> Option<(&'static str, Option)> { - let table: [(Date, &'static str, Option); 24] = [ + let table: [(Date, &'static str, Option); 27] = [ (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)), @@ -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.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.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.all_saints, "The Sunday of All Saints", Some(Rank::Polyeleos)), (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) .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) +} diff --git a/src/paschalion.rs b/src/paschalion.rs index 4132c66..425f14e 100644 --- a/src/paschalion.rs +++ b/src/paschalion.rs @@ -9,7 +9,7 @@ //! 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}; +use crate::calendars::{Date, JulianDate, Weekday, MAX_YEAR, MIN_YEAR}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Paschalion { @@ -95,7 +95,10 @@ pub struct Movables { pub bright_week_end: Date, pub thomas_sunday: Date, pub myrrhbearers: Date, + pub paralytic: Date, pub midfeast: Date, + pub samaritan_woman: Date, + pub blind_man: Date, pub ascension: Date, pub fathers_first_council: Date, pub pentecost: Date, @@ -131,7 +134,10 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables { bright_week_end: d + 6, thomas_sunday: d + 7, myrrhbearers: d + 14, + paralytic: d + 21, midfeast: d + 24, + samaritan_woman: d + 28, + blind_man: d + 35, ascension: d + 39, fathers_first_council: d + 42, pentecost: d + 49, @@ -140,3 +146,55 @@ pub fn movable_cycle(year: i32, p: Paschalion) -> Movables { 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 { + 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 { + 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}") +} diff --git a/src/python.rs b/src/python.rs index 325268b..7f814d2 100644 --- a/src/python.rs +++ b/src/python.rs @@ -106,6 +106,11 @@ impl PyDay { self.inner.cycle.as_deref() } + #[getter] + fn week(&self) -> Option<&str> { + self.inner.week.as_deref() + } + #[getter] fn feast(&self) -> Option<&str> { self.inner.feast.as_deref() @@ -203,6 +208,7 @@ impl PyDay { d.set_item("church_date", j.church_date)?; d.set_item("season", j.season)?; d.set_item("cycle", j.cycle)?; + d.set_item("week", j.week)?; d.set_item("feast", j.feast)?; d.set_item("rank", j.rank)?; d.set_item("sign", j.sign)?; diff --git a/src/wasm.rs b/src/wasm.rs index 8bca2e1..2f29c06 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -50,6 +50,8 @@ export interface Day { season: string | null; /** The place in the movable cycle, or null outside it. */ cycle: string | null; + /** "the 32nd Sunday after Pentecost", or null outside that count. */ + week: string | null; feast: string | null; /** The rank of the feast, or null where there is none. */ rank: string | null; diff --git a/tests/parity/oracle.py b/tests/parity/oracle.py index 0691f77..c5aa884 100644 --- a/tests/parity/oracle.py +++ b/tests/parity/oracle.py @@ -6,7 +6,7 @@ import sys FIELDS = [ "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", "daysToPascha", "julianOffset", ] diff --git a/tests/parity/probe.c b/tests/parity/probe.c index 139ffa5..0ca040f 100644 --- a/tests/parity/probe.c +++ b/tests/parity/probe.c @@ -62,6 +62,7 @@ int main(int argc, char **argv) { field(f, "churchDate", typikon_day_church_date(day), &first); field(f, "season", typikon_day_season(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, "rank", typikon_day_rank(day), &first); field(f, "sign", typikon_day_sign(day), &first); diff --git a/tests/parity/probe.cjs b/tests/parity/probe.cjs index b60406a..ae99586 100644 --- a/tests/parity/probe.cjs +++ b/tests/parity/probe.cjs @@ -10,7 +10,7 @@ for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = { date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, 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, fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, obligation: d.obligation, binds: d.binds, pascha: d.pascha, diff --git a/tests/parity/probe.lua b/tests/parity/probe.lua index 251980c..67a4fce 100644 --- a/tests/parity/probe.lua +++ b/tests/parity/probe.lua @@ -30,7 +30,7 @@ local probes = { local fields = { {"date","iso"}, {"weekday","weekday"}, {"jurisdiction","jurisdiction"}, {"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"}, {"fastLabel","fast_label"}, {"emoji","emoji"}, {"reason","reason"}, {"obligation","obligation"}, {"binds","binds"}, diff --git a/tests/parity/probe.py b/tests/parity/probe.py index f245cc1..8f0d0b0 100644 --- a/tests/parity/probe.py +++ b/tests/parity/probe.py @@ -18,6 +18,7 @@ for date, jurisdiction, tk in json.load(open(probes_path)): "churchDate": day.church_date, "season": day.season, "cycle": day.cycle, + "week": day.week, "feast": day.feast, "rank": day.rank, "sign": day.sign, diff --git a/tests/parity/probe.ts b/tests/parity/probe.ts index 82a0fcd..a3360ba 100644 --- a/tests/parity/probe.ts +++ b/tests/parity/probe.ts @@ -17,7 +17,7 @@ for (const [date, jurisdiction, typikon] of JSON.parse(readFileSync(probesPath, out[`${date}|${jurisdiction}|${typikon ? 1 : 0}`] = { date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, 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, fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, obligation: d.obligation, binds: d.binds, pascha: d.pascha, diff --git a/tests/parity/shape.mjs b/tests/parity/shape.mjs index 2054621..de27921 100644 --- a/tests/parity/shape.mjs +++ b/tests/parity/shape.mjs @@ -5,7 +5,7 @@ export function shape(d) { return { date: d.date, weekday: d.weekday, jurisdiction: d.jurisdiction, 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, fast: d.fast, fastLabel: d.fastLabel, emoji: d.emoji, reason: d.reason, obligation: d.obligation, binds: d.binds, pascha: d.pascha, diff --git a/tests/typikon.rs b/tests/typikon.rs index bacd2a6..c7b98f2 100644 --- a/tests/typikon.rs +++ b/tests/typikon.rs @@ -3,11 +3,11 @@ use typikon::calendars::{Date, JulianDate, Weekday}; 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::jurisdictions::{resolve, Calendar, MenologionKind, CALENDARS, JURISDICTIONS}; 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; 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()); } +#[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 = 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 ─────────────────────────── #[test]