mirror of
https://github.com/MalteT/mensa.git
synced 2024-10-22 21:59:17 +02:00
Improve text wrapping
This commit is contained in:
parent
67f197d333
commit
ca128a5faa
30
Cargo.lock
generated
30
Cargo.lock
generated
|
@ -147,7 +147,7 @@ dependencies = [
|
|||
"bitflags",
|
||||
"strsim",
|
||||
"term_size",
|
||||
"textwrap",
|
||||
"textwrap 0.11.0",
|
||||
"unicode-width",
|
||||
"vec_map",
|
||||
]
|
||||
|
@ -597,11 +597,13 @@ dependencies = [
|
|||
"serde",
|
||||
"structopt",
|
||||
"termion",
|
||||
"textwrap 0.14.2",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1161,6 +1163,12 @@ version = "1.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ecab6c735a6bb4139c0caafd0cc3635748bbb3acf4550e8138122099251f309"
|
||||
|
||||
[[package]]
|
||||
name = "smawk"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.4.2"
|
||||
|
@ -1269,6 +1277,17 @@ dependencies = [
|
|||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0066c8d12af8b5acd21e00547c3797fde4e8677254a7ee429176ccebbe93dd80"
|
||||
dependencies = [
|
||||
"smawk",
|
||||
"unicode-linebreak",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.30"
|
||||
|
@ -1480,6 +1499,15 @@ version = "0.3.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-linebreak"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a52dcaab0c48d931f7cc8ef826fa51690a08e1ea55117ef26f89864f532383f"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.19"
|
||||
|
|
|
@ -21,3 +21,5 @@ num_enum = "0.5"
|
|||
regex = "1.5"
|
||||
prettytable-rs = "0.8"
|
||||
toml = "0.5"
|
||||
textwrap = "0.14"
|
||||
unicode-width = "0.1"
|
||||
|
|
|
@ -1,25 +1,36 @@
|
|||
use serde::Deserialize;
|
||||
use chrono::NaiveDate;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use structopt::StructOpt;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::{pass_info, Error, Result, ResultExt};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONFIG: Config = Config {
|
||||
args: Args::from_args()
|
||||
};
|
||||
pub static ref CONFIG: Config = Config::assemble().log_panic();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
args: Args,
|
||||
file: Option<ConfigFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct ConfigFile {
|
||||
default_mensa_id: Option<usize>,
|
||||
}
|
||||
|
||||
impl ConfigFile {
|
||||
fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
|
||||
let file = fs::read_to_string(path).map_err(Error::ReadingConfig)?;
|
||||
toml::from_str(&file).map_err(Error::DeserializingConfig)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
|
@ -36,23 +47,43 @@ pub struct Args {
|
|||
default_value = "today")]
|
||||
pub date: NaiveDate,
|
||||
/// Path to the configuration file.
|
||||
#[structopt(long, short, env = "MENSA_CONFIG", default_value = "~/.config/mensa/config.toml", name = "PATH")]
|
||||
pub config: PathBuf,
|
||||
#[structopt(long, short, env = "MENSA_CONFIG", name = "PATH")]
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn mensa_id(&self) -> usize {
|
||||
match self.args.mensa_id {
|
||||
Some(id) => id,
|
||||
None => todo!("Config not done yet"),
|
||||
}
|
||||
pub fn mensa_id(&self) -> Result<usize> {
|
||||
// Get the default mensa id from the config file
|
||||
let default = self
|
||||
.file
|
||||
.as_ref()
|
||||
.map(|conf| conf.default_mensa_id)
|
||||
.flatten();
|
||||
self.args.mensa_id.or(default).ok_or(Error::MensaIdMissing)
|
||||
}
|
||||
|
||||
pub fn date(&self) -> &chrono::NaiveDate {
|
||||
&self.args.date
|
||||
}
|
||||
|
||||
fn assemble() -> Result<Self> {
|
||||
let args = Args::from_args();
|
||||
let path = args
|
||||
.config
|
||||
.clone()
|
||||
.or_else(|| default_config_path().log_warn());
|
||||
let file = path.map(ConfigFile::load).transpose().log_warn().flatten();
|
||||
let config = pass_info(Config { file, args });
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_human_date(inp: &str) -> Result<NaiveDate> {
|
||||
date_time_parser::DateParser::parse(inp).ok_or(Error::InvalidDateInArgs)
|
||||
}
|
||||
|
||||
fn default_config_path() -> Result<PathBuf> {
|
||||
dirs::config_dir()
|
||||
.ok_or(Error::NoConfigDir)
|
||||
.map(|base| base.join("mensa/config.toml"))
|
||||
}
|
||||
|
|
42
src/error.rs
42
src/error.rs
|
@ -1,5 +1,7 @@
|
|||
use core::fmt;
|
||||
|
||||
use thiserror::Error;
|
||||
use tracing::error;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
|
@ -9,10 +11,22 @@ pub enum Error {
|
|||
Reqwest(#[from] reqwest::Error),
|
||||
#[error("could not parse date")]
|
||||
InvalidDateInArgs,
|
||||
#[error("no default mensa id is defined and `--id` was not given")]
|
||||
MensaIdMissing,
|
||||
#[error("could not read configuration file: {_0}")]
|
||||
ReadingConfig(#[source] std::io::Error),
|
||||
#[error("could not deserialize configuration file: {_0}")]
|
||||
DeserializingConfig(#[source] toml::de::Error),
|
||||
#[error("no configuration directory found. On Linux, try setting $XDG_CONFIG_DIR")]
|
||||
NoConfigDir,
|
||||
#[error("failed to read terminal size for standard output")]
|
||||
UnableToGetTerminalSize(#[source] std::io::Error),
|
||||
}
|
||||
|
||||
pub trait ResultExt<T> {
|
||||
fn log_err(self) -> Option<T>;
|
||||
fn log_warn(self) -> Option<T>;
|
||||
fn log_panic(self) -> T;
|
||||
}
|
||||
|
||||
impl<T, E> ResultExt<T> for std::result::Result<T, E>
|
||||
|
@ -28,4 +42,30 @@ where
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_panic(self) -> T {
|
||||
match self {
|
||||
Ok(inner) => inner,
|
||||
Err(why) => {
|
||||
error!("{}", why.into());
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_warn(self) -> Option<T> {
|
||||
match self {
|
||||
Ok(inner) => Some(inner),
|
||||
Err(why) => {
|
||||
warn!("{}", why.into());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug print the given value using [`info`].
|
||||
pub fn pass_info<T: fmt::Debug>(t: T) -> T {
|
||||
info!("{:#?}", &t);
|
||||
t
|
||||
}
|
||||
|
|
15
src/main.rs
15
src/main.rs
|
@ -1,3 +1,5 @@
|
|||
use tracing::error;
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod meal;
|
||||
|
@ -10,8 +12,19 @@ const ENDPOINT: &str = "https://openmensa.org/api/v2";
|
|||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let res = real_main().await;
|
||||
match res {
|
||||
Ok(_) => {}
|
||||
Err(ref why) => error!("{}", why),
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn real_main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
// TODO: Actually do what the user wants
|
||||
// TODO: Command to show ingredient legend
|
||||
// TODO: Command to list mensas
|
||||
let meals = fetch_meals().await?;
|
||||
// TODO: Display meals for mensa
|
||||
// TODO: More pizzazz
|
||||
|
@ -23,7 +36,7 @@ async fn fetch_meals() -> Result<Vec<Meal>> {
|
|||
let url = format!(
|
||||
"{}/canteens/{}/days/{}/meals",
|
||||
ENDPOINT,
|
||||
CONFIG.mensa_id(),
|
||||
CONFIG.mensa_id()?,
|
||||
CONFIG.date()
|
||||
);
|
||||
Ok(reqwest::get(url).await?.json().await?)
|
||||
|
|
189
src/meal.rs
189
src/meal.rs
|
@ -3,9 +3,19 @@ use lazy_static::lazy_static;
|
|||
use num_enum::{IntoPrimitive, TryFromPrimitive};
|
||||
use regex::RegexSet;
|
||||
use serde::Deserialize;
|
||||
use termion::{color, style};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::{Error, ResultExt};
|
||||
|
||||
const MIN_TERM_WIDTH: usize = 20;
|
||||
const NAME_PRE: &str = "╭───╴";
|
||||
const NAME_CONTINUE_PRE: &str = "┊ ";
|
||||
const OTHER_NOTE_PRE: &str = "├╴";
|
||||
const OTHER_NOTE_CONTINUE_PRE: &str = "┊ ";
|
||||
|
||||
lazy_static! {
|
||||
static ref ALLERGENE_RE: RegexSet = RegexSet::new(&[
|
||||
r"(?i)alkohol",
|
||||
|
@ -14,6 +24,7 @@ lazy_static! {
|
|||
r"(?i)farbstoff",
|
||||
r"(?i)eier",
|
||||
r"(?i)geschmacksverstärker",
|
||||
r"(?i)knoblauch",
|
||||
r"(?i)gluten",
|
||||
r"(?i)milch",
|
||||
r"(?i)senf",
|
||||
|
@ -33,12 +44,13 @@ lazy_static! {
|
|||
r"(?i)schwein",
|
||||
r"(?i)geflügel",
|
||||
r"(?i)vegan",
|
||||
r"(?i)fleischlos",
|
||||
r"(?i)fleischlos|vegetarisch|ohne fleisch",
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[cfg_attr(debug, serde(deny_unknown_fields))]
|
||||
pub struct Meal {
|
||||
id: usize,
|
||||
name: String,
|
||||
|
@ -48,6 +60,7 @@ pub struct Meal {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[cfg_attr(debug, serde(deny_unknown_fields))]
|
||||
pub struct Prices {
|
||||
students: f32,
|
||||
employees: f32,
|
||||
|
@ -61,6 +74,13 @@ enum Note {
|
|||
Other(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct SplittedNotes {
|
||||
tags: HashSet<Tag>,
|
||||
allergenes: Vec<Allergene>,
|
||||
others: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, Hash, PartialEq, Eq, Ord, PartialOrd, IntoPrimitive, TryFromPrimitive,
|
||||
)]
|
||||
|
@ -85,6 +105,7 @@ enum Allergene {
|
|||
Coloring,
|
||||
Egg,
|
||||
FlavorEnhancer,
|
||||
Garlic,
|
||||
Gluten,
|
||||
Milk,
|
||||
Mustard,
|
||||
|
@ -110,42 +131,17 @@ impl Note {
|
|||
|
||||
impl Meal {
|
||||
pub fn print_to_terminal(&self) {
|
||||
use termion::{
|
||||
color::{self, *},
|
||||
style::{self, *},
|
||||
};
|
||||
let name = format!("{}{}{}", Bold, self.name, style::Reset);
|
||||
let (tags, mut allergenes, others) = self
|
||||
.notes
|
||||
.iter()
|
||||
.cloned()
|
||||
.flat_map(|raw| Note::from_str(&raw))
|
||||
.fold(
|
||||
(HashSet::new(), Vec::new(), HashSet::new()),
|
||||
|(mut tags, mut allergenes, mut others), note| {
|
||||
match note {
|
||||
Note::Tag(tag) => {
|
||||
tags.insert(tag);
|
||||
}
|
||||
Note::Allergene(all) => allergenes.push(all),
|
||||
Note::Other(other) => {
|
||||
others.insert(other);
|
||||
}
|
||||
}
|
||||
(tags, allergenes, others)
|
||||
},
|
||||
);
|
||||
allergenes.sort_unstable();
|
||||
allergenes.dedup();
|
||||
let tag_str = tags
|
||||
.iter()
|
||||
.fold(String::from(" "), |s, e| s + &format!("{} ", e));
|
||||
println!("╭─╴{}{}", name, tag_str);
|
||||
use termion::{color::*, style::*};
|
||||
let (width, _height) = get_sane_terminal_dimensions();
|
||||
// Print meal name
|
||||
self.print_name_to_terminal(width);
|
||||
// Get notes, i.e. allergenes, descriptions, tags
|
||||
let notes = self.parse_and_split_notes();
|
||||
self.print_category_and_tags(¬es.tags);
|
||||
self.prices.print_to_terminal();
|
||||
for note in &others {
|
||||
println!("├╴{}", note);
|
||||
}
|
||||
let allergene_str = allergenes
|
||||
self.print_other_notes(width, ¬es.others);
|
||||
let allergene_str = notes
|
||||
.allergenes
|
||||
.iter()
|
||||
.fold(String::new(), |s, a| s + &format!("{} ", a));
|
||||
println!(
|
||||
|
@ -165,6 +161,86 @@ impl Meal {
|
|||
Fg(color::Reset),
|
||||
);
|
||||
}
|
||||
|
||||
fn print_name_to_terminal(&self, width: usize) {
|
||||
let max_name_width = width.saturating_sub(NAME_PRE.width()).max(MIN_TERM_WIDTH);
|
||||
let mut name_parts = textwrap::wrap(&self.name, max_name_width).into_iter();
|
||||
// There will always be a first part of the splitted string
|
||||
let first_name_part = name_parts.next().unwrap();
|
||||
println!(
|
||||
"{}{}{}{}",
|
||||
NAME_PRE,
|
||||
style::Bold,
|
||||
first_name_part,
|
||||
style::Reset
|
||||
);
|
||||
for name_part in name_parts {
|
||||
println!(
|
||||
"{}{}{}{}",
|
||||
NAME_CONTINUE_PRE,
|
||||
style::Bold,
|
||||
name_part,
|
||||
style::Reset
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_category_and_tags(&self, tags: &HashSet<Tag>) {
|
||||
let tag_str = tags
|
||||
.iter()
|
||||
.fold(String::from(" "), |s, e| s + &format!("{} ", e));
|
||||
println!(
|
||||
"├─╴{}{}{}{}",
|
||||
color::Fg(color::LightBlue),
|
||||
self.category,
|
||||
color::Fg(color::Reset),
|
||||
tag_str
|
||||
);
|
||||
}
|
||||
|
||||
fn print_other_notes(&self, width: usize, others: &HashSet<String>) {
|
||||
let max_note_width = width - OTHER_NOTE_PRE.width();
|
||||
for note in others {
|
||||
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
|
||||
// There will always be a first part in the splitted string
|
||||
println!("{}{}", OTHER_NOTE_PRE, note_parts.next().unwrap());
|
||||
for part in note_parts {
|
||||
println!("{}{}", OTHER_NOTE_CONTINUE_PRE, part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_and_split_notes(&self) -> SplittedNotes {
|
||||
let mut splitted_notes = self
|
||||
.notes
|
||||
.iter()
|
||||
.cloned()
|
||||
.flat_map(|raw| Note::from_str(&raw))
|
||||
.fold(SplittedNotes::default(), |mut sn, note| {
|
||||
match note {
|
||||
Note::Tag(tag) => {
|
||||
sn.tags.insert(tag);
|
||||
}
|
||||
Note::Allergene(all) => sn.allergenes.push(all),
|
||||
Note::Other(other) => {
|
||||
sn.others.insert(other);
|
||||
}
|
||||
}
|
||||
sn
|
||||
});
|
||||
splitted_notes.allergenes.sort_unstable();
|
||||
splitted_notes.allergenes.dedup();
|
||||
splitted_notes
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sane_terminal_dimensions() -> (usize, usize) {
|
||||
termion::terminal_size()
|
||||
.map(|(w, h)| (w as usize, h as usize))
|
||||
.map(|(w, h)| (w.max(MIN_TERM_WIDTH), h))
|
||||
.map_err(Error::UnableToGetTerminalSize)
|
||||
.log_warn()
|
||||
.unwrap_or((80, 80))
|
||||
}
|
||||
|
||||
impl Note {
|
||||
|
@ -231,47 +307,6 @@ impl Prices {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<String> for Note {
|
||||
fn from(note: String) -> Self {
|
||||
let raw = note.to_lowercase();
|
||||
if raw.contains("vegan") {
|
||||
Self::Tag(Tag::Vegan)
|
||||
} else if raw.contains("fleischlos") {
|
||||
Self::Tag(Tag::Vegetarian)
|
||||
} else if raw.contains("eier") {
|
||||
Self::Allergene(Allergene::Egg)
|
||||
} else if raw.contains("milch") {
|
||||
Self::Allergene(Allergene::Milk)
|
||||
} else if raw.contains("schwein") {
|
||||
Self::Tag(Tag::Pig)
|
||||
} else if raw.contains("fisch") {
|
||||
Self::Tag(Tag::Fish)
|
||||
} else if raw.contains("rind") {
|
||||
Self::Tag(Tag::Cow)
|
||||
} else if raw.contains("geflügel") {
|
||||
Self::Tag(Tag::Poultry)
|
||||
} else if raw.contains("soja") {
|
||||
Self::Allergene(Allergene::Soy)
|
||||
} else if raw.contains("gluten") {
|
||||
Self::Allergene(Allergene::Gluten)
|
||||
} else if raw.contains("antioxidation") {
|
||||
Self::Allergene(Allergene::Antioxidant)
|
||||
} else if raw.contains("sulfit") || raw.contains("schwefel") {
|
||||
Self::Allergene(Allergene::Sulfite)
|
||||
} else if raw.contains("senf") {
|
||||
Self::Allergene(Allergene::Mustard)
|
||||
} else if raw.contains("farbstoff") {
|
||||
Self::Allergene(Allergene::Coloring)
|
||||
} else if raw.contains("sellerie") {
|
||||
Self::Allergene(Allergene::Sellery)
|
||||
} else if raw.contains("konservierung") {
|
||||
Self::Allergene(Allergene::Preservative)
|
||||
} else {
|
||||
Self::Other(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Note {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
|
Loading…
Reference in a new issue