diff --git a/README.md b/README.md index 0cd42ce..d3dd69c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/export/csv.rs b/src/export/csv.rs new file mode 100644 index 0000000..b1dd251 --- /dev/null +++ b/src/export/csv.rs @@ -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::>().join(&sep.to_string())); + out.push('\n'); + for day in days { + let row: Vec = 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())) +} diff --git a/src/export/mod.rs b/src/export/mod.rs index 96a7510..19212fb 100644 --- a/src/export/mod.rs +++ b/src/export/mod.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index f0f90bf..b888ca9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, + + /// List the columns csv and tsv can print + #[arg(long)] + list_fields: bool, + /// Indent JSON output #[arg(long)] indent: Option, @@ -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 { 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 { .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); diff --git a/tests/typikon.rs b/tests/typikon.rs index e0b8feb..0a71cc3 100644 --- a/tests/typikon.rs +++ b/tests/typikon.rs @@ -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 = 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()); +}