From acff0b9fbc6f18d72b3d06bfa4f1557a856a9e77 Mon Sep 17 00:00:00 2001 From: figsoda Date: Tue, 25 Oct 2022 13:04:09 -0400 Subject: [PATCH] add --info and --time-format --- Cargo.lock | 47 +++++++++++++++- Cargo.toml | 1 + README.md | 2 + src/cli.rs | 11 ++++ src/config.rs | 14 +++++ src/course.rs | 150 +++++++++++++++++++++++++++++++++++++++++--------- src/main.rs | 9 ++- src/state.rs | 6 +- src/submit.rs | 4 +- 9 files changed, 211 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ee960eb..59d2fde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,7 +134,7 @@ dependencies = [ "js-sys", "num-integer", "num-traits", - "time", + "time 0.1.44", "wasm-bindgen", "winapi", ] @@ -666,6 +666,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "itoa" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc" + [[package]] name = "java-properties" version = "1.4.1" @@ -919,6 +925,15 @@ dependencies = [ "syn", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -1211,6 +1226,7 @@ dependencies = [ "rpassword", "serde", "tempfile", + "time 0.3.16", "toml", "ureq", "webbrowser", @@ -1371,6 +1387,35 @@ dependencies = [ "winapi", ] +[[package]] +name = "time" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fab5c8b9980850e06d92ddbe3ab839c062c801f3927c0fb8abd6fc8e918fbca" +dependencies = [ + "itoa", + "libc", + "num_threads", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" + +[[package]] +name = "time-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bb801831d812c562ae7d2bfb531f26e66e4e1f6b17307ba4149c5064710e5b" +dependencies = [ + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 372ce29..17a99f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ is_executable = "1.0.1" java-properties = "1.4.1" rpassword = "7.0.0" serde = { version = "1.0.147", features = ["derive"] } +time = { version = "0.3.16", features = ["formatting", "macros", "parsing"] } toml = "0.5.9" ureq = "2.5.0" webbrowser = "0.8.0" diff --git a/README.md b/README.md index 88fb517..3de81de 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,11 @@ Arguments: Options: -n, --no-submit Don't submit the project + -i, --info Show information about the project and exit -o, --open Open the project page in a web browser --color Controls when to use color [default: auto] [possible values: auto, always, never] -c, --config Specify the path to the config file, looks for sagoin/config.toml under XDG configuration directories on unix-like systems, and defaults to {FOLDERID_RoamingAppData}\sagoin\config.toml on windows when unspecified [env: SAGOIN_CONFIG=] + -t, --time-format Specify how to format the due date, ignored without the --info flag, defaults to "[month repr:short] [day padding:none], [hour]:[minute]" when unspecified [env: SAGOIN_TIME_FORMAT=] -u, --username Specify the username for authentication, see --username-type for more information [env: SAGOIN_USERNAME=] -U, --username-type Specify the type for the username, defaults to text when unspecified [env: SAGOIN_USERNAME_TYPE=] [possible values: command, file, text] -p, --password Specify the password for authentication, see --password-type for more information [env: SAGOIN_PASSWORD=] diff --git a/src/cli.rs b/src/cli.rs index ce1007b..0fd676b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,10 @@ pub(crate) struct Opts { #[arg(short, long)] pub no_submit: bool, + /// Show information about the project and exit + #[arg(short, long)] + pub info: bool, + /// Open the project page in a web browser #[arg(short, long)] pub open: bool, @@ -36,6 +40,13 @@ pub(crate) struct Opts { #[arg(short, long, env = "SAGOIN_CONFIG", value_name = "FILE")] pub config: Option, + /// Specify how to format the due date, ignored without the --info flag, + /// defaults to "[month repr:short] [day padding:none], [hour]:[minute]" when unspecified + /// + /// See https://time-rs.github.io/book/api/format-description.html for more information + #[arg(short, long, env = "SAGOIN_TIME_FORMAT", value_name = "FORMAT")] + pub time_format: Option, + /// Specify the username for authentication, /// see --username-type for more information #[arg(short, long, env = "SAGOIN_USERNAME")] diff --git a/src/config.rs b/src/config.rs index 42260cf..129ea81 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,7 +18,9 @@ use crate::{ pub struct Config { pub dir: Option, pub no_submit: bool, + pub info: bool, pub open: bool, + pub time_format: String, pub(crate) username: Option, pub(crate) password: Option, pub(crate) pre_submit_hook: Option, @@ -33,6 +35,7 @@ pub(crate) enum Credential { #[derive(Deserialize)] struct ConfigFile { + time_format: Option, username: Option, username_type: Option, password: Option, @@ -56,7 +59,12 @@ pub fn load_config() -> Result<(Config, State>)> { Config { dir: opts.dir, no_submit: opts.no_submit, + info: opts.info, open: opts.open, + time_format: opts + .time_format + .or(cfg.time_format) + .unwrap_or_else(default_time_format), username: Credential::from_fallback( &mut state, "username", @@ -78,7 +86,9 @@ pub fn load_config() -> Result<(Config, State>)> { Config { dir: opts.dir, no_submit: opts.no_submit, + info: opts.info, open: opts.open, + time_format: opts.time_format.unwrap_or_else(default_time_format), username: opts.username.and_then(|user| { Credential::from_os_string(&mut state, "username", user, opts.username_type) }), @@ -93,6 +103,10 @@ pub fn load_config() -> Result<(Config, State>)> { )) } +fn default_time_format() -> String { + "[month repr:short] [day padding:none], [hour]:[minute]".into() +} + impl Credential { fn from_fallback( state: &mut State, diff --git a/src/course.rs b/src/course.rs index 0ddfad5..66d62eb 100644 --- a/src/course.rs +++ b/src/course.rs @@ -1,37 +1,103 @@ -use eyre::{eyre, Result, WrapErr}; -use icalendar::parser::{read_calendar_simple, unfold}; +use eyre::{eyre, Report, Result, WrapErr}; +use icalendar::parser::{read_calendar_simple, unfold, Component}; +use time::{format_description, macros::format_description, PrimitiveDateTime}; + +use std::io::{stdout, Write}; use crate::{Props, PropsExt}; -pub fn get_course_url(props: &Props) -> Result { - let proj = format!( - "{} project {}: ", - props.get_prop("courseName")?, - props.get_prop("projectNumber")?, - ); +#[derive(Default)] +pub struct CourseInfo { + summary: String, + due: Option, + description: Option, + url: Option, +} - read_calendar_simple(&unfold( - &ureq::get(&format!( - "{}/feed/CourseCalendar?courseKey={}", - props.get_prop("baseURL")?, - props.get_prop("courseKey")?, - )) - .call() - .wrap_err("failed to download the course calendar")? - .into_string() - .wrap_err("failed to parse the course calendar")?, - )) - .map_err(|e| eyre!("{e}").wrap_err("failed to parse the course calendar"))? - .get(0) - .and_then(|root| { - root.components.iter().find_map(|component| { +pub fn print_course_info(props: &Props, fmt: String) -> Result<()> { + let info = get_course_info(props)?; + let mut out = stdout().lock(); + + writeln!(out, "{}", info.summary)?; + if let Some(due) = info.due { + write!(out, "Due: ")?; + + PrimitiveDateTime::parse( + &due, + format_description!("[year][month][day]T[hour][minute][second]Z"), + ) + .wrap_err("failed to parse time")? + .format_into( + &mut out, + &format_description::parse(&fmt).wrap_err("failed to parse time format")?, + ) + .context("failed to format time")?; + + writeln!(out)?; + } + if let Some(description) = info.description { + // https://github.com/hoodie/icalendar-rs/issues/53 + writeln!(out, "{}", description.replace('\\', ""))?; + } + if let Some(url) = info.url { + writeln!(out, "{url}")?; + } + + Ok(()) +} + +fn get_course_info(props: &Props) -> Result { + get_course_props( + props, + |component, prefix| { + let mut summary = None; + let mut due = None; + let mut description = None; + let mut url = None; + + for prop in component.properties.iter() { + match prop.name.as_str() { + "SUMMARY" => { + let s = prop.val.to_string(); + if s.starts_with(prefix) { + summary = Some(s); + } else { + return None; + } + } + + "DTSTART" => due = Some(prop.val.to_string()), + + "DESCRIPTION" => description = Some(prop.val.to_string()), + + "URL" => url = Some(prop.val.to_string()), + + _ => {} + } + } + + summary.map(|summary| CourseInfo { + summary, + due, + description, + url, + }) + }, + || eyre!("failed to find information for the course"), + ) +} + +pub fn get_course_url(props: &Props) -> Result { + get_course_props( + props, + |component, prefix| { let mut url = None; let mut found = false; for prop in component.properties.iter() { match prop.name.as_str() { "SUMMARY" => { - if prop.val.as_str().starts_with(&proj) { + if prop.val.as_str().starts_with(prefix) { found = true; } else { return None; @@ -49,7 +115,39 @@ pub fn get_course_url(props: &Props) -> Result { } else { None } - }) + }, + || eyre!("failed to find the course url"), + ) +} + +fn get_course_props( + props: &Props, + f: fn(&Component, &str) -> Option, + e: fn() -> Report, +) -> Result { + let prefix = format!( + "{} project {}: ", + props.get_prop("courseName")?, + props.get_prop("projectNumber")?, + ); + + read_calendar_simple(&unfold( + &ureq::get(&format!( + "{}/feed/CourseCalendar?courseKey={}", + props.get_prop("baseURL")?, + props.get_prop("courseKey")?, + )) + .call() + .wrap_err("failed to download the course calendar")? + .into_string() + .wrap_err("failed to parse the course calendar")?, + )) + .map_err(|e| eyre!("{e}").wrap_err("failed to parse the course calendar"))? + .get(0) + .and_then(|root| { + root.components + .iter() + .find_map(|component| f(component, &prefix)) }) - .ok_or_else(|| eyre!("failed to find the course url")) + .ok_or_else(e) } diff --git a/src/main.rs b/src/main.rs index a1400e0..4125bf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,10 @@ use std::{ io::{self, Cursor}, }; -use sagoin::{config::load_config, course::get_course_url}; +use sagoin::{ + config::load_config, + course::{get_course_url, print_course_info}, +}; fn main() -> Result<()> { let (cfg, mut state) = load_config()?; @@ -23,6 +26,10 @@ fn main() -> Result<()> { let props = java_properties::read(File::open(".submit").wrap_err("failed to read .submit")?) .wrap_err("failed to parse .submit")?; + if cfg.info { + return print_course_info(&props, cfg.time_format); + } + if !cfg.no_submit { let mut zip = ZipWriter::new(Cursor::new(Vec::new())); zip.set_comment(""); diff --git a/src/state.rs b/src/state.rs index d5bd909..84d89c8 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,11 +1,11 @@ +use color_eyre::config::{HookBuilder, Theme}; +use eyre::{Result, WrapErr}; + use std::{ fmt::Display, io::{self, StderrLock, Write}, }; -use color_eyre::config::{HookBuilder, Theme}; -use eyre::{Result, WrapErr}; - pub struct State { pub(crate) color: bool, pub(crate) out: W, diff --git a/src/submit.rs b/src/submit.rs index b5a73e4..7750a32 100644 --- a/src/submit.rs +++ b/src/submit.rs @@ -1,8 +1,8 @@ -use std::io::Write; - use eyre::{eyre, Result, WrapErr}; use multipart::client::lazy::Multipart; +use std::io::Write; + use crate::{config::Config, state::State, warn, Props, PropsExt}; impl State {