Files
typikon/src/export/csv.rs
T

79 lines
3.0 KiB
Rust
Raw Normal View History

2026-09-18 11:15:22 -04:00
//! 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", "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(),
"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()))
}