From 097745aeed41971a2336ae51e619e4ab26116ff5 Mon Sep 17 00:00:00 2001 From: _ <_@_> Date: Tue, 26 Aug 2025 06:34:53 +0000 Subject: [PATCH] add support for CommonNinja calendars --- src/main.rs | 50 ++++++++++++----- src/prelude.rs | 5 ++ src/wac_campfire.rs | 4 +- src/wac_common_ninja.rs | 121 ++++++++++++++++++++++++++++++++++++++++ src/wac_ical.rs | 4 +- 5 files changed, 166 insertions(+), 18 deletions(-) create mode 100644 src/prelude.rs create mode 100644 src/wac_common_ninja.rs diff --git a/src/main.rs b/src/main.rs index 00ffcd6..e2fce47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,23 @@ -use anyhow::{Context as _, Result, bail}; -use camino::Utf8PathBuf; -use chrono::{DateTime, Utc}; +use chrono::DateTime; use clap::Parser as _; -use serde::Deserialize; use std::{collections::BTreeSet, io::Write as _, time::Duration}; -use url::Url; + +use prelude::*; #[cfg(test)] mod tests; +mod prelude; mod wac_campfire; +mod wac_common_ninja; mod wac_ical; #[derive(Clone, Default, Deserialize)] -struct Downloadable { - /// URL to scrape to download the JSON +struct SimpleDownload { + /// URL to scrape to download the file from download_url: Option, - /// Disk location to cache the JSON file for debugging + /// Disk location to cache the file for debugging file_path: Utf8PathBuf, } @@ -52,10 +52,21 @@ struct ConfigOutput { #[derive(Deserialize)] struct Config { campfires: Vec, + common_ninjas: Vec, icals: Vec, output: ConfigOutput, } +impl Config { + fn downloads(&self) -> impl Iterator { + self.campfires + .iter() + .map(|cf| cf.dl.clone()) + .chain(self.common_ninjas.iter().map(|cn| cn.simple_download())) + .chain(self.icals.iter().map(|ical| ical.dl.clone())) + } +} + #[derive(clap::Parser)] struct CliAuto { #[arg(long)] @@ -175,6 +186,7 @@ impl EventInstance { #[derive(Default)] struct Data { campfires: Vec, + common_ninjas: Vec, icals: Vec, } @@ -185,6 +197,11 @@ fn read_data_from_disk(config: &Config) -> Result { .iter() .map(|cfg| wac_campfire::Calendar::read_from_config(cfg.clone())) .collect::, _>>()?, + common_ninjas: config + .common_ninjas + .iter() + .map(|cfg| wac_common_ninja::Calendar::read_from_config(cfg.clone())) + .collect::, _>>()?, icals: config .icals .iter() @@ -212,6 +229,16 @@ fn process_data<'a>( } } + for common_ninja in &data.common_ninjas { + for ev in common_ninja + .event_instances(¶ms)? + .into_iter() + .filter(|x| x.filter(config_output)) + { + instances.push(ev); + } + } + for ical in &data.icals { for ev in ical .event_instances(¶ms)? @@ -412,12 +439,7 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { let client = reqwest::Client::builder() .user_agent(APP_USER_AGENT) .build()?; - for dl in config - .campfires - .iter() - .map(|cf| &cf.dl) - .chain(config.icals.iter().map(|ical| &ical.dl)) - { + for dl in config.downloads() { let Some(download_url) = &dl.download_url else { continue; }; diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 0000000..6009610 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,5 @@ +pub(crate) use anyhow::{Context as _, Result, bail}; +pub(crate) use camino::Utf8PathBuf; +pub(crate) use chrono::Utc; +pub(crate) use serde::Deserialize; +pub(crate) use url::Url; diff --git a/src/wac_campfire.rs b/src/wac_campfire.rs index 19cb589..72af29d 100644 --- a/src/wac_campfire.rs +++ b/src/wac_campfire.rs @@ -4,14 +4,14 @@ //! //! Note that recurring events aren't implemented for this cause I don't know how they work -use super::{CalendarUi, DatePerhapsTime, Downloadable, EventInstance, Parameters}; +use super::{CalendarUi, DatePerhapsTime, EventInstance, Parameters, SimpleDownload}; use anyhow::{Context as _, Result, bail}; use serde::Deserialize; #[derive(Clone, Default, Deserialize)] pub(crate) struct Config { #[serde(flatten)] - pub(crate) dl: Downloadable, + pub(crate) dl: SimpleDownload, #[serde(flatten)] pub(crate) ui: CalendarUi, diff --git a/src/wac_common_ninja.rs b/src/wac_common_ninja.rs new file mode 100644 index 0000000..4d6174f --- /dev/null +++ b/src/wac_common_ninja.rs @@ -0,0 +1,121 @@ +use super::{CalendarUi, EventInstance, Parameters, SimpleDownload}; +use crate::prelude::*; + +#[derive(Clone, Default, Deserialize)] +struct SillyDownloadable { + /// URL to scrape to download the file from + download_url: Option, + + /// Disk location to cache the file for debugging + file_path: Utf8PathBuf, +} + +#[derive(Clone, Default, Deserialize)] +pub(crate) struct Config { + #[serde(flatten)] + pub(crate) dl: SillyDownloadable, + + #[serde(flatten)] + pub(crate) ui: CalendarUi, +} + +impl Config { + pub(crate) fn simple_download(&self) -> SimpleDownload { + todo!() + } +} + +#[derive(Deserialize)] +struct Event { + #[serde(alias = "durationInMinutes")] + duration_in_minutes: u32, + timestamp: i64, + title: String, + description: String, +} + +#[derive(Deserialize)] +struct Data { + items: Vec, +} + +/// The bit that we deserialize directly from JSON +#[derive(Deserialize)] +struct CalendarInner { + data: Data, +} + +pub(crate) struct Calendar { + config: Config, + inner: CalendarInner, +} + +impl Calendar { + fn to_event_instance(&self, params: &Parameters, ev: &Event) -> Result> { + let dt = chrono::DateTime::from_timestamp_millis(ev.timestamp) + .context("cannot represent timestamp as a date")? + .with_timezone(¶ms.tz); + let dtstart = crate::DatePerhapsTime { dt, all_day: false }; + if dtstart.dt < params.output_start || dtstart.dt > params.output_stop { + return Ok(None); + } + + Ok(Some(EventInstance { + calendar_ui: self.config.ui.clone(), + dtstart, + location: None, + recurrence_id: None, + summary: Some(ev.title.clone()), + uid: None, + url: None, + })) + } + + pub(crate) fn event_instances(&self, params: &Parameters) -> Result> { + self.inner + .data + .items + .iter() + .filter_map(|ev| self.to_event_instance(params, ev).transpose()) + .collect() + } + + pub(crate) fn read_from_str(config: Config, s: &str) -> Result { + let inner = serde_json::from_str(s)?; + Ok(Self { config, inner }) + } + + pub(crate) fn read_from_config(config: Config) -> Result { + let s = std::fs::read_to_string(&config.dl.file_path)?; + Self::read_from_str(config, &s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn end_to_end() { + let s = r#" +{"data":{"items":[{"timestamp":1748989800000,"durationInMinutes":90,"title":"Foo Bar","description":""},{"timestamp":1749999600000,"durationInMinutes":30,"title":"Snaf Oo","description":""}]},"success":true,"message":""} + "#; + + let cfg = Config { + dl: SillyDownloadable { + download_url: None, + file_path: ".".into(), + }, + ui: CalendarUi { + html_url: None, + short_name: "asdf".into(), + }, + }; + let cal = Calendar::read_from_str(cfg, s).unwrap(); + let params = + Parameters::new(Utc::now().with_timezone(&chrono_tz::America::Chicago)).unwrap(); + let instances = cal.event_instances(¶ms).unwrap(); + + assert_eq!(instances.len(), 2); + } +} diff --git a/src/wac_ical.rs b/src/wac_ical.rs index 20649ed..6ba5df8 100644 --- a/src/wac_ical.rs +++ b/src/wac_ical.rs @@ -1,6 +1,6 @@ //! Structs and functions specific to gathering input from ics files, which is a popular format that Google Calendar happens to put out -use super::{CalendarUi, DatePerhapsTime, Downloadable, EventInstance, Parameters}; +use super::{CalendarUi, DatePerhapsTime, EventInstance, Parameters, SimpleDownload}; use anyhow::{Context as _, Result, anyhow}; use base64::Engine as _; use chrono::TimeZone as _; @@ -12,7 +12,7 @@ use std::{collections::BTreeSet, str::FromStr as _}; #[derive(Clone, Default, Deserialize)] pub(crate) struct Config { #[serde(flatten)] - pub(crate) dl: Downloadable, + pub(crate) dl: SimpleDownload, /// Magical ID we pass to Google to deep-link to Google Calendar events google_id: Option,