mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-23 03:07:12 +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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user