diff --git a/Cargo.toml b/Cargo.toml index 5cabe31..5d32839 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ rpassword = "7.3.1" serde = {version = "1.0.204", features = ["derive"]} serde_json = "1.0.122" colored = "2.1.0" +nom = "7.1.3" diff --git a/src/date.rs b/src/date.rs new file mode 100644 index 0000000..5f76952 --- /dev/null +++ b/src/date.rs @@ -0,0 +1,155 @@ +use chrono::{Datelike, Duration, NaiveDate, Weekday}; +use nom::branch::alt; +use nom::bytes::complete::{tag, tag_no_case}; +use nom::character::complete::{i32, multispace0 as ws}; +use nom::combinator::{eof, value, verify}; +use nom::multi::separated_list1; +use nom::sequence::{delimited, separated_pair, tuple}; +use nom::IResult; +use std::collections::BTreeSet; +use std::error::Error; + +/// Defines a day within the month. Negative numbers count from the end. +pub enum DaySpec { + /// nth day, no matter the weekday + DayOfMonth(i32), + /// nth occurrence of a specific weekday + NthWeekdayOfMonth(i32, Weekday), +} + +/* ***** parsing w/ nom ***** */ + +fn parse_weekday(input: &str) -> IResult<&str, Weekday> { + alt(( + value(Weekday::Mon, tag_no_case("Mon")), + value(Weekday::Tue, tag_no_case("Tue")), + value(Weekday::Wed, tag_no_case("Wed")), + value(Weekday::Thu, tag_no_case("Thu")), + value(Weekday::Fri, tag_no_case("Fri")), + value(Weekday::Sat, tag_no_case("Sat")), + value(Weekday::Sun, tag_no_case("Sun")), + ))(input) +} + +fn parse_nth_weekday(input: &str) -> IResult<&str, DaySpec> { + let (remaining, (i, w)) = delimited( + tuple((tag("("), ws)), + separated_pair( + verify(i32, |&i| i >= -5 && i != 0 && i <= 5), + tuple((tag(","), ws)), + parse_weekday, + ), + tuple((ws, tag(")"))), + )(input)?; + Ok((remaining, DaySpec::NthWeekdayOfMonth(i, w))) +} + +fn parse_day_of_month(input: &str) -> IResult<&str, DaySpec> { + let (remaining, i) = verify(i32, |&i| i >= -31 && i != 0 && i <= 31)(input)?; + Ok((remaining, DaySpec::DayOfMonth(i))) +} + +fn parse_day_spec(input: &str) -> IResult<&str, DaySpec> { + alt((parse_nth_weekday, parse_day_of_month))(input) +} + +fn parse_day_spec_list(input: &str) -> IResult<&str, Vec> { + delimited(ws, separated_list1(tuple((tag(","), ws)), parse_day_spec), tuple((ws, eof)))(input) +} + +pub fn parse_spec(input: &str) -> Result, Box> { + match parse_day_spec_list(input) { + Ok((_remainder, days)) => Ok(days), + Err(e) => Err(format!("{}", e).into()), + } +} + +/* ***** figuring out which days are within a given month ***** */ + +fn day_of_month(year: i32, month: u32, day: i32) -> Option { + if day > 0 { + NaiveDate::from_ymd_opt(year, month, day as u32) + } else { + NaiveDate::from_ymd_opt( + if month == 12 { year + 1 } else { year }, + if month == 12 { 1 } else { month }, + 1, + ) + .map(|d| d + Duration::days(day as i64)) + } +} + +fn nth_weekday_of_month(year: i32, month: u32, week_day: Weekday, nth: i32) -> Option { + // collect all + let mut dates = Vec::new(); + let mut date = NaiveDate::from_ymd_opt(year, month, 1)?; + while date.weekday() != week_day { + date = date.succ_opt()?; + } + while date.month() == month { + dates.push(date); + date = date + Duration::weeks(1); + } + // get the right one + let index = if nth < 0 { dates.len() as i32 + nth } else { nth - 1 } as usize; + dates.get(index).cloned() +} + +pub fn get_matching_dates_around( + reference_date: NaiveDate, day_specs: Vec, +) -> Vec { + let mut dates = BTreeSet::new(); + // compute current / previous / next month once + let (cyear, cmonth, _day) = + (reference_date.year_ce().1 as i32, reference_date.month(), reference_date.day()); + let (pyear, pmonth) = if cmonth == 1 { (cyear - 1, 12) } else { (cyear, cmonth - 1) }; + let (nyear, nmonth) = if cmonth == 12 { (cyear + 1, 1) } else { (cyear, cmonth + 1) }; + // collect all relevant days, deduplicating as we go + for spec in &day_specs { + match spec { + DaySpec::DayOfMonth(day) => { + dates.insert_opt(day_of_month(pyear, pmonth, *day)); + dates.insert_opt(day_of_month(cyear, cmonth, *day)); + dates.insert_opt(day_of_month(nyear, nmonth, *day)); + }, + DaySpec::NthWeekdayOfMonth(nth, week_day) => { + dates.insert_opt(nth_weekday_of_month(pyear, pmonth, *week_day, *nth)); + dates.insert_opt(nth_weekday_of_month(cyear, cmonth, *week_day, *nth)); + dates.insert_opt(nth_weekday_of_month(nyear, nmonth, *week_day, *nth)); + }, + } + } + // make sorted list + let mut result: Vec = dates.into_iter().collect(); + result.sort_unstable(); + // find closest index & return either nearest 2 or nearest 3 (if reference date is in the list) + match result.binary_search(&reference_date) { + Ok(index) => { + let start = index.saturating_sub(1); + let end = (index + 2).min(result.len()); + result[start..end].to_vec() + }, + Err(index) => { + let start = index.saturating_sub(1); + let end = (index + 1).min(result.len()); + result[start..end].to_vec() + }, + } +} + +/* ***** more util ***** */ + +// I don't want to deal with manual unwrapping / matching… +trait InsertOpt { + fn insert_opt(&mut self, item: Option) -> bool; +} + +impl InsertOpt for BTreeSet { + fn insert_opt(&mut self, item: Option) -> bool { + if let Some(value) = item { + self.insert(value) + } else { + false + } + } +} diff --git a/src/main.rs b/src/main.rs index 6954621..26165f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ mod hedgedoc; use hedgedoc::HedgeDoc; mod mediawiki; use mediawiki::Mediawiki; +mod date; const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE; @@ -64,6 +65,27 @@ const CONFIG_SPEC: CfgSpec<'static> = CfgSpec { CfgField::Silent { key: "version", default: "1" }, ], }, + CfgGroup { name: "state", + description: "varying state info that needs to be kept across invocations", + fields: &[ + CfgField::Default { key: "name", + default: "NORMAL", + description: "Named state of the program logic. (NORMAL, ANNOUNCED, REMINDED, LOGGED)", + }, + CfgField::Optional { key: "last-run", + description: "Last run of the program (used to figure out state of previous plenum mails.)", + }, + ] + }, + CfgGroup { name: "date", + description: "specification of relevant dates", + fields: &[ + CfgField::Default { key: "spec", + default: "(2,Tue),(4,Tue)", + description: "Days on which the plenum happens.", + }, + ], + }, hedgedoc::CONFIG, mediawiki::CONFIG, email::CONFIG, @@ -73,11 +95,11 @@ const CONFIG_SPEC: CfgSpec<'static> = CfgSpec { fields: &[ CfgField::Default { key: "email-greeting", default: "Hallo liebe Mitreisende,", - description: "\"Hello\"-greeting added at the start of every email." + description: "\"Hello\"-greeting added at the start of every email.", }, CfgField::Default { key: "email-signature", default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]", - description: "Text added at the bottom of every email." + description: "Text added at the bottom of every email.", }, ], }, @@ -129,19 +151,6 @@ fn parse_args() -> Args { fn main() -> Result<(), Box> { // set up config file access - - let ansi_art = r#" - _____ _____ _____ ____ _____ _ ____ _ - / ____/ ____/ ____| _ \ | __ \| | | _ \ | | - | | | | | | | |_) | | |__) | | ___ _ __ _ _ _ __ ___ | |_) | ___ | |_ - | | | | | | | _ < | ___/| |/ _ \ '_ \| | | | '_ ` _ \| _ < / _ \| __| - | |___| |___| |____| |_) | | | | | __/ | | | |_| | | | | | | |_) | (_) | |_ - \_____\_____\_____|____/ |_| |_|\___|_| |_|\__,_|_| |_| |_|____/ \___/ \__| - -"#; - - println!("{}", ansi_art.red()); - let args = parse_args(); let config_file = args.config_file.as_str(); let config = KV::new(config_file).unwrap(); @@ -149,7 +158,7 @@ fn main() -> Result<(), Box> { if args.check_mode { return config_spec::interactive_check(&CONFIG_SPEC, config); } - // config + // get config let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run()); let email_ = Email::new( &config["email-server"], @@ -169,6 +178,57 @@ fn main() -> Result<(), Box> { &config["wiki-http-password"], is_dry_run(), ); + // figure out where we are + let last_state = ProgramState::parse(&config["state-name"]); + let last_run = config.get("state-last-run").unwrap_or_default(); + let last_run = NaiveDate::parse_from_str(&last_run, "%Y-%m-%d").unwrap_or_default(); + // figure out where we should be + let plenum_spec = date::parse_spec(&config["date-spec"])?; + let today = Local::now().date_naive(); + let nearest_plenum_days = date::get_matching_dates_around(today, plenum_spec); + // deltas has either 2 or 3 days, if 3 then the middle one is == 0 + let deltas: Vec = nearest_plenum_days.iter().map(|&d| (d - today).num_days()).collect(); + // find the relevant one: + let delta = *match last_state { + // In normal state, the past doesn't matter – just pick the next occurrence. + ProgramState::Normal | ProgramState::Logged => deltas.last(), + // If announced or reminded, a *recent* past event may be relevant. + ProgramState::Announced | ProgramState::Reminded | ProgramState::Waiting => { + let candidate = deltas.get(deltas.len() - 2); + if candidate.is_some_and(|&i| i >= -7) { + candidate + } else { + deltas.last() + } + }, + } + .unwrap(); // always has at least 2 elems + let intended_state = if delta > 3 { + ProgramState::Normal // nothing to do 3+ days in advance + } else if delta > 1 { + ProgramState::Announced // 2+ days in advance we want to have it announced + } else if delta >= 0 { + ProgramState::Reminded // up to the day of, we want to send a reminder (or cancel) + } else if delta > -2 { + ProgramState::Waiting // we will wait a day for the protocol to be cleaned up + } else { + ProgramState::Logged // after that, we want to log it to the list & the wiki + }; + + println!("aktueller Zustand: {}", last_state); + println!("letzter Durchlauf: {}", last_run); + println!("relevante Tage: {:#?}", nearest_plenum_days); + println!("Abweichungen: {:#?}", deltas); + println!("Geschätzter Termin (Δ): {}", delta); + println!("Gewollter Zustand: {}", intended_state); + + // TODO: state-matrix: was macht sinn? + // Normal Announced Reminded Waiting Logged + // Normal nop announce reminder(!) nop log + // Announced log nop reminder nop log + // Reminded log log;announce nop nop log + // Waiting log log;announce log;reminder(!) nop log + // Logged nop announce reminder(!) nop nop // Dienstage diesen Monat let all_tuesdays: Vec = get_all_weekdays(0, Weekday::Tue); @@ -464,3 +524,71 @@ fn try_to_remove_top_instructions(pad_content: String) -> String { let result: Cow = re_top_instructions.replace_all(&pad_content, "---"); result.to_string() // Wenn es nicht geklappt hat, wird einfach das Pad mit dem Kommentar zurückgegeben } + +/// State machine of the announcement logic, ensuring we can deal with inconsistent runs +/// (e.g. multiple runs in a day, or skipping days). The states are: +/// +/// - Normal (default) – in between dates, with nothing to do +/// - Announced – the first announcement was sent +/// - Reminded – the reminder was sent +/// +/// The bot knows in which state it is at all times. It knows this because it +/// knows in which state it isn't. By comparing where it is with where it +/// isn't, it obtains a difference, or deviation. The program logic uses +/// deviations to generate corrective commands to drive the bot from a state +/// where it is to a state where it isn't, and arriving in a state where it +/// wasn't, it now is. Consequently, the state where it is, is now the state +/// that it wasn't, and it follows that the state that it was in, is now the +/// state that it isn't in. +/// +/// In the event that the state that it is in is not the state that it wasn't, +/// the system has acquired a variation, the variation being the difference +/// between where the bot is, and where it wasn't. If variation is considered +/// to be a significant factor, it too may be corrected by the program logic. +/// However, the bot must also know where it was. +/// +/// The program logic works as follows. Because a delay has modified some +/// of the information the bot has obtained, it is not sure just when it is. +/// However, it is sure when it isn't, within reason, and it knows when it was. +/// It now subtracts when it should be from when it wasn't, or vice-versa, and +/// by differentiating this from the algebraic sum of when it shouldn't be, +/// and when it was, it is able to obtain the deviation and its variation, +/// which is called error. +enum ProgramState { + /// Normal is the default state, with no actions currently outstanding. + Normal, + /// There is an upcoming event, and the first announcement has been sent. + Announced, + /// The reminder / second announcement was sent. + Reminded, + /// We're waiting for the protocol to surely be there & cleaned up. + Waiting, + /// (transient) The protocol was submitted. + Logged, +} + +impl ProgramState { + /// Read state from a string. Always succeeds by just assuming the default state. + fn parse(name: &str) -> ProgramState { + match name.to_lowercase().as_str() { + "announced" => ProgramState::Announced, + "reminded" => ProgramState::Reminded, + "waiting" => ProgramState::Waiting, + // "logged" also becomes Normal + _ => ProgramState::Normal, + } + } +} + +impl std::fmt::Display for ProgramState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + ProgramState::Normal => "Normal", + ProgramState::Announced => "Announced", + ProgramState::Reminded => "Reminded", + ProgramState::Waiting => "Waiting", + ProgramState::Logged => "Logged", + }; + write!(f, "{}", str) + } +}