mirror of
https://github.com/MalteT/mensa.git
synced 2024-10-22 21:59:17 +02:00
Move to global config, rewrite fetching
This commit is contained in:
parent
6da8a95a3b
commit
15e5133f8c
|
@ -47,7 +47,10 @@ mod wrapper;
|
|||
pub use fetchable::Fetchable;
|
||||
pub use wrapper::clear_cache as clear;
|
||||
|
||||
use crate::error::{Error, Result, ResultExt};
|
||||
use crate::{
|
||||
config::CONF,
|
||||
error::{Error, Result, ResultExt},
|
||||
};
|
||||
|
||||
/// Returned by most functions in this module.
|
||||
type TextAndHeaders = (String, Headers);
|
||||
|
@ -81,12 +84,12 @@ enum CacheResult<T> {
|
|||
}
|
||||
|
||||
/// Wrapper around [`fetch`] for responses that contain json.
|
||||
pub fn fetch_json<U, T>(client: &Client, url: U, local_ttl: Duration) -> Result<T>
|
||||
pub fn fetch_json<U, T>(url: U, local_ttl: Duration) -> Result<T>
|
||||
where
|
||||
U: IntoUrl,
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
fetch(client, url, local_ttl, |text, _| {
|
||||
fetch(&CONF.client, url, local_ttl, |text, _| {
|
||||
// TODO: Check content header?
|
||||
serde_json::from_str(&text).map_err(|why| Error::Deserializing(why, "fetching json"))
|
||||
})
|
||||
|
|
27
src/cache/fetchable.rs
vendored
27
src/cache/fetchable.rs
vendored
|
@ -26,13 +26,36 @@ impl<T> Fetchable<T> {
|
|||
let value = f()?;
|
||||
*self = Self::Fetched(value);
|
||||
// This is safe, since we've just fetched successfully
|
||||
Ok(self.unwrap())
|
||||
Ok(self.assume_fetched())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fetch_mut<F>(&mut self, f: F) -> Result<&mut T>
|
||||
where
|
||||
F: FnOnce() -> Result<T>,
|
||||
{
|
||||
match self {
|
||||
Self::Fetched(ref mut value) => Ok(value),
|
||||
Self::None => {
|
||||
let value = f()?;
|
||||
*self = Self::Fetched(value);
|
||||
// This is safe, since we've just fetched successfully
|
||||
Ok(self.assume_fetched_mut())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics if the resource doesn't exist
|
||||
fn unwrap(&self) -> &T {
|
||||
fn assume_fetched(&self) -> &T {
|
||||
match self {
|
||||
Self::Fetched(value) => value,
|
||||
Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics if the resource doesn't exist
|
||||
fn assume_fetched_mut(&mut self) -> &mut T {
|
||||
match self {
|
||||
Self::Fetched(value) => value,
|
||||
Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"),
|
||||
|
|
139
src/canteen.rs
139
src/canteen.rs
|
@ -1,4 +1,8 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::info;
|
||||
|
||||
|
@ -6,8 +10,16 @@ mod de;
|
|||
mod ser;
|
||||
|
||||
use crate::{
|
||||
cache::Fetchable, config::CanteensState, error::Result, geoip, get_sane_terminal_dimensions,
|
||||
meal::Meal, pagination::PaginatedList, print_json, ENDPOINT, TTL_CANTEENS,
|
||||
cache::{fetch_json, Fetchable},
|
||||
config::{
|
||||
args::{CloseCommand, Command, GeoCommand},
|
||||
CONF,
|
||||
},
|
||||
error::Result,
|
||||
geoip, get_sane_terminal_dimensions,
|
||||
meal::Meal,
|
||||
pagination::PaginatedList,
|
||||
print_json, ENDPOINT, TTL_CANTEENS, TTL_MEALS,
|
||||
};
|
||||
|
||||
use self::ser::CanteenCompleteWithoutMeals;
|
||||
|
@ -16,13 +28,20 @@ pub type CanteenId = usize;
|
|||
|
||||
const ADRESS_INDENT: &str = " ";
|
||||
|
||||
lazy_static! {
|
||||
static ref EMPTY: Vec<Meal> = Vec::new();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(from = "de::CanteenDeserialized")]
|
||||
pub struct Canteen {
|
||||
id: CanteenId,
|
||||
#[serde(flatten)]
|
||||
meta: Fetchable<Meta>,
|
||||
meals: Fetchable<Vec<Meal>>,
|
||||
/// A map from dates to lists of meals.
|
||||
///
|
||||
/// The list of dates itself is fetchable as are the lists of meals.
|
||||
meals: Fetchable<HashMap<NaiveDate, Fetchable<Vec<Meal>>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
@ -33,6 +52,13 @@ pub struct Meta {
|
|||
coordinates: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(try_from = "de::DayDeserialized")]
|
||||
pub struct Day {
|
||||
date: NaiveDate,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Meta {
|
||||
pub fn fetch(id: CanteenId) -> Result<Self> {
|
||||
todo!()
|
||||
|
@ -40,7 +66,28 @@ impl Meta {
|
|||
}
|
||||
|
||||
impl Canteen {
|
||||
pub fn print(&mut self, state: &CanteensState) -> Result<()> {
|
||||
/// Infer canteens from the config.
|
||||
///
|
||||
/// # Command
|
||||
/// - Meals:
|
||||
/// - Close: Canteens close to the current location
|
||||
/// - Else: Canteen given by id
|
||||
/// - Else: Panic!
|
||||
pub fn infer() -> Result<Vec<Self>> {
|
||||
match CONF.cmd() {
|
||||
Command::Meals(cmd) => match cmd.close {
|
||||
Some(CloseCommand::Close(ref geo)) => Self::fetch_for_geo(geo, false),
|
||||
None => {
|
||||
let id = CONF.canteen_id()?;
|
||||
Ok(vec![id.into()])
|
||||
}
|
||||
},
|
||||
Command::Canteens(cmd) => Self::fetch_for_geo(&cmd.geo, cmd.all),
|
||||
Command::Tags => unreachable!("BUG: This is not relevant here"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print(&mut self) -> Result<()> {
|
||||
let (width, _) = get_sane_terminal_dimensions();
|
||||
let address = textwrap::fill(
|
||||
self.address()?,
|
||||
|
@ -50,9 +97,9 @@ impl Canteen {
|
|||
);
|
||||
println!(
|
||||
"{} {}\n{}",
|
||||
color!(state: format!("{:>4}", self.id); bold, bright_yellow),
|
||||
color!(state: self.meta()?.name; bold),
|
||||
color!(state: address; bright_black),
|
||||
color!(format!("{:>4}", self.id); bold, bright_yellow),
|
||||
color!(self.meta()?.name; bold),
|
||||
color!(address; bright_black),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -76,36 +123,30 @@ impl Canteen {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn fetch(state: &CanteensState) -> Result<Vec<Self>> {
|
||||
let url = if state.cmd.all {
|
||||
info!("Fetching all canteens");
|
||||
format!("{}/canteens", ENDPOINT)
|
||||
} else {
|
||||
let (lat, long) = geoip::fetch(state)?;
|
||||
info!(
|
||||
"Fetching canteens for lat: {}, long: {} with radius: {}",
|
||||
lat, long, state.cmd.geo.radius
|
||||
);
|
||||
format!(
|
||||
"{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}",
|
||||
ENDPOINT, lat, long, state.cmd.geo.radius,
|
||||
)
|
||||
};
|
||||
PaginatedList::from(&state.client, url, *TTL_CANTEENS)?.try_flatten_and_collect()
|
||||
}
|
||||
|
||||
pub fn print_all(state: &CanteensState, canteens: &mut [Self]) -> Result<()> {
|
||||
if state.args.json {
|
||||
pub fn print_all(canteens: &mut [Self]) -> Result<()> {
|
||||
if CONF.args.json {
|
||||
Self::print_all_json(canteens)
|
||||
} else {
|
||||
for canteen in canteens {
|
||||
println!();
|
||||
canteen.print(state)?;
|
||||
canteen.print()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn meals_at_mut(&mut self, date: &NaiveDate) -> Result<Option<&mut Vec<Meal>>> {
|
||||
let id = self.id();
|
||||
let dates = self.meals.fetch_mut(|| fetch_dates_for_canteen(self.id))?;
|
||||
match dates.get_mut(date) {
|
||||
Some(meals) => {
|
||||
let meals = meals.fetch_mut(|| fetch_meals(id, date))?;
|
||||
Ok(Some(meals))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_all_json(canteens: &mut [Self]) -> Result<()> {
|
||||
let serializable: Vec<_> = canteens
|
||||
.iter_mut()
|
||||
|
@ -117,4 +158,46 @@ impl Canteen {
|
|||
fn meta(&mut self) -> Result<&Meta> {
|
||||
self.meta.fetch(|| Meta::fetch(self.id))
|
||||
}
|
||||
|
||||
fn fetch_for_geo(geo: &GeoCommand, all: bool) -> Result<Vec<Self>> {
|
||||
let url = if all {
|
||||
info!("Fetching all canteens");
|
||||
format!("{}/canteens", ENDPOINT)
|
||||
} else {
|
||||
let (lat, long) = geoip::infer()?;
|
||||
info!(
|
||||
"Fetching canteens for lat: {}, long: {} with radius: {}",
|
||||
lat, long, geo.radius
|
||||
);
|
||||
format!(
|
||||
"{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}",
|
||||
ENDPOINT, lat, long, geo.radius,
|
||||
)
|
||||
};
|
||||
PaginatedList::from(&CONF.client, url, *TTL_CANTEENS)?.try_flatten_and_collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_dates_for_canteen(id: CanteenId) -> Result<HashMap<NaiveDate, Fetchable<Vec<Meal>>>> {
|
||||
let url = format!("{}/canteens/{}/days", ENDPOINT, id,);
|
||||
let days: Vec<Day> = fetch_json(url, *TTL_MEALS)?;
|
||||
Ok(days
|
||||
.into_iter()
|
||||
.map(|day| (day.date, Fetchable::None))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn fetch_meals(id: CanteenId, date: &NaiveDate) -> Result<Vec<Meal>> {
|
||||
let url = format!("{}/canteens/{}/days/{}/meals", ENDPOINT, id, date);
|
||||
fetch_json(url, *TTL_MEALS)
|
||||
}
|
||||
|
||||
impl From<CanteenId> for Canteen {
|
||||
fn from(id: CanteenId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
meta: Fetchable::None,
|
||||
meals: Fetchable::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use chrono::NaiveDate;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::cache::Fetchable;
|
||||
use crate::{cache::Fetchable, error::Error};
|
||||
|
||||
use super::{CanteenId, Meta};
|
||||
|
||||
|
@ -28,3 +29,20 @@ impl From<CanteenDeserialized> for super::Canteen {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DayDeserialized {
|
||||
date: String,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl TryFrom<DayDeserialized> for super::Day {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(raw: DayDeserialized) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
date: NaiveDate::parse_from_str(&raw.date, "%Y-%m-%d").map_err(Error::InvalidDate)?,
|
||||
closed: raw.closed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,8 +57,6 @@ pub enum Command {
|
|||
Tags,
|
||||
/// Default. Show meals.
|
||||
Meals(MealsCommand),
|
||||
/// Shortcut for `mensa meals -d tomorrow`
|
||||
Tomorrow(MealsCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, StructOpt)]
|
||||
|
@ -100,7 +98,8 @@ pub struct MealsCommand {
|
|||
#[structopt(long, short,
|
||||
env = "MENSA_DATE",
|
||||
parse(try_from_str = parse_human_date),
|
||||
default_value = "today")]
|
||||
default_value = "today",
|
||||
global = true)]
|
||||
pub date: NaiveDate,
|
||||
|
||||
/// Canteen ID for which to fetch meals.
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
use chrono::NaiveDate;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
use structopt::clap::arg_enum;
|
||||
use structopt::{clap::arg_enum, StructOpt};
|
||||
|
||||
use std::{collections::HashSet, fs, path::Path, time::Duration as StdDuration};
|
||||
|
||||
use crate::{
|
||||
canteen::CanteenId,
|
||||
config::args::{parse_human_date, Command},
|
||||
error::{Error, Result, ResultExt},
|
||||
DIR,
|
||||
};
|
||||
|
||||
use self::{
|
||||
args::{Args, CanteensCommand, MealsCommand},
|
||||
args::{Args, MealsCommand},
|
||||
rule::{RegexRule, Rule, TagRule},
|
||||
};
|
||||
|
||||
|
@ -19,9 +22,123 @@ pub mod args;
|
|||
pub mod rule;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CONF: Config = Config::assemble().unwrap();
|
||||
static ref REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub config: Option<ConfigFile>,
|
||||
pub client: Client,
|
||||
pub args: Args,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn assemble() -> Result<Self> {
|
||||
let args = Args::from_args();
|
||||
let default_config_path = || DIR.config_dir().join("config.toml");
|
||||
let path = args.config.clone().unwrap_or_else(default_config_path);
|
||||
let config = ConfigFile::load_or_log(path);
|
||||
let client = Client::builder()
|
||||
.timeout(*REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(Error::Reqwest)?;
|
||||
Ok(Config {
|
||||
config,
|
||||
client,
|
||||
args,
|
||||
})
|
||||
}
|
||||
|
||||
/// Easy reference to the Command
|
||||
pub fn cmd(&self) -> &Command {
|
||||
lazy_static! {
|
||||
static ref DEFAULT: Command = Command::Meals(MealsCommand::default());
|
||||
}
|
||||
match self.args.command {
|
||||
Some(ref cmd) => cmd,
|
||||
None => &*DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn canteen_id(&self) -> Result<CanteenId> {
|
||||
// Get the default canteen id from the config file
|
||||
let default = || self.config.as_ref()?.default_canteen_id;
|
||||
let id = match self.cmd() {
|
||||
Command::Meals(cmd) => cmd.canteen_id,
|
||||
_ => None,
|
||||
};
|
||||
id.or_else(default).ok_or(Error::CanteenIdMissing)
|
||||
}
|
||||
|
||||
pub fn date(&self) -> &NaiveDate {
|
||||
lazy_static! {
|
||||
static ref DEFAULT: NaiveDate = parse_human_date("today").unwrap();
|
||||
}
|
||||
match self.cmd() {
|
||||
Command::Meals(cmd) => &cmd.date,
|
||||
_ => &*DEFAULT,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn price_tags(&self) -> HashSet<PriceTags> {
|
||||
let from_file = || Some(self.config.as_ref()?.price_tags.clone());
|
||||
match self.cmd() {
|
||||
Command::Meals(cmd) => match cmd.price.clone() {
|
||||
Some(prices) => prices.into_iter().collect(),
|
||||
None => from_file().unwrap_or_default(),
|
||||
},
|
||||
_ => from_file().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_filter_rule(&self) -> Rule {
|
||||
match self.cmd() {
|
||||
Command::Meals(cmd) => {
|
||||
let conf_filter = || Some(self.config.as_ref()?.filter.clone());
|
||||
let args_filter = Rule {
|
||||
name: RegexRule::from_arg_parts(&cmd.filter_name, &cmd.no_filter_name),
|
||||
tag: TagRule {
|
||||
add: cmd.filter_tag.clone(),
|
||||
sub: cmd.no_filter_tag.clone(),
|
||||
},
|
||||
category: RegexRule::from_arg_parts(&cmd.filter_cat, &cmd.no_filter_cat),
|
||||
};
|
||||
if cmd.overwrite_filter {
|
||||
args_filter
|
||||
} else {
|
||||
conf_filter().unwrap_or_default().joined(args_filter)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
unreachable!("Filters should not be relevant here")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_favourites_rule(&self) -> Rule {
|
||||
match self.cmd() {
|
||||
Command::Meals(cmd) => {
|
||||
let conf_favs = || Some(self.config.as_ref()?.favs.clone());
|
||||
let args_favs = Rule {
|
||||
name: RegexRule::from_arg_parts(&cmd.favs_name, &cmd.no_favs_name),
|
||||
tag: TagRule {
|
||||
add: cmd.favs_tag.clone(),
|
||||
sub: cmd.no_favs_tag.clone(),
|
||||
},
|
||||
category: RegexRule::from_arg_parts(&cmd.favs_cat, &cmd.no_favs_cat),
|
||||
};
|
||||
if cmd.overwrite_favs {
|
||||
args_favs
|
||||
} else {
|
||||
conf_favs().unwrap_or_default().joined(args_favs)
|
||||
}
|
||||
}
|
||||
_ => unreachable!("Favourite rules should not be relevant here"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct ConfigFile {
|
||||
|
@ -34,7 +151,6 @@ pub struct ConfigFile {
|
|||
#[serde(default)]
|
||||
favs: Rule,
|
||||
}
|
||||
|
||||
arg_enum! {
|
||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize)]
|
||||
pub enum PriceTags {
|
||||
|
@ -54,100 +170,3 @@ impl ConfigFile {
|
|||
.log_err()
|
||||
}
|
||||
}
|
||||
|
||||
pub type CanteensState<'s> = State<'s, CanteensCommand>;
|
||||
pub type MealsState<'s> = State<'s, MealsCommand>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State<'s, Cmd> {
|
||||
pub config: Option<ConfigFile>,
|
||||
pub client: Client,
|
||||
pub args: &'s Args,
|
||||
pub cmd: &'s Cmd,
|
||||
}
|
||||
|
||||
impl<'s> State<'s, ()> {
|
||||
pub fn assemble(args: &'s Args) -> Result<Self> {
|
||||
let default_config_path = || DIR.config_dir().join("config.toml");
|
||||
let path = args.config.clone().unwrap_or_else(default_config_path);
|
||||
let config = ConfigFile::load_or_log(path);
|
||||
let client = Client::builder()
|
||||
.timeout(*REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(Error::Reqwest)?;
|
||||
Ok(Self {
|
||||
config,
|
||||
client,
|
||||
args,
|
||||
cmd: &(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'s, Cmd> State<'s, Cmd> {
|
||||
pub fn from<OldCmd>(old: State<'s, OldCmd>, cmd: &'s Cmd) -> Self {
|
||||
Self {
|
||||
config: old.config,
|
||||
client: old.client,
|
||||
args: old.args,
|
||||
cmd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MealsState<'_> {
|
||||
pub fn canteen_id(&self) -> Result<usize> {
|
||||
// Get the default canteen id from the config file
|
||||
let default = || self.config.as_ref()?.default_canteen_id;
|
||||
self.cmd
|
||||
.canteen_id
|
||||
.or_else(default)
|
||||
.ok_or(Error::CanteenIdMissing)
|
||||
}
|
||||
|
||||
pub fn date(&self) -> &chrono::NaiveDate {
|
||||
&self.cmd.date
|
||||
}
|
||||
|
||||
pub fn price_tags(&self) -> HashSet<PriceTags> {
|
||||
let from_file = || Some(self.config.as_ref()?.price_tags.clone());
|
||||
match self.cmd.price.clone() {
|
||||
Some(prices) => prices.into_iter().collect(),
|
||||
None => from_file().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_filter(&self) -> Rule {
|
||||
let conf_filter = || Some(self.config.as_ref()?.filter.clone());
|
||||
let args_filter = Rule {
|
||||
name: RegexRule::from_arg_parts(&self.cmd.filter_name, &self.cmd.no_filter_name),
|
||||
tag: TagRule {
|
||||
add: self.cmd.filter_tag.clone(),
|
||||
sub: self.cmd.no_filter_tag.clone(),
|
||||
},
|
||||
category: RegexRule::from_arg_parts(&self.cmd.filter_cat, &self.cmd.no_filter_cat),
|
||||
};
|
||||
if self.cmd.overwrite_filter {
|
||||
args_filter
|
||||
} else {
|
||||
conf_filter().unwrap_or_default().joined(args_filter)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_favs_rule(&self) -> Rule {
|
||||
let conf_favs = || Some(self.config.as_ref()?.favs.clone());
|
||||
let args_favs = Rule {
|
||||
name: RegexRule::from_arg_parts(&self.cmd.favs_name, &self.cmd.no_favs_name),
|
||||
tag: TagRule {
|
||||
add: self.cmd.favs_tag.clone(),
|
||||
sub: self.cmd.no_favs_tag.clone(),
|
||||
},
|
||||
category: RegexRule::from_arg_parts(&self.cmd.favs_cat, &self.cmd.no_favs_cat),
|
||||
};
|
||||
if self.cmd.overwrite_favs {
|
||||
args_favs
|
||||
} else {
|
||||
conf_favs().unwrap_or_default().joined(args_favs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ use std::convert::TryFrom;
|
|||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
meal::Meal,
|
||||
meal::MealComplete,
|
||||
tag::Tag,
|
||||
};
|
||||
|
||||
|
@ -42,7 +42,7 @@ struct RawRegexRule {
|
|||
}
|
||||
|
||||
impl Rule {
|
||||
pub fn is_match(&self, meal: &Meal) -> bool {
|
||||
pub fn is_match(&self, meal: &MealComplete) -> bool {
|
||||
let all_adds_empty =
|
||||
self.tag.is_empty_add() && self.category.is_empty_add() && self.name.is_empty_add();
|
||||
let any_add = self.tag.is_match_add(meal)
|
||||
|
@ -64,12 +64,12 @@ impl Rule {
|
|||
}
|
||||
|
||||
impl TagRule {
|
||||
fn is_match_add(&self, meal: &Meal) -> bool {
|
||||
self.add.iter().any(|tag| meal.tags.contains(tag))
|
||||
fn is_match_add(&self, meal: &MealComplete) -> bool {
|
||||
self.add.iter().any(|tag| meal.meta.tags.contains(tag))
|
||||
}
|
||||
|
||||
fn is_match_sub(&self, meal: &Meal) -> bool {
|
||||
self.sub.iter().any(|tag| meal.tags.contains(tag))
|
||||
fn is_match_sub(&self, meal: &MealComplete) -> bool {
|
||||
self.sub.iter().any(|tag| meal.meta.tags.contains(tag))
|
||||
}
|
||||
|
||||
fn is_empty_add(&self) -> bool {
|
||||
|
@ -101,16 +101,16 @@ impl RegexRule {
|
|||
Self { add, sub }
|
||||
}
|
||||
|
||||
fn is_match_add(&self, meal: &Meal) -> bool {
|
||||
fn is_match_add(&self, meal: &MealComplete) -> bool {
|
||||
match self.add {
|
||||
Some(ref rset) => rset.is_match(&meal.category),
|
||||
Some(ref rset) => rset.is_match(&meal.meta.category),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_match_sub(&self, meal: &Meal) -> bool {
|
||||
fn is_match_sub(&self, meal: &MealComplete) -> bool {
|
||||
match self.sub {
|
||||
Some(ref rset) => rset.is_match(&meal.category),
|
||||
Some(ref rset) => rset.is_match(&meal.meta.category),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,8 @@ pub enum Error {
|
|||
NonSuccessStatusCode(String, reqwest::StatusCode),
|
||||
#[error("read invalid utf8 bytes")]
|
||||
DecodingUtf8(#[source] std::string::FromUtf8Error),
|
||||
#[error("invalid date encountered: {_0}")]
|
||||
InvalidDate(#[source] chrono::ParseError),
|
||||
}
|
||||
|
||||
pub trait ResultExt<T> {
|
||||
|
|
40
src/geoip.rs
40
src/geoip.rs
|
@ -2,10 +2,16 @@
|
|||
|
||||
use chrono::Duration;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{cache::fetch_json, config::CanteensState, error::Result};
|
||||
use crate::{
|
||||
cache::fetch_json,
|
||||
config::{
|
||||
args::{CloseCommand, Command},
|
||||
CONF,
|
||||
},
|
||||
error::Result,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref TTL_GEOIP: Duration = Duration::minutes(5);
|
||||
|
@ -21,26 +27,34 @@ struct LatLong {
|
|||
longitude: f32,
|
||||
}
|
||||
|
||||
/// Derive Latitude and Longitude from the [`CanteensState`].
|
||||
/// Infer Latitude and Longitude from the config.
|
||||
///
|
||||
/// This will use the cli arguments if given and
|
||||
/// fetch any missing values from api.geoip.rs.
|
||||
pub fn fetch(state: &CanteensState) -> Result<(f32, f32)> {
|
||||
let geo = &state.cmd.geo;
|
||||
Ok(if geo.lat.is_none() || geo.long.is_none() {
|
||||
let guessed = fetch_geoip(&state.client)?;
|
||||
pub fn infer() -> Result<(f32, f32)> {
|
||||
let (lat, long) = match CONF.cmd() {
|
||||
Command::Canteens(cmd) => (cmd.geo.lat, cmd.geo.long),
|
||||
Command::Meals(cmd) => match &cmd.close {
|
||||
Some(CloseCommand::Close(geo)) => (geo.lat, geo.long),
|
||||
None => (None, None),
|
||||
},
|
||||
Command::Tags => (None, None),
|
||||
};
|
||||
let (lat, long) = if lat.is_none() || long.is_none() {
|
||||
let guessed = fetch_geoip()?;
|
||||
(
|
||||
geo.lat.unwrap_or(guessed.latitude),
|
||||
geo.long.unwrap_or(guessed.longitude),
|
||||
lat.unwrap_or(guessed.latitude),
|
||||
long.unwrap_or(guessed.longitude),
|
||||
)
|
||||
} else {
|
||||
// Cannot panic, due to above if
|
||||
(geo.lat.unwrap(), geo.long.unwrap())
|
||||
})
|
||||
(lat.unwrap(), long.unwrap())
|
||||
};
|
||||
Ok((lat, long))
|
||||
}
|
||||
|
||||
/// Fetch geoip for current ip.
|
||||
fn fetch_geoip(client: &Client) -> Result<LatLong> {
|
||||
fn fetch_geoip() -> Result<LatLong> {
|
||||
let url = "https://api.geoip.rs";
|
||||
fetch_json(client, url, *TTL_GEOIP)
|
||||
fetch_json(url, *TTL_GEOIP)
|
||||
}
|
||||
|
|
54
src/main.rs
54
src/main.rs
|
@ -64,7 +64,6 @@ use chrono::Duration;
|
|||
use directories_next::ProjectDirs;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Serialize;
|
||||
use structopt::StructOpt;
|
||||
use tracing::error;
|
||||
|
||||
/// Colorizes the output.
|
||||
|
@ -72,11 +71,11 @@ use tracing::error;
|
|||
/// This will colorize for Stdout based on heuristics and colors
|
||||
/// from the [`owo_colors`] library.
|
||||
macro_rules! color {
|
||||
($state:ident: $what:expr; $($fn:ident),+) => {
|
||||
($what:expr; $($fn:ident),+) => {
|
||||
{
|
||||
use owo_colors::{OwoColorize, Stream};
|
||||
use crate::config::args::ColorWhen;
|
||||
match $state.args.color {
|
||||
match crate::config::CONF.args.color {
|
||||
ColorWhen::Always => {
|
||||
$what $(. $fn())+ .to_string()
|
||||
}
|
||||
|
@ -93,8 +92,8 @@ macro_rules! color {
|
|||
}
|
||||
|
||||
macro_rules! if_plain {
|
||||
($state:ident: $fancy:expr, $plain:expr) => {
|
||||
if $state.args.plain {
|
||||
($fancy:expr, $plain:expr) => {
|
||||
if crate::config::CONF.args.plain {
|
||||
$plain
|
||||
} else {
|
||||
$fancy
|
||||
|
@ -113,10 +112,7 @@ mod tag;
|
|||
|
||||
use crate::{
|
||||
canteen::Canteen,
|
||||
config::{
|
||||
args::{parse_human_date, Args, Command, MealsCommand},
|
||||
State,
|
||||
},
|
||||
config::{args::Command, CONF},
|
||||
error::{Error, Result, ResultExt},
|
||||
meal::Meal,
|
||||
tag::Tag,
|
||||
|
@ -129,6 +125,7 @@ lazy_static! {
|
|||
static ref DIR: ProjectDirs =
|
||||
ProjectDirs::from("rocks", "tammena", "mensa").expect("Could not detect home directory");
|
||||
static ref TTL_CANTEENS: Duration = Duration::days(1);
|
||||
static ref TTL_MEALS: Duration = Duration::hours(1);
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
|
@ -143,41 +140,22 @@ fn main() -> Result<()> {
|
|||
fn real_main() -> Result<()> {
|
||||
// Initialize logger
|
||||
tracing_subscriber::fmt::init();
|
||||
// Construct client, load config and assemble cli args
|
||||
let args = Args::from_args();
|
||||
let state = State::assemble(&args)?;
|
||||
// Clear cache if requested
|
||||
if state.args.clear_cache {
|
||||
if CONF.args.clear_cache {
|
||||
cache::clear()?;
|
||||
}
|
||||
// Match over the user requested command
|
||||
match &state.args.command {
|
||||
Some(Command::Meals(cmd)) => {
|
||||
let state = State::from(state, cmd);
|
||||
let meals = Meal::fetch(&state)?;
|
||||
Meal::print_all(&state, &meals)?;
|
||||
match CONF.cmd() {
|
||||
Command::Meals(_) => {
|
||||
let mut canteens = Canteen::infer()?;
|
||||
Meal::print_for_all_canteens(&mut canteens)?;
|
||||
}
|
||||
Some(Command::Tomorrow(cmd)) => {
|
||||
// Works like the meals command. But we replace the date with tomorrow!
|
||||
let mut cmd = cmd.clone();
|
||||
cmd.date = parse_human_date("tomorrow").unwrap();
|
||||
let state = State::from(state, &cmd);
|
||||
let meals = Meal::fetch(&state)?;
|
||||
Meal::print_all(&state, &meals)?;
|
||||
Command::Canteens(_) => {
|
||||
let mut canteens = Canteen::infer()?;
|
||||
Canteen::print_all(&mut canteens)?;
|
||||
}
|
||||
Some(Command::Canteens(cmd)) => {
|
||||
let state = State::from(state, cmd);
|
||||
let mut canteens = Canteen::fetch(&state)?;
|
||||
Canteen::print_all(&state, &mut canteens)?;
|
||||
}
|
||||
Some(Command::Tags) => {
|
||||
Tag::print_all(&state)?;
|
||||
}
|
||||
None => {
|
||||
let cmd = MealsCommand::default();
|
||||
let state = State::from(state, &cmd);
|
||||
let meals = Meal::fetch(&state)?;
|
||||
Meal::print_all(&state, &meals)?;
|
||||
Command::Tags => {
|
||||
Tag::print_all()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
57
src/meal/de.rs
Normal file
57
src/meal/de.rs
Normal file
|
@ -0,0 +1,57 @@
|
|||
use serde::Deserialize;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{cache::Fetchable, tag::Tag};
|
||||
|
||||
use super::{MealId, Meta, Note, Prices};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[cfg_attr(debug, serde(deny_unknown_fields))]
|
||||
pub struct Meal {
|
||||
id: MealId,
|
||||
name: String,
|
||||
notes: Vec<String>,
|
||||
prices: Prices,
|
||||
category: String,
|
||||
}
|
||||
|
||||
impl Meal {
|
||||
/// Parse notes and return them split into [`Tag`]s and descriptions.
|
||||
fn parse_and_split_notes(&self) -> (HashSet<Tag>, HashSet<String>) {
|
||||
self.notes
|
||||
.iter()
|
||||
.cloned()
|
||||
.flat_map(|raw| Note::parse_str(&raw))
|
||||
.fold(
|
||||
(HashSet::new(), HashSet::new()),
|
||||
|(mut tags, mut descs), note| {
|
||||
match note {
|
||||
Note::Tag(tag) => {
|
||||
tags.insert(tag);
|
||||
}
|
||||
Note::Desc(other) => {
|
||||
descs.insert(other);
|
||||
}
|
||||
}
|
||||
(tags, descs)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Meal> for super::Meal {
|
||||
fn from(raw: Meal) -> Self {
|
||||
let (tags, descs) = raw.parse_and_split_notes();
|
||||
Self {
|
||||
id: raw.id,
|
||||
meta: Fetchable::Fetched(Meta {
|
||||
name: raw.name,
|
||||
prices: raw.prices,
|
||||
category: raw.category,
|
||||
tags,
|
||||
descs,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
315
src/meal/mod.rs
315
src/meal/mod.rs
|
@ -1,47 +1,36 @@
|
|||
use chrono::Duration;
|
||||
use core::fmt;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
cache::fetch_json,
|
||||
canteen::{Canteen, CanteenId},
|
||||
config::{
|
||||
args::{CanteensCommand, CloseCommand, GeoCommand, MealsCommand},
|
||||
MealsState, PriceTags,
|
||||
},
|
||||
error::{pass_info, Result, ResultExt},
|
||||
get_sane_terminal_dimensions, print_json,
|
||||
tag::Tag,
|
||||
State, ENDPOINT,
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
};
|
||||
|
||||
const NAME_PRE: &str = " ╭───╴";
|
||||
const NAME_PRE_PLAIN: &str = " - ";
|
||||
const NAME_CONTINUE_PRE: &str = " ┊ ";
|
||||
const NAME_CONTINUE_PRE_PLAIN: &str = " ";
|
||||
const OTHER_NOTE_PRE: &str = " ├╴";
|
||||
const OTHER_NOTE_PRE_PLAIN: &str = " ";
|
||||
const OTHER_NOTE_CONTINUE_PRE: &str = " ┊ ";
|
||||
const OTHER_NOTE_CONTINUE_PRE_PLAIN: &str = " ";
|
||||
const CATEGORY_PRE: &str = " ├─╴";
|
||||
const CATEGORY_PRE_PLAIN: &str = " ";
|
||||
const PRICES_PRE: &str = " ╰╴";
|
||||
const PRICES_PRE_PLAIN: &str = " ";
|
||||
mod de;
|
||||
mod ser;
|
||||
|
||||
lazy_static! {
|
||||
static ref TTL_MEALS: Duration = Duration::hours(1);
|
||||
use crate::{
|
||||
cache::Fetchable,
|
||||
canteen::{Canteen, CanteenId},
|
||||
config::{PriceTags, CONF},
|
||||
error::Result,
|
||||
print_json,
|
||||
tag::Tag,
|
||||
};
|
||||
|
||||
pub use self::ser::MealComplete;
|
||||
|
||||
pub type MealId = usize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(from = "de::Meal")]
|
||||
pub struct Meal {
|
||||
pub id: MealId,
|
||||
pub meta: Fetchable<Meta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(from = "RawMeal")]
|
||||
pub struct Meal {
|
||||
#[serde(rename = "id")]
|
||||
pub _id: usize,
|
||||
pub struct Meta {
|
||||
pub name: String,
|
||||
pub tags: HashSet<Tag>,
|
||||
pub descs: HashSet<String>,
|
||||
|
@ -49,16 +38,6 @@ pub struct Meal {
|
|||
pub category: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[cfg_attr(debug, serde(deny_unknown_fields))]
|
||||
struct RawMeal {
|
||||
id: usize,
|
||||
name: String,
|
||||
notes: Vec<String>,
|
||||
prices: Prices,
|
||||
category: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(debug, serde(deny_unknown_fields))]
|
||||
pub struct Prices {
|
||||
|
@ -73,176 +52,69 @@ enum Note {
|
|||
Desc(String),
|
||||
}
|
||||
|
||||
impl RawMeal {
|
||||
/// Parse notes and return them split into [`Tag`]s and descriptions.
|
||||
fn parse_and_split_notes(&self) -> (HashSet<Tag>, HashSet<String>) {
|
||||
self.notes
|
||||
.iter()
|
||||
.cloned()
|
||||
.flat_map(|raw| Note::parse_str(&raw))
|
||||
.fold(
|
||||
(HashSet::new(), HashSet::new()),
|
||||
|(mut tags, mut descs), note| {
|
||||
match note {
|
||||
Note::Tag(tag) => {
|
||||
tags.insert(tag);
|
||||
}
|
||||
Note::Desc(other) => {
|
||||
descs.insert(other);
|
||||
}
|
||||
}
|
||||
(tags, descs)
|
||||
},
|
||||
)
|
||||
impl Meta {
|
||||
fn fetch(id: MealId) -> Result<Meta> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl Meal {
|
||||
/// Print this [`Meal`] to the terminal.
|
||||
pub fn print(&self, state: &State<MealsCommand>, highlight: bool) {
|
||||
let (width, _height) = get_sane_terminal_dimensions();
|
||||
// Print meal name
|
||||
self.print_name_to_terminal(state, width, highlight);
|
||||
// Get notes, i.e. allergenes, descriptions, tags
|
||||
self.print_category_and_primary_tags(state, highlight);
|
||||
self.print_descriptions(state, width, highlight);
|
||||
self.print_price_and_secondary_tags(state, highlight);
|
||||
pub fn print(&mut self, highlight: bool) -> Result<()> {
|
||||
self.complete()?.print(highlight);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the meals.
|
||||
///
|
||||
/// This will respect passed cli arguments and the configuration.
|
||||
pub fn fetch(state: &MealsState) -> Result<HashMap<CanteenId, Vec<Self>>> {
|
||||
match state.cmd.close {
|
||||
Some(CloseCommand::Close(ref close)) => {
|
||||
let meals = fetch_canteens_for_close_command(state, close)?
|
||||
.into_iter()
|
||||
.map(
|
||||
|canteen| match fetch_meals_for_canteen_id(state, canteen.id()) {
|
||||
Ok(meals) => Ok((canteen.id(), meals)),
|
||||
Err(why) => Err(why),
|
||||
},
|
||||
)
|
||||
// Drop any failed canteens with a warning
|
||||
.filter_map(ResultExt::log_warn)
|
||||
.collect();
|
||||
Ok(meals)
|
||||
}
|
||||
None => {
|
||||
let id = state.canteen_id()?;
|
||||
let meals = fetch_meals_for_canteen_id(state, id)?;
|
||||
let mut map = HashMap::new();
|
||||
map.insert(id, meals);
|
||||
Ok(map)
|
||||
}
|
||||
}
|
||||
pub fn meta(&mut self) -> Result<&Meta> {
|
||||
self.meta.fetch(|| Meta::fetch(self.id))
|
||||
}
|
||||
|
||||
pub fn complete(&mut self) -> Result<MealComplete<'_>> {
|
||||
Ok(MealComplete {
|
||||
id: self.id,
|
||||
meta: self.meta()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Print the given meals.
|
||||
///
|
||||
/// Thi will respect passed cli arguments and the configuration.
|
||||
pub fn print_all(state: &MealsState, meals: &HashMap<CanteenId, Vec<Self>>) -> Result<()> {
|
||||
/// This will respect passed cli arguments and the configuration.
|
||||
pub fn print_for_all_canteens(canteens: &mut [Canteen]) -> Result<()> {
|
||||
// Load the filter which is used to select which meals to print.
|
||||
let filter = state.get_filter();
|
||||
let filter = CONF.get_filter_rule();
|
||||
// Load the favourites which will be used for marking meals.
|
||||
let favs = state.get_favs_rule();
|
||||
let favs = CONF.get_favourites_rule();
|
||||
// The day for which to print meals
|
||||
let day = CONF.date();
|
||||
// Filter all meals
|
||||
let meal_map = meals.iter().map(|(id, meals)| {
|
||||
let keep = meals.iter().filter(|meal| filter.is_match(meal));
|
||||
(id, keep.collect::<Vec<_>>())
|
||||
let meals = canteens.iter_mut().map(|canteen| {
|
||||
let id = canteen.id();
|
||||
let meals: Vec<_> = match canteen.meals_at_mut(day)? {
|
||||
Some(meals) => meals
|
||||
.iter_mut()
|
||||
.map(|meal| meal.complete())
|
||||
.filter_ok(|meal| filter.is_match(meal))
|
||||
.try_collect()?,
|
||||
None => vec![],
|
||||
};
|
||||
Result::Ok((id, meals))
|
||||
});
|
||||
if state.args.json {
|
||||
print_json(&meal_map.collect::<HashMap<_, _>>())
|
||||
if CONF.args.json {
|
||||
let map: HashMap<CanteenId, Vec<_>> = meals.try_collect()?;
|
||||
print_json(&map)
|
||||
} else {
|
||||
for (id, meals) in meal_map {
|
||||
println!("{}", id);
|
||||
for res in meals {
|
||||
let (canteen_id, meals) = res?;
|
||||
println!("{}", canteen_id);
|
||||
for meal in meals {
|
||||
let is_fav = favs.is_match(meal);
|
||||
let is_fav = favs.is_match(&meal);
|
||||
println!();
|
||||
meal.print(state, is_fav);
|
||||
meal.print(is_fav);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn print_name_to_terminal(&self, state: &MealsState, width: usize, highlight: bool) {
|
||||
let max_name_width = width - NAME_PRE.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();
|
||||
let pre = if_plain!(state: NAME_PRE, NAME_PRE_PLAIN);
|
||||
println!(
|
||||
"{}{}",
|
||||
hl_if(state, highlight, pre),
|
||||
color!(state: hl_if(state, highlight, first_name_part); bold),
|
||||
);
|
||||
for name_part in name_parts {
|
||||
let name_part = hl_if(state, highlight, name_part);
|
||||
let pre = if_plain!(state: NAME_CONTINUE_PRE, NAME_CONTINUE_PRE_PLAIN);
|
||||
println!(
|
||||
"{}{}",
|
||||
hl_if(state, highlight, pre),
|
||||
color!(state: name_part; bold),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_category_and_primary_tags(&self, state: &MealsState, highlight: bool) {
|
||||
let mut tag_str = self
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.is_primary())
|
||||
.map(|tag| tag.as_id(state));
|
||||
let tag_str_colored = if_plain!(
|
||||
state: color!(state: tag_str.join(" "); bright_black),
|
||||
tag_str.join(", ")
|
||||
);
|
||||
let pre = if_plain!(state: CATEGORY_PRE, CATEGORY_PRE_PLAIN);
|
||||
let comma_if_plain = if_plain!(state: "", ",");
|
||||
println!(
|
||||
"{}{}{} {}",
|
||||
hl_if(state, highlight, pre),
|
||||
color!(state: self.category; bright_blue),
|
||||
color!(state: comma_if_plain; bright_black),
|
||||
tag_str_colored
|
||||
);
|
||||
}
|
||||
|
||||
fn print_descriptions(&self, state: &MealsState, width: usize, highlight: bool) {
|
||||
let pre = if_plain!(state: OTHER_NOTE_PRE, OTHER_NOTE_PRE_PLAIN);
|
||||
let pre_continue = if_plain!(
|
||||
state: OTHER_NOTE_CONTINUE_PRE,
|
||||
OTHER_NOTE_CONTINUE_PRE_PLAIN
|
||||
);
|
||||
let max_note_width = width - OTHER_NOTE_PRE.width();
|
||||
for note in &self.descs {
|
||||
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
|
||||
// There will always be a first part in the splitted string
|
||||
println!(
|
||||
"{}{}",
|
||||
hl_if(state, highlight, pre),
|
||||
note_parts.next().unwrap()
|
||||
);
|
||||
for part in note_parts {
|
||||
println!("{}{}", hl_if(state, highlight, pre_continue), part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_price_and_secondary_tags(&self, state: &State<MealsCommand>, highlight: bool) {
|
||||
let prices = self.prices.to_terminal_string(state);
|
||||
let mut secondary: Vec<_> = self.tags.iter().filter(|tag| tag.is_secondary()).collect();
|
||||
secondary.sort_unstable();
|
||||
let secondary_str = secondary.iter().map(|tag| tag.as_id(state)).join(" ");
|
||||
let pre = if_plain!(state: PRICES_PRE, PRICES_PRE_PLAIN);
|
||||
println!(
|
||||
"{}{} {}",
|
||||
hl_if(state, highlight, pre),
|
||||
prices,
|
||||
color!(state: secondary_str; bright_black),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Note {
|
||||
|
@ -257,8 +129,8 @@ impl Note {
|
|||
}
|
||||
|
||||
impl Prices {
|
||||
fn to_terminal_string(&self, state: &State<MealsCommand>) -> String {
|
||||
let price_tags = state.price_tags();
|
||||
fn to_terminal_string(&self) -> String {
|
||||
let price_tags = CONF.price_tags();
|
||||
let price_tags = if price_tags.is_empty() {
|
||||
// Print all of them
|
||||
vec![self.students, self.employees, self.others]
|
||||
|
@ -278,56 +150,23 @@ impl Prices {
|
|||
let price_tags: Vec<_> = price_tags
|
||||
.into_iter()
|
||||
.map(|tag| format!("{:.2}€", tag))
|
||||
.map(|tag| color!(state: tag; bright_green))
|
||||
.map(|tag| color!(tag; bright_green))
|
||||
.collect();
|
||||
match price_tags.len() {
|
||||
0 => String::new(),
|
||||
_ => {
|
||||
let slash = color!(state: " / "; bright_black);
|
||||
let slash = color!(" / "; bright_black);
|
||||
format!(
|
||||
"{} {} {}",
|
||||
color!(state: "("; bright_black),
|
||||
color!("("; bright_black),
|
||||
price_tags.join(&slash),
|
||||
color!(state: ")"; bright_black),
|
||||
color!(")"; bright_black),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hl_if<Cmd, S>(state: &State<Cmd>, highlight: bool, text: S) -> String
|
||||
where
|
||||
S: fmt::Display,
|
||||
{
|
||||
if highlight {
|
||||
color!(state: text; bright_yellow)
|
||||
} else {
|
||||
format!("{}", text)
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_canteens_for_close_command(
|
||||
state: &MealsState,
|
||||
close: &GeoCommand,
|
||||
) -> Result<Vec<Canteen>> {
|
||||
let canteens_cmd = CanteensCommand {
|
||||
all: false,
|
||||
geo: close.clone(),
|
||||
};
|
||||
let canteen_state = State::from(state.clone(), &canteens_cmd);
|
||||
Canteen::fetch(&canteen_state)
|
||||
}
|
||||
|
||||
fn fetch_meals_for_canteen_id(state: &MealsState, canteen_id: usize) -> Result<Vec<Meal>> {
|
||||
let url = format!(
|
||||
"{}/canteens/{}/days/{}/meals",
|
||||
ENDPOINT,
|
||||
canteen_id,
|
||||
state.date()
|
||||
);
|
||||
fetch_json(&state.client, url, *TTL_MEALS)
|
||||
}
|
||||
|
||||
impl fmt::Display for Note {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
|
@ -336,17 +175,3 @@ impl fmt::Display for Note {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawMeal> for Meal {
|
||||
fn from(raw: RawMeal) -> Self {
|
||||
let (tags, descs) = pass_info(&raw).parse_and_split_notes();
|
||||
Self {
|
||||
_id: raw.id,
|
||||
name: raw.name,
|
||||
prices: raw.prices,
|
||||
category: raw.category,
|
||||
tags,
|
||||
descs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
124
src/meal/ser.rs
Normal file
124
src/meal/ser.rs
Normal file
|
@ -0,0 +1,124 @@
|
|||
use core::fmt;
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::Serialize;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::get_sane_terminal_dimensions;
|
||||
|
||||
use super::{MealId, Meta};
|
||||
|
||||
const NAME_PRE: &str = " ╭───╴";
|
||||
const NAME_PRE_PLAIN: &str = " - ";
|
||||
const NAME_CONTINUE_PRE: &str = " ┊ ";
|
||||
const NAME_CONTINUE_PRE_PLAIN: &str = " ";
|
||||
const OTHER_NOTE_PRE: &str = " ├╴";
|
||||
const OTHER_NOTE_PRE_PLAIN: &str = " ";
|
||||
const OTHER_NOTE_CONTINUE_PRE: &str = " ┊ ";
|
||||
const OTHER_NOTE_CONTINUE_PRE_PLAIN: &str = " ";
|
||||
const CATEGORY_PRE: &str = " ├─╴";
|
||||
const CATEGORY_PRE_PLAIN: &str = " ";
|
||||
const PRICES_PRE: &str = " ╰╴";
|
||||
const PRICES_PRE_PLAIN: &str = " ";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MealComplete<'c> {
|
||||
pub id: MealId,
|
||||
#[serde(flatten)]
|
||||
pub meta: &'c Meta,
|
||||
}
|
||||
|
||||
impl<'c> MealComplete<'c> {
|
||||
/// Print this [`MealComplete`] to the terminal.
|
||||
pub fn print(&self, highlight: bool) {
|
||||
let (width, _height) = get_sane_terminal_dimensions();
|
||||
// Print meal name
|
||||
self.print_name_to_terminal(width, highlight);
|
||||
// Get notes, i.e. allergenes, descriptions, tags
|
||||
self.print_category_and_primary_tags(highlight);
|
||||
self.print_descriptions(width, highlight);
|
||||
self.print_price_and_secondary_tags(highlight);
|
||||
}
|
||||
|
||||
fn print_name_to_terminal(&self, width: usize, highlight: bool) {
|
||||
let max_name_width = width - NAME_PRE.width();
|
||||
let mut name_parts = textwrap::wrap(&self.meta.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();
|
||||
let pre = if_plain!(NAME_PRE, NAME_PRE_PLAIN);
|
||||
println!(
|
||||
"{}{}",
|
||||
hl_if(highlight, pre),
|
||||
color!(hl_if(highlight, first_name_part); bold),
|
||||
);
|
||||
for name_part in name_parts {
|
||||
let name_part = hl_if(highlight, name_part);
|
||||
let pre = if_plain!(NAME_CONTINUE_PRE, NAME_CONTINUE_PRE_PLAIN);
|
||||
println!("{}{}", hl_if(highlight, pre), color!(name_part; bold),);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_category_and_primary_tags(&self, highlight: bool) {
|
||||
let mut tag_str = self
|
||||
.meta
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.is_primary())
|
||||
.map(|tag| tag.as_id());
|
||||
let tag_str_colored =
|
||||
if_plain!(color!(tag_str.join(" "); bright_black), tag_str.join(", "));
|
||||
let pre = if_plain!(CATEGORY_PRE, CATEGORY_PRE_PLAIN);
|
||||
let comma_if_plain = if_plain!("", ",");
|
||||
println!(
|
||||
"{}{}{} {}",
|
||||
hl_if(highlight, pre),
|
||||
color!(self.meta.category; bright_blue),
|
||||
color!(comma_if_plain; bright_black),
|
||||
tag_str_colored
|
||||
);
|
||||
}
|
||||
|
||||
fn print_descriptions(&self, width: usize, highlight: bool) {
|
||||
let pre = if_plain!(OTHER_NOTE_PRE, OTHER_NOTE_PRE_PLAIN);
|
||||
let pre_continue = if_plain!(OTHER_NOTE_CONTINUE_PRE, OTHER_NOTE_CONTINUE_PRE_PLAIN);
|
||||
let max_note_width = width - OTHER_NOTE_PRE.width();
|
||||
for note in &self.meta.descs {
|
||||
let mut note_parts = textwrap::wrap(note, max_note_width).into_iter();
|
||||
// There will always be a first part in the splitted string
|
||||
println!("{}{}", hl_if(highlight, pre), note_parts.next().unwrap());
|
||||
for part in note_parts {
|
||||
println!("{}{}", hl_if(highlight, pre_continue), part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_price_and_secondary_tags(&self, highlight: bool) {
|
||||
let prices = self.meta.prices.to_terminal_string();
|
||||
let mut secondary: Vec<_> = self
|
||||
.meta
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|tag| tag.is_secondary())
|
||||
.collect();
|
||||
secondary.sort_unstable();
|
||||
let secondary_str = secondary.iter().map(|tag| tag.as_id()).join(" ");
|
||||
let pre = if_plain!(PRICES_PRE, PRICES_PRE_PLAIN);
|
||||
println!(
|
||||
"{}{} {}",
|
||||
hl_if(highlight, pre),
|
||||
prices,
|
||||
color!(secondary_str; bright_black),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn hl_if<S>(highlight: bool, text: S) -> String
|
||||
where
|
||||
S: fmt::Display,
|
||||
{
|
||||
if highlight {
|
||||
color!(text; bright_yellow)
|
||||
} else {
|
||||
format!("{}", text)
|
||||
}
|
||||
}
|
40
src/tag.rs
40
src/tag.rs
|
@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||
use strum::{Display, EnumIter, IntoEnumIterator};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::{config::State, error::Result, get_sane_terminal_dimensions, print_json};
|
||||
use crate::{config::CONF, error::Result, get_sane_terminal_dimensions, print_json};
|
||||
|
||||
const ID_WIDTH: usize = 4;
|
||||
const TEXT_INDENT: &str = " ";
|
||||
|
@ -161,14 +161,14 @@ impl Tag {
|
|||
///
|
||||
/// Will respect any settings given, i.e. emojis will be used
|
||||
/// unless the output should be plain.
|
||||
pub fn as_id<Cmd>(&self, state: &State<Cmd>) -> String {
|
||||
pub fn as_id(&self) -> String {
|
||||
match self {
|
||||
Self::Vegan => if_plain!(state: "🌱".into(), "Vegan".into()),
|
||||
Self::Vegetarian => if_plain!(state:"🧀".into(), "Vegetarian".into()),
|
||||
Self::Pig => if_plain!(state:"🐖".into(), "Pig".into()),
|
||||
Self::Fish => if_plain!(state:"🐟".into(), "Fish".into()),
|
||||
Self::Cow => if_plain!(state:"🐄".into(), "Cow".into()),
|
||||
Self::Poultry => if_plain!(state:"🐓".into(), "Poultry".into()),
|
||||
Self::Vegan => if_plain!("🌱".into(), "Vegan".into()),
|
||||
Self::Vegetarian => if_plain!("🧀".into(), "Vegetarian".into()),
|
||||
Self::Pig => if_plain!("🐖".into(), "Pig".into()),
|
||||
Self::Fish => if_plain!("🐟".into(), "Fish".into()),
|
||||
Self::Cow => if_plain!("🐄".into(), "Cow".into()),
|
||||
Self::Poultry => if_plain!("🐓".into(), "Poultry".into()),
|
||||
_ => {
|
||||
// If no special emoji is available, just use the id
|
||||
let number: u8 = (*self).into();
|
||||
|
@ -180,11 +180,11 @@ impl Tag {
|
|||
/// Print this tag.
|
||||
///
|
||||
/// Does **not** respect `--json`, use [`Self::print_all`].
|
||||
pub fn print<Cmd>(&self, state: &State<Cmd>) {
|
||||
let emoji = if state.args.plain && self.is_primary() {
|
||||
pub fn print(&self) {
|
||||
let emoji = if CONF.args.plain && self.is_primary() {
|
||||
format!("{:>width$}", "-", width = ID_WIDTH)
|
||||
} else {
|
||||
let emoji = self.as_id(state);
|
||||
let emoji = self.as_id();
|
||||
let emoji_len = emoji.width();
|
||||
format!(
|
||||
"{}{}",
|
||||
|
@ -201,20 +201,20 @@ impl Tag {
|
|||
);
|
||||
println!(
|
||||
"{} {}\n{}",
|
||||
color!(state: emoji; bright_yellow, bold),
|
||||
color!(state: self; bold),
|
||||
color!(state: description; bright_black),
|
||||
color!(emoji; bright_yellow, bold),
|
||||
color!(self; bold),
|
||||
color!(description; bright_black),
|
||||
);
|
||||
}
|
||||
|
||||
/// Print all tags.
|
||||
pub fn print_all<Cmd>(state: &State<Cmd>) -> Result<()> {
|
||||
if state.args.json {
|
||||
Self::print_all_json(state)
|
||||
pub fn print_all() -> Result<()> {
|
||||
if CONF.args.json {
|
||||
Self::print_all_json()
|
||||
} else {
|
||||
for tag in Tag::iter() {
|
||||
println!();
|
||||
tag.print(state);
|
||||
tag.print();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
@ -227,11 +227,11 @@ impl Tag {
|
|||
/// - name: The name of the tag.
|
||||
/// - desc: A simple description.
|
||||
///
|
||||
fn print_all_json<Cmd>(state: &State<Cmd>) -> Result<()> {
|
||||
fn print_all_json() -> Result<()> {
|
||||
let tags: Vec<HashMap<&str, String>> = Tag::iter()
|
||||
.map(|tag| {
|
||||
vec![
|
||||
("id", tag.as_id(state)),
|
||||
("id", tag.as_id()),
|
||||
("name", tag.to_string()),
|
||||
("desc", tag.describe().to_owned()),
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue