mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user