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 ## Output
```console ```console
$ typikon --json --days 7 # for a bar, a widget, a script $ typikon --days 7 # for a person
$ typikon --ics --days 365 > orthodox.ics $ typikon --format json --days 7 # for a bar, a widget, a script
$ typikon --year 2027 # a year's key dates, to check against yours $ 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 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 written by someone else, and a schema that shifts underneath them is a bad
neighbour. 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 ics;
pub mod json; pub mod json;
pub use csv::to_delimited;
pub use ics::to_ics; pub use ics::to_ics;
pub use json::to_json; pub use json::to_json;
+84 -16
View File
@@ -1,15 +1,27 @@
//! The command line. //! The command line.
use clap::Parser; 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_ics, to_json}; use typikon::export::{to_delimited, to_ics, to_json};
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;
/// 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)] #[derive(Parser)]
#[command( #[command(
name = "typikon", name = "typikon",
@@ -29,14 +41,30 @@ struct Args {
#[arg(short = 'n', long, default_value_t = 1)] #[arg(short = 'n', long, default_value_t = 1)]
days: i32, days: i32,
/// Machine-readable output /// How to print it
#[arg(long)] #[arg(short = 'f', long, value_enum, default_value_t = Format::Text)]
format: Format,
/// Shorthand for --format json
#[arg(long, conflicts_with = "ics")]
json: bool, json: bool,
/// An iCalendar feed /// Shorthand for --format ics
#[arg(long)] #[arg(long)]
ics: bool, 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 /// Indent JSON output
#[arg(long)] #[arg(long)]
indent: Option<usize>, 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!(" {} \u{b7} {}{from}", jur.name, rule.title);
println!(); 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 { if let Some(c) = &d.cycle {
println!(" {c}"); 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); let room = cols.saturating_sub(fixed + 1).max(24);
for d in days { for d in days {
let mark = if d.is_obligatory() { "*" } else { " " }; 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!( println!(
" {} {:>10}{mark} {:<32}{}", " {glyph}{:>10}{mark} {:<32}{}",
d.emoji,
d.date.short(), d.date.short(),
d.fast_label, d.fast_label,
elide(d.title(), room) elide(d.title(), room)
@@ -199,6 +233,15 @@ fn run() -> Result<i32, String> {
return Ok(0); 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 { if args.list_menaia {
println!("\n Supplementary commemorations, fetched on request:\n"); println!("\n Supplementary commemorations, fetched on request:\n");
for s in menaia::SOURCES { 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"))?, .ok_or_else(|| format!("{s:?} is not a date \u{2014} expected YYYY-MM-DD"))?,
None => today(), None => today(),
}; };
let days = week(start, args.days, jur); let mut days = week(start, args.days, jur);
if args.no_emoji {
if args.json { for d in &mut days {
println!("{}", to_json(&days, args.indent)); d.emoji = "";
return Ok(0); }
} }
if args.ics {
print!("{}", to_ics(&days, &format!("{} \u{2014} Typikon", jur.name), &stamp())); // The boolean flags predate --format and still mean what they did.
return Ok(0); 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); 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. // No rank means no change.
assert_eq!(Level::Xerophagy.relaxed_to(Level::Xerophagy, Level::Fish), Level::Xerophagy); 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());
}