date logic: parsing, computations, config, states
date: - parse a date specification - now has most of the date computations we need main: - add explicit state machine (states + config) - has first steps towards figuring out intended state - still needs more logic, and some stuff can probably move to date
This commit is contained in:
parent
f7627246f5
commit
124c1b2a55
|
@ -20,3 +20,4 @@ rpassword = "7.3.1"
|
||||||
serde = {version = "1.0.204", features = ["derive"]}
|
serde = {version = "1.0.204", features = ["derive"]}
|
||||||
serde_json = "1.0.122"
|
serde_json = "1.0.122"
|
||||||
colored = "2.1.0"
|
colored = "2.1.0"
|
||||||
|
nom = "7.1.3"
|
||||||
|
|
155
src/date.rs
Normal file
155
src/date.rs
Normal file
|
@ -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<DaySpec>> {
|
||||||
|
delimited(ws, separated_list1(tuple((tag(","), ws)), parse_day_spec), tuple((ws, eof)))(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_spec(input: &str) -> Result<Vec<DaySpec>, Box<dyn Error>> {
|
||||||
|
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<NaiveDate> {
|
||||||
|
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<NaiveDate> {
|
||||||
|
// 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<DaySpec>,
|
||||||
|
) -> Vec<NaiveDate> {
|
||||||
|
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<NaiveDate> = 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<T> {
|
||||||
|
fn insert_opt(&mut self, item: Option<T>) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Ord> InsertOpt<T> for BTreeSet<T> {
|
||||||
|
fn insert_opt(&mut self, item: Option<T>) -> bool {
|
||||||
|
if let Some(value) = item {
|
||||||
|
self.insert(value)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
160
src/main.rs
160
src/main.rs
|
@ -52,6 +52,7 @@ mod hedgedoc;
|
||||||
use hedgedoc::HedgeDoc;
|
use hedgedoc::HedgeDoc;
|
||||||
mod mediawiki;
|
mod mediawiki;
|
||||||
use mediawiki::Mediawiki;
|
use mediawiki::Mediawiki;
|
||||||
|
mod date;
|
||||||
|
|
||||||
const FALLBACK_TEMPLATE: &str = variables_and_settings::FALLBACK_TEMPLATE;
|
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" },
|
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,
|
hedgedoc::CONFIG,
|
||||||
mediawiki::CONFIG,
|
mediawiki::CONFIG,
|
||||||
email::CONFIG,
|
email::CONFIG,
|
||||||
|
@ -73,11 +95,11 @@ const CONFIG_SPEC: CfgSpec<'static> = CfgSpec {
|
||||||
fields: &[
|
fields: &[
|
||||||
CfgField::Default { key: "email-greeting",
|
CfgField::Default { key: "email-greeting",
|
||||||
default: "Hallo liebe Mitreisende,",
|
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",
|
CfgField::Default { key: "email-signature",
|
||||||
default: "\n\n[Diese Nachricht wurde automatisiert vom Plenumsbot erstellt und ist daher ohne Unterschrift gültig.]",
|
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<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
// set up config file access
|
// set up config file access
|
||||||
|
|
||||||
let ansi_art = r#"
|
|
||||||
_____ _____ _____ ____ _____ _ ____ _
|
|
||||||
/ ____/ ____/ ____| _ \ | __ \| | | _ \ | |
|
|
||||||
| | | | | | | |_) | | |__) | | ___ _ __ _ _ _ __ ___ | |_) | ___ | |_
|
|
||||||
| | | | | | | _ < | ___/| |/ _ \ '_ \| | | | '_ ` _ \| _ < / _ \| __|
|
|
||||||
| |___| |___| |____| |_) | | | | | __/ | | | |_| | | | | | | |_) | (_) | |_
|
|
||||||
\_____\_____\_____|____/ |_| |_|\___|_| |_|\__,_|_| |_| |_|____/ \___/ \__|
|
|
||||||
|
|
||||||
"#;
|
|
||||||
|
|
||||||
println!("{}", ansi_art.red());
|
|
||||||
|
|
||||||
let args = parse_args();
|
let args = parse_args();
|
||||||
let config_file = args.config_file.as_str();
|
let config_file = args.config_file.as_str();
|
||||||
let config = KV::new(config_file).unwrap();
|
let config = KV::new(config_file).unwrap();
|
||||||
|
@ -149,7 +158,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
if args.check_mode {
|
if args.check_mode {
|
||||||
return config_spec::interactive_check(&CONFIG_SPEC, config);
|
return config_spec::interactive_check(&CONFIG_SPEC, config);
|
||||||
}
|
}
|
||||||
// config
|
// get config
|
||||||
let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run());
|
let hedgedoc = HedgeDoc::new(&config["hedgedoc-server-url"], is_dry_run());
|
||||||
let email_ = Email::new(
|
let email_ = Email::new(
|
||||||
&config["email-server"],
|
&config["email-server"],
|
||||||
|
@ -169,6 +178,57 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||||
&config["wiki-http-password"],
|
&config["wiki-http-password"],
|
||||||
is_dry_run(),
|
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<i64> = 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
|
// Dienstage diesen Monat
|
||||||
let all_tuesdays: Vec<NaiveDate> = get_all_weekdays(0, Weekday::Tue);
|
let all_tuesdays: Vec<NaiveDate> = get_all_weekdays(0, Weekday::Tue);
|
||||||
|
@ -464,3 +524,71 @@ fn try_to_remove_top_instructions(pad_content: String) -> String {
|
||||||
let result: Cow<str> = re_top_instructions.replace_all(&pad_content, "---");
|
let result: Cow<str> = re_top_instructions.replace_all(&pad_content, "---");
|
||||||
result.to_string() // Wenn es nicht geklappt hat, wird einfach das Pad mit dem Kommentar zurückgegeben
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue