print it as a table, or without the glyphs

--format takes text, json, csv, tsv or ics. --json and --ics stay as
shorthand, because the widgets already call them.

csv and tsv are RFC 4180 quoted, one header row, one row per day, with
commemorations joined by a semicolon: a cell that is itself a table is worse
than a cell you have to split. --list-fields prints the twenty columns,
--fields picks a subset in the order you name them, and a column that does
not exist is caught before anything is printed rather than silently emitting
an empty cell.

--no-emoji drops the glyphs from every format, including the emoji field in
the JSON, and collapses the column in the text output rather than leaving a
hole where a glyph used to be.
This commit is contained in:
funkpower
2026-09-18 11:15:22 -04:00
parent d115616eff
commit 227abac6f6
5 changed files with 234 additions and 19 deletions
+11 -3
View File
@@ -120,11 +120,19 @@ spiritual father can.
## Output
```console
$ typikon --json --days 7 # for a bar, a widget, a script
$ typikon --ics --days 365 > orthodox.ics
$ typikon --year 2027 # a year's key dates, to check against yours
$ typikon --days 7 # for a person
$ typikon --format json --days 7 # for a bar, a widget, a script
$ typikon --format csv --days 365 # for a spreadsheet
$ typikon --format tsv --fields date,fast,title
$ typikon --format ics --days 365 > orthodox.ics
$ typikon --year 2027 # a year's key dates, to check against yours
```
`--no-emoji` drops the glyphs from every format, for a terminal or a bar
without them. `--list-fields` prints the columns `csv` and `tsv` can take, and
`--fields` picks a subset in the order you name them. `--json` and `--ics`
still work as shorthand.
The JSON shape is flat and stable. Front ends are the thing most likely to be
written by someone else, and a schema that shifts underneath them is a bad
neighbour.
+78
View File
@@ -0,0 +1,78 @@
//! 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()))
}
+2
View File
@@ -1,5 +1,7 @@
pub mod csv;
pub mod ics;
pub mod json;
pub use csv::to_delimited;
pub use ics::to_ics;
pub use json::to_json;
+84 -16
View File
@@ -1,15 +1,27 @@
//! The command line.
use clap::Parser;
use clap::{Parser, ValueEnum};
use typikon::calendars::Date;
use typikon::config::{self, Source};
use typikon::day::{week, year_of, Day};
use typikon::export::{to_ics, to_json};
use typikon::export::{to_delimited, to_ics, to_json};
use typikon::jurisdictions::{self, Jurisdiction, CALENDARS, JURISDICTIONS};
use typikon::menaia;
use typikon::rules;
/// The shapes this can print in. `text` is for a person, `json` for a front
/// end, `csv` and `tsv` for a spreadsheet or a pipeline, `ics` for a calendar
/// you subscribe to.
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
Text,
Json,
Csv,
Tsv,
Ics,
}
#[derive(Parser)]
#[command(
name = "typikon",
@@ -29,14 +41,30 @@ struct Args {
#[arg(short = 'n', long, default_value_t = 1)]
days: i32,
/// Machine-readable output
#[arg(long)]
/// How to print it
#[arg(short = 'f', long, value_enum, default_value_t = Format::Text)]
format: Format,
/// Shorthand for --format json
#[arg(long, conflicts_with = "ics")]
json: bool,
/// An iCalendar feed
/// Shorthand for --format ics
#[arg(long)]
ics: bool,
/// Leave the glyphs out, for a terminal or a bar without them
#[arg(long)]
no_emoji: bool,
/// Columns for csv and tsv, comma-separated; see --list-fields
#[arg(long, value_name = "LIST", value_delimiter = ',')]
fields: Vec<String>,
/// List the columns csv and tsv can print
#[arg(long)]
list_fields: bool,
/// Indent JSON output
#[arg(long)]
indent: Option<usize>,
@@ -141,7 +169,11 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
};
println!(" {} \u{b7} {}{from}", jur.name, rule.title);
println!();
println!(" {} {}", d.emoji, d.title());
if d.emoji.is_empty() {
println!(" {}", d.title());
} else {
println!(" {} {}", d.emoji, d.title());
}
if let Some(c) = &d.cycle {
println!(" {c}");
}
@@ -177,9 +209,11 @@ fn print_days(days: &[Day], jur: &Jurisdiction, source: Source, verbose: bool) {
let room = cols.saturating_sub(fixed + 1).max(24);
for d in days {
let mark = if d.is_obligatory() { "*" } else { " " };
// The glyph column collapses entirely when there is no glyph, rather
// than leaving a hole where one used to be.
let glyph = if d.emoji.is_empty() { String::new() } else { format!("{} ", d.emoji) };
println!(
" {} {:>10}{mark} {:<32}{}",
d.emoji,
" {glyph}{:>10}{mark} {:<32}{}",
d.date.short(),
d.fast_label,
elide(d.title(), room)
@@ -199,6 +233,15 @@ fn run() -> Result<i32, String> {
return Ok(0);
}
if args.list_fields {
println!("\n Columns for --format csv and --format tsv:\n");
for c in typikon::export::csv::COLUMNS {
println!(" {c}");
}
println!("\n Pick a subset with --fields date,fast,title\n");
return Ok(0);
}
if args.list_menaia {
println!("\n Supplementary commemorations, fetched on request:\n");
for s in menaia::SOURCES {
@@ -279,15 +322,40 @@ fn run() -> Result<i32, String> {
.ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
None => today(),
};
let days = week(start, args.days, jur);
if args.json {
println!("{}", to_json(&days, args.indent));
return Ok(0);
let mut days = week(start, args.days, jur);
if args.no_emoji {
for d in &mut days {
d.emoji = "";
}
}
if args.ics {
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
return Ok(0);
// The boolean flags predate --format and still mean what they did.
let format = if args.json {
Format::Json
} else if args.ics {
Format::Ics
} else {
args.format
};
match format {
Format::Json => {
println!("{}", to_json(&days, args.indent));
return Ok(0);
}
Format::Ics => {
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp()));
return Ok(0);
}
Format::Csv | Format::Tsv => {
if let Some(bad) = typikon::export::csv::unknown_column(&args.fields) {
return Err(format!("no such column {bad:?}; try --list-fields"));
}
let sep = if format == Format::Csv { ',' } else { '\t' };
print!("{}", to_delimited(&days, sep, &args.fields));
return Ok(0);
}
Format::Text => {}
}
print_days(&days, jur, source, args.verbose);
+59
View File
@@ -406,3 +406,62 @@ fn a_feast_can_only_relax_a_fast_never_tighten_it() {
// No rank means no change.
assert_eq!(Level::Xerophagy.relaxed_to(Level::Xerophagy, Level::Fish), Level::Xerophagy);
}
// ───────────────────────── the delimited formats ──────────────────────────
#[test]
fn csv_has_a_header_and_one_row_per_day() {
use typikon::export::to_delimited;
let days = week(d("2026-01-01"), 5, resolve(Some("rocor")).unwrap());
let text = to_delimited(&days, ',', &[]);
let lines: Vec<&str> = text.trim_end().split('\n').collect();
assert_eq!(lines.len(), 6, "header plus five days");
assert!(lines[0].starts_with("date,weekday,"));
assert!(lines[1].starts_with("2026-01-01,Thursday,rocor,old,"));
}
#[test]
fn csv_quotes_the_fields_that_need_it() {
use typikon::export::to_delimited;
// Commemorations are full of commas, and joined with semicolons besides.
let days = week(d("2026-09-18"), 1, resolve(Some("rocor")).unwrap());
let text = to_delimited(&days, ',', &["title".to_string()]);
let row = text.trim_end().split('\n').nth(1).unwrap();
assert!(row.starts_with('"'), "a title with a comma must be quoted: {row}");
assert!(row.ends_with('"'));
// A quote inside a field doubles rather than escaping.
assert_eq!(row.matches("\"\"").count(), 0);
}
#[test]
fn tsv_uses_tabs_and_a_chosen_column_order() {
use typikon::export::to_delimited;
let days = week(d("2026-04-12"), 1, resolve(Some("new")).unwrap());
let cols = vec!["fast".to_string(), "date".to_string()];
let text = to_delimited(&days, '\t', &cols);
let lines: Vec<&str> = text.trim_end().split('\n').collect();
assert_eq!(lines[0], "fast\tdate");
assert_eq!(lines[1], "none\t2026-04-12");
}
#[test]
fn an_unknown_column_is_caught_before_anything_is_printed() {
use typikon::export::csv::unknown_column;
assert!(unknown_column(&["date".to_string(), "fast".to_string()]).is_none());
assert_eq!(
unknown_column(&["date".to_string(), "nonsense".to_string()]).map(String::as_str),
Some("nonsense")
);
}
#[test]
fn every_advertised_column_actually_renders() {
use typikon::export::{csv::COLUMNS, to_delimited};
let days = week(d("2026-02-16"), 1, resolve(Some("ruthenian")).unwrap());
let cols: Vec<String> = COLUMNS.iter().map(|c| c.to_string()).collect();
let text = to_delimited(&days, '\t', &cols);
let lines: Vec<&str> = text.trim_end().split('\n').collect();
// Every column produces a cell, even the empty ones.
assert_eq!(lines[0].split('\t').count(), COLUMNS.len());
assert_eq!(lines[1].split('\t').count(), COLUMNS.len());
}