Files
typikon/src/export/csv.rs
T
funkpower 56cc1a3d92 Greek and Slavic usage, and the marks the books print
Both keep the same Typikon and read it a little differently, and until now
this library silently picked one reading and called it the Typikon. Two
differences are modelled, each one visible in the output and named in the
day's reason:

  - The Protection of the Theotokos is a great feast in Slavic usage. Greek
    practice ranks it lower, and the Church of Greece keeps it on 28 October
    besides. This has teeth: rank is what lifts a Wednesday or Friday fast.
  - Greek practice admits fish on any day of the Nativity Fast but Wednesday
    and Friday until 17 December; Slavic practice keeps fish to Saturdays and
    Sundays throughout.

Every jurisdiction carries the usage its books follow, and --tradition
overrides it for a parish that does not match its jurisdiction's default. The
difference in the calendar lives in a data overlay compiled in beside the
common menologion, which is where a local church's own commemorations would
go; the difference in the fast lives in the rule, because that is where it is.

Each rank also now carries the mark the books print beside it, circled cross,
cross and Jerusalem cross for great, vigil and polyeleos. Unicode has no glyph
for the polyeleos sign, a cross over a half-circle, so the Jerusalem cross
stands in as digital calendars generally settle on.
2026-09-18 11:28:40 -04:00

81 lines
3.1 KiB
Rust

//! Delimited output, for the times a calendar is a table.
//!
//! One row per day, one header row, RFC 4180 quoting. `commemorations` is a
//! list, so it is joined with a semicolon: a cell that is itself a table is
//! worse than a cell you have to split.
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",
"reason", "obligation", "binds", "pascha", "days_to_pascha",
"julian_offset", "commemorations",
];
fn quote(field: &str, sep: char) -> String {
let needs = field.contains(sep)
|| field.contains('"')
|| field.contains('\n')
|| field.contains('\r');
if !needs {
return field.to_string();
}
format!("\"{}\"", field.replace('"', "\"\""))
}
fn cell(day: &Day, column: &str) -> String {
match column {
"date" => day.date.iso(),
"weekday" => day.weekday.name().to_string(),
"jurisdiction" => day.jurisdiction.to_string(),
"calendar" => day.calendar.to_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(),
"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(),
"tradition" => day.tradition.to_string(),
"title" => day.title().to_string(),
"fast" => day.fast.key().to_string(),
"fast_label" => day.fast_label.to_string(),
"emoji" => day.emoji.to_string(),
"reason" => day.reason.clone(),
"obligation" => match day.obligation {
Some(crate::fasting::Obligation::Obligatory) => "obligatory".into(),
Some(crate::fasting::Obligation::Customary) => "customary".into(),
None => String::new(),
},
"binds" => day.binds.key().to_string(),
"pascha" => day.pascha.iso(),
"days_to_pascha" => day.days_to_pascha.to_string(),
"julian_offset" => day.julian_offset.to_string(),
"commemorations" => day.commemorations.join("; "),
_ => String::new(),
}
}
/// `columns` empty means every column, in the documented order.
pub fn to_delimited(days: &[Day], sep: char, columns: &[String]) -> String {
let cols: Vec<&str> = if columns.is_empty() {
COLUMNS.to_vec()
} else {
columns.iter().map(String::as_str).collect()
};
let mut out = String::new();
out.push_str(&cols.iter().map(|c| quote(c, sep)).collect::<Vec<_>>().join(&sep.to_string()));
out.push('\n');
for day in days {
let row: Vec<String> = cols.iter().map(|c| quote(&cell(day, c), sep)).collect();
out.push_str(&row.join(&sep.to_string()));
out.push('\n');
}
out
}
/// Is every name a real column? Returns the first that is not.
pub fn unknown_column(columns: &[String]) -> Option<&String> {
columns.iter().find(|c| !COLUMNS.contains(&c.as_str()))
}