diff --git a/src/cache.rs b/src/cache.rs index 517293d..168149d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -39,10 +39,12 @@ use reqwest::{ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::{info, warn}; +mod fetchable; #[cfg(test)] mod tests; mod wrapper; +pub use fetchable::Fetchable; pub use wrapper::clear_cache as clear; use crate::error::{Error, Result, ResultExt}; diff --git a/src/cache/fetchable.rs b/src/cache/fetchable.rs new file mode 100644 index 0000000..f9d2ef0 --- /dev/null +++ b/src/cache/fetchable.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(from = "T", untagged)] +pub enum Fetchable { + /// The value does not exist, but can be fetched. + None, + /// The value has been fetched. + Fetched(T), +} + +impl Fetchable { + pub fn is_fetched(&self) -> bool { + matches!(self, Self::Fetched(_)) + } + + pub fn fetch(&mut self, f: F) -> Result<&T> + where + F: FnOnce() -> Result, + { + match self { + Self::Fetched(ref value) => Ok(value), + Self::None => { + let value = f()?; + *self = Self::Fetched(value); + // This is safe, since we've just fetched successfully + Ok(self.unwrap()) + } + } + } + + /// Panics if the resource doesn't exist + fn unwrap(&self) -> &T { + match self { + Self::Fetched(value) => value, + Self::None => panic!("Called .unwrap() on a Fetchable that is not fetched!"), + } + } +} + +impl From for Fetchable { + fn from(value: T) -> Self { + Fetchable::Fetched(value) + } +} diff --git a/src/canteen.rs b/src/canteen.rs index cab0246..370c487 100644 --- a/src/canteen.rs +++ b/src/canteen.rs @@ -1,33 +1,49 @@ -use chrono::Duration; -use reqwest::blocking::Client; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; use tracing::info; -use std::marker::PhantomData; +mod de; +mod ser; use crate::{ - cache, - config::CanteensState, - error::{Error, Result}, - geoip, get_sane_terminal_dimensions, print_json, ENDPOINT, TTL_CANTEENS, + cache::Fetchable, config::CanteensState, error::Result, geoip, get_sane_terminal_dimensions, + meal::Meal, pagination::PaginatedList, print_json, ENDPOINT, TTL_CANTEENS, }; +use self::ser::CanteenCompleteWithoutMeals; + +pub type CanteenId = usize; + const ADRESS_INDENT: &str = " "; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] +#[serde(from = "de::CanteenDeserialized")] pub struct Canteen { - id: usize, + id: CanteenId, + #[serde(flatten)] + meta: Fetchable, + meals: Fetchable>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Meta { name: String, city: String, address: String, - coordinates: [f32; 2], + coordinates: Option<[f32; 2]>, +} + +impl Meta { + pub fn fetch(id: CanteenId) -> Result { + todo!() + } } impl Canteen { - pub fn print(&self, state: &CanteensState) { + pub fn print(&mut self, state: &CanteensState) -> Result<()> { let (width, _) = get_sane_terminal_dimensions(); let address = textwrap::fill( - &self.address, + self.address()?, textwrap::Options::new(width) .initial_indent(ADRESS_INDENT) .subsequent_indent(ADRESS_INDENT), @@ -35,9 +51,29 @@ impl Canteen { println!( "{} {}\n{}", color!(state: format!("{:>4}", self.id); bold, bright_yellow), - color!(state: self.name; bold), + color!(state: self.meta()?.name; bold), color!(state: address; bright_black), ); + Ok(()) + } + + pub fn id(&self) -> CanteenId { + self.id + } + + pub fn name(&mut self) -> Result<&String> { + Ok(&self.meta()?.address) + } + + pub fn address(&mut self) -> Result<&String> { + Ok(&self.meta()?.address) + } + + pub fn complete_without_meals(&mut self) -> Result> { + Ok(CanteenCompleteWithoutMeals { + id: self.id, + meta: self.meta()?, + }) } pub fn fetch(state: &CanteensState) -> Result> { @@ -48,99 +84,37 @@ impl Canteen { let (lat, long) = geoip::fetch(state)?; info!( "Fetching canteens for lat: {}, long: {} with radius: {}", - lat, long, state.cmd.radius + lat, long, state.cmd.geo.radius ); format!( "{}/canteens?near[lat]={}&near[lng]={}&near[dist]={}", - ENDPOINT, lat, long, state.cmd.radius, + 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: &[Self]) -> Result<()> { + pub fn print_all(state: &CanteensState, canteens: &mut [Self]) -> Result<()> { if state.args.json { - print_json(&canteens) + Self::print_all_json(canteens) } else { for canteen in canteens { println!(); - canteen.print(state); + canteen.print(state)?; } Ok(()) } } -} -struct PaginatedList<'client, T> -where - T: DeserializeOwned, -{ - client: &'client Client, - next_page: Option, - ttl: Duration, - __item: PhantomData, -} + fn print_all_json(canteens: &mut [Self]) -> Result<()> { + let serializable: Vec<_> = canteens + .iter_mut() + .map(|c| c.complete_without_meals()) + .try_collect()?; + print_json(&serializable) + } -impl<'client, T> PaginatedList<'client, T> -where - T: DeserializeOwned, -{ - pub fn from>(client: &'client Client, url: S, ttl: Duration) -> Result { - Ok(PaginatedList { - client, - ttl, - next_page: Some(url.as_ref().into()), - __item: PhantomData, - }) - } -} - -impl<'client, T> PaginatedList<'client, T> -where - T: DeserializeOwned, -{ - pub fn try_flatten_and_collect(self) -> Result> { - let mut ret = vec![]; - for value in self { - let value = value?; - ret.extend(value); - } - Ok(ret) - } -} - -impl<'client, T> Iterator for PaginatedList<'client, T> -where - T: DeserializeOwned, -{ - type Item = Result>; - - fn next(&mut self) -> Option { - // This will yield until no next_page is available - let curr_page = self.next_page.take()?; - let res = cache::fetch(self.client, &curr_page, self.ttl, |text, headers| { - let val = serde_json::from_str::>(&text) - .map_err(|why| Error::Deserializing(why, "fetching json in pagination iterator"))?; - Ok((val, headers.this_page, headers.next_page, headers.last_page)) - }); - match res { - Ok((val, this_page, next_page, last_page)) => { - // Only update next_page, if we're not on the last page! - // This should be safe for all cases - if this_page.unwrap_or_default() < last_page.unwrap_or_default() { - // OpenMensa returns empty lists for large pages - // this is just to keep me sane - if !val.is_empty() { - self.next_page = next_page; - } - } - Some(Ok(val)) - } - Err(why) => { - // Implicitly does not set the next_page url, so - // this iterator is done now - Some(Err(why)) - } - } + fn meta(&mut self) -> Result<&Meta> { + self.meta.fetch(|| Meta::fetch(self.id)) } } diff --git a/src/canteen/de.rs b/src/canteen/de.rs new file mode 100644 index 0000000..d9352ef --- /dev/null +++ b/src/canteen/de.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; + +use crate::cache::Fetchable; + +use super::{CanteenId, Meta}; + +/// For deserializing responses from API/canteens. +#[derive(Debug, Deserialize)] +pub struct CanteenDeserialized { + id: CanteenId, + name: String, + city: String, + address: String, + coordinates: Option<[f32; 2]>, +} + +impl From for super::Canteen { + fn from(raw: CanteenDeserialized) -> Self { + Self { + id: raw.id, + meta: Fetchable::Fetched(Meta { + name: raw.name, + city: raw.city, + address: raw.address, + coordinates: raw.coordinates, + }), + meals: Fetchable::None, + } + } +} diff --git a/src/canteen/ser.rs b/src/canteen/ser.rs new file mode 100644 index 0000000..7927104 --- /dev/null +++ b/src/canteen/ser.rs @@ -0,0 +1,10 @@ +use serde::Serialize; + +use super::{CanteenId, Meta}; + +#[derive(Debug, Serialize)] +pub struct CanteenCompleteWithoutMeals<'c> { + pub id: CanteenId, + #[serde(flatten)] + pub meta: &'c Meta, +} diff --git a/src/config/args.rs b/src/config/args.rs index 7d38e88..d587521 100644 --- a/src/config/args.rs +++ b/src/config/args.rs @@ -63,6 +63,22 @@ pub enum Command { #[derive(Debug, StructOpt)] pub struct CanteensCommand { + /// Ignore other arguments. List all canteens. + #[structopt(long, short)] + pub all: bool, + + #[structopt(flatten)] + pub geo: GeoCommand, +} + +#[derive(Debug, Clone, StructOpt)] +pub enum CloseCommand { + /// Show meals from canteens around you. Will overwrite --id. + Close(GeoCommand), +} + +#[derive(Debug, Clone, StructOpt)] +pub struct GeoCommand { /// Latitude of your position. If omitted, geoip will be used to guess it. #[structopt(long)] pub lat: Option, @@ -74,10 +90,6 @@ pub struct CanteensCommand { /// Maximum distance of potential canteens from your position in km. #[structopt(long, short, default_value = "10")] pub radius: f32, - - /// Ignore other arguments. List all canteens. - #[structopt(long, short)] - pub all: bool, } #[derive(Debug, Clone, StructOpt)] @@ -144,6 +156,9 @@ pub struct MealsCommand { #[structopt(long, env = "MENSA_FAVS_CATEGORY_SUB")] pub no_favs_cat: Vec, + + #[structopt(subcommand)] + pub close: Option, } arg_enum! { @@ -185,6 +200,7 @@ impl Default for MealsCommand { no_favs_tag: vec![], favs_cat: vec![], no_favs_cat: vec![], + close: None, } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 929c189..598a7b7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -22,7 +22,7 @@ lazy_static! { static ref REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10); } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all(deserialize = "kebab-case"))] pub struct ConfigFile { #[serde(default)] @@ -58,7 +58,7 @@ impl ConfigFile { pub type CanteensState<'s> = State<'s, CanteensCommand>; pub type MealsState<'s> = State<'s, MealsCommand>; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct State<'s, Cmd> { pub config: Option, pub client: Client, diff --git a/src/geoip.rs b/src/geoip.rs index 3ddea61..202477f 100644 --- a/src/geoip.rs +++ b/src/geoip.rs @@ -26,15 +26,16 @@ struct LatLong { /// 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)> { - Ok(if state.cmd.lat.is_none() || state.cmd.long.is_none() { + let geo = &state.cmd.geo; + Ok(if geo.lat.is_none() || geo.long.is_none() { let guessed = fetch_geoip(&state.client)?; ( - state.cmd.lat.unwrap_or(guessed.latitude), - state.cmd.long.unwrap_or(guessed.longitude), + geo.lat.unwrap_or(guessed.latitude), + geo.long.unwrap_or(guessed.longitude), ) } else { // Cannot panic, due to above if - (state.cmd.lat.unwrap(), state.cmd.long.unwrap()) + (geo.lat.unwrap(), geo.long.unwrap()) }) } diff --git a/src/main.rs b/src/main.rs index 1dbebef..f5504e8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -108,6 +108,7 @@ mod config; mod error; mod geoip; mod meal; +mod pagination; mod tag; use crate::{ @@ -166,8 +167,8 @@ fn real_main() -> Result<()> { } Some(Command::Canteens(cmd)) => { let state = State::from(state, cmd); - let canteens = Canteen::fetch(&state)?; - Canteen::print_all(&state, &canteens)?; + let mut canteens = Canteen::fetch(&state)?; + Canteen::print_all(&state, &mut canteens)?; } Some(Command::Tags) => { Tag::print_all(&state)?; diff --git a/src/meal/mod.rs b/src/meal/mod.rs index 4343b84..5b1a953 100644 --- a/src/meal/mod.rs +++ b/src/meal/mod.rs @@ -5,12 +5,16 @@ use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use unicode_width::UnicodeWidthStr; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::{ cache::fetch_json, - config::{args::MealsCommand, MealsState, PriceTags}, - error::{pass_info, Result}, + 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, @@ -33,7 +37,7 @@ lazy_static! { static ref TTL_MEALS: Duration = Duration::hours(1); } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(from = "RawMeal")] pub struct Meal { #[serde(rename = "id")] @@ -55,7 +59,7 @@ struct RawMeal { category: String, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(debug, serde(deny_unknown_fields))] pub struct Prices { students: f32, @@ -108,32 +112,55 @@ impl Meal { /// Fetch the meals. /// /// This will respect passed cli arguments and the configuration. - pub fn fetch(state: &MealsState) -> Result> { - let url = format!( - "{}/canteens/{}/days/{}/meals", - ENDPOINT, - state.canteen_id()?, - state.date() - ); - fetch_json(&state.client, url, *TTL_MEALS) + pub fn fetch(state: &MealsState) -> Result>> { + 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) + } + } } /// Print the given meals. /// /// Thi will respect passed cli arguments and the configuration. - pub fn print_all(state: &MealsState, meals: &[Self]) -> Result<()> { + pub fn print_all(state: &MealsState, meals: &HashMap>) -> Result<()> { // Load the filter which is used to select which meals to print. let filter = state.get_filter(); // Load the favourites which will be used for marking meals. let favs = state.get_favs_rule(); - let meals = meals.iter().filter(|meal| filter.is_match(meal)); + // Filter all meals + let meal_map = meals.iter().map(|(id, meals)| { + let keep = meals.iter().filter(|meal| filter.is_match(meal)); + (id, keep.collect::>()) + }); if state.args.json { - print_json(&meals.collect::>()) + print_json(&meal_map.collect::>()) } else { - for meal in meals { - let is_fav = favs.is_match(meal); - println!(); - meal.print(state, is_fav); + for (id, meals) in meal_map { + println!("{}", id); + for meal in meals { + let is_fav = favs.is_match(meal); + println!(); + meal.print(state, is_fav); + } } Ok(()) } @@ -279,6 +306,28 @@ where } } +fn fetch_canteens_for_close_command( + state: &MealsState, + close: &GeoCommand, +) -> Result> { + 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> { + 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 { diff --git a/src/pagination.rs b/src/pagination.rs new file mode 100644 index 0000000..4444a8a --- /dev/null +++ b/src/pagination.rs @@ -0,0 +1,84 @@ +use chrono::Duration; +use reqwest::blocking::Client; +use serde::de::DeserializeOwned; + +use std::marker::PhantomData; + +use crate::{ + cache, + error::{Error, Result}, +}; + +pub struct PaginatedList<'client, T> +where + T: DeserializeOwned, +{ + client: &'client Client, + next_page: Option, + ttl: Duration, + __item: PhantomData, +} + +impl<'client, T> PaginatedList<'client, T> +where + T: DeserializeOwned, +{ + pub fn from>(client: &'client Client, url: S, ttl: Duration) -> Result { + Ok(PaginatedList { + client, + ttl, + next_page: Some(url.as_ref().into()), + __item: PhantomData, + }) + } +} + +impl<'client, T> PaginatedList<'client, T> +where + T: DeserializeOwned, +{ + pub fn try_flatten_and_collect(self) -> Result> { + let mut ret = vec![]; + for value in self { + let value = value?; + ret.extend(value); + } + Ok(ret) + } +} + +impl<'client, T> Iterator for PaginatedList<'client, T> +where + T: DeserializeOwned, +{ + type Item = Result>; + + fn next(&mut self) -> Option { + // This will yield until no next_page is available + let curr_page = self.next_page.take()?; + let res = cache::fetch(self.client, &curr_page, self.ttl, |text, headers| { + let val = serde_json::from_str::>(&text) + .map_err(|why| Error::Deserializing(why, "fetching json in pagination iterator"))?; + Ok((val, headers.this_page, headers.next_page, headers.last_page)) + }); + match res { + Ok((val, this_page, next_page, last_page)) => { + // Only update next_page, if we're not on the last page! + // This should be safe for all cases + if this_page.unwrap_or_default() < last_page.unwrap_or_default() { + // OpenMensa returns empty lists for large pages + // this is just to keep me sane + if !val.is_empty() { + self.next_page = next_page; + } + } + Some(Ok(val)) + } + Err(why) => { + // Implicitly does not set the next_page url, so + // this iterator is done now + Some(Err(why)) + } + } + } +}