diff --git a/src/main.rs b/src/main.rs index 368500e..45e1320 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,13 @@ use chrono::DateTime; use clap::Parser as _; -use std::{collections::BTreeSet, io::Write as _, time::Duration}; +use std::time::Duration; use prelude::*; #[cfg(test)] mod tests; +mod output; mod prelude; mod wac_campfire; mod wac_common_ninja; @@ -30,31 +31,12 @@ struct CalendarUi { short_name: String, } -#[derive(Deserialize)] -struct ConfigOutput { - /// Used as the OpenGraph description in meta tags - description: String, - - hide_summaries: BTreeSet, - - /// Hide all these UIDs from the final output - hide_uids: BTreeSet, - - /// Timezone to use for output (e.g. "Antarctica/South_Pole") - /// - /// - timezone: chrono_tz::Tz, - - /// Used as the page title and OpenGraph title in meta tags - title: String, -} - #[derive(Deserialize)] struct Config { campfires: Vec, common_ninjas: Vec, icals: Vec, - output: ConfigOutput, + output: output::Config, } impl Config { @@ -69,6 +51,26 @@ impl Config { ) .chain(self.icals.iter().map(|ical| ical.dl.clone())) } + + fn upstreams(&self) -> Vec { + let Self { + campfires, + common_ninjas, + icals, + output: _, + } = self; + + let mut upstreams: Vec<_> = campfires + .iter() + .map(|cfg| &cfg.ui) + .cloned() + .chain(common_ninjas.iter().map(|cfg| &cfg.ui).cloned()) + .chain(icals.iter().map(|cfg| &cfg.ui).cloned()) + .collect(); + upstreams.sort_by_key(|ui| ui.short_name.clone()); + + upstreams + } } #[derive(clap::Parser)] @@ -152,22 +154,6 @@ struct EventInstance { url: Option, } -impl EventInstance { - fn filter(&self, config_output: &ConfigOutput) -> bool { - if let Some(uid) = &self.uid - && config_output.hide_uids.contains(uid) - { - return false; - } - if let Some(summary) = &self.summary - && config_output.hide_summaries.contains(summary) - { - return false; - } - true - } -} - #[derive(Default)] struct Data { campfires: Vec, @@ -197,7 +183,7 @@ fn read_data_from_disk(config: &Config) -> Result { fn process_data<'a>( data: &'a Data, - config_output: &'a ConfigOutput, + config_output: &'a output::Config, now: DateTime, ) -> Result> { let params = Parameters::new(now)?; @@ -208,7 +194,7 @@ fn process_data<'a>( for ev in campfire .event_instances(¶ms)? .into_iter() - .filter(|x| x.filter(config_output)) + .filter(|x| config_output.filter(x)) { instances.push(ev); } @@ -218,7 +204,7 @@ fn process_data<'a>( for ev in common_ninja .event_instances(¶ms)? .into_iter() - .filter(|x| x.filter(config_output)) + .filter(|x| config_output.filter(x)) { instances.push(ev); } @@ -228,7 +214,7 @@ fn process_data<'a>( for ev in ical .event_instances(¶ms)? .into_iter() - .filter(|x| x.filter(config_output)) + .filter(|x| config_output.filter(x)) { instances.push(ev); } @@ -238,187 +224,6 @@ fn process_data<'a>( Ok(instances) } -fn output_html( - config: &ConfigOutput, - instances: &[EventInstance], - now: DateTime, -) -> Result<()> { - let today = now.date_naive(); - let mut last_month_printed: Option = None; - let mut last_date_printed = None; - let mut html_list = vec![]; - let mut day_list = vec![]; - for ei in instances { - let date = ei.dtstart.date_naive(); - let past = date < today; - let month = date.format("%B").to_string(); - match last_month_printed { - Some(ref x) if *x == month => {} - None | Some(_) => { - // FIXME: De-dupe - if !day_list.is_empty() { - html_list.push(maud::html! { - ul { @for entry in day_list { - (entry) - } } - }); - day_list = vec![]; - } - - html_list.push(maud::html! { - h2 { (month) } - }); - last_month_printed = Some(month); - } - } - if last_date_printed != Some(date) { - // FIXME: De-dupe - if !day_list.is_empty() { - html_list.push(maud::html! { - ul { @for entry in day_list { - (entry) - } } - }); - day_list = vec![]; - } - if past { - html_list.push(maud::html! { - p class="past"{ s { (date.format("%-d %A")) } } - }); - } else { - let date_s = date.format("%-d %A"); - let id = date.format("%F"); - html_list.push(maud::html! { - h3 id=(id) { (date_s) " " a href=(format!("#{id}")) title=(format!("Permalink to {date_s}")) {"🔗"} } - hr{} - }); - } - - last_date_printed = Some(date); - } - let time = ei.dtstart.time(); - let time = time - .map(|t| t.format("%l:%M %P").to_string()) - .unwrap_or_else(|| "No time listed".to_string()); - - let summary = ei - .summary - .as_deref() - .unwrap_or("Data error BXH45NAR - No summary in event"); - let summary = if let Some(url) = &ei.url { - maud::html! {a href=(url) {(summary)}} - } else { - maud::html! {(summary)} - }; - - if past { - day_list.push(maud::html! { - li class="past" { (time) " - " (summary) } - }); - } else { - let calendar_link = if let Some(html_url) = &ei.calendar_ui.html_url { - maud::html! { a href=(html_url) { (ei.calendar_ui.short_name) } } - } else { - maud::html! { (ei.calendar_ui.short_name)} - }; - - // This is where the main stuff happens - - tracing::debug!(uid = ei.uid, summary = ei.summary); - day_list.push(maud::html! { - li { details { - summary { (time) " - " (summary) } - ul { - li { (calendar_link) " calendar" } - @if let Some(location) = &ei.location { - li { "Location: " (location) } - } - } - } - } }); - } - } - // FIXME: De-dupe - if !day_list.is_empty() { - html_list.push(maud::html! { - ul { @for entry in day_list { - (entry) - } } - }); - } - - std::fs::create_dir_all("output")?; - { - let temp_path = "output/calendars.html.tmp"; - let final_path = "output/calendars.html"; - let mut f = std::fs::File::create(temp_path)?; - - f.write_all("".as_bytes())?; - std::fs::rename(temp_path, final_path)?; - } - - { - let temp_path = "output/index.html.tmp"; - let final_path = "output/index.html"; - let mut f = std::fs::File::create(temp_path)?; - let css = r#" - - "#; - - let description = &config.description; - let title = &config.title; - - let s = maud::html! { - (maud::PreEscaped("")) - html lang="en" { - head { - meta http-equiv="Content-Type" content="text/html; charset=utf-8" {} - meta name="viewport" content="width=device-width, initial-scale=1" {} - (maud::PreEscaped(css)) - - meta property="og:locale" content="en" {} - meta property="og:type" content="website" {} - - meta name="description" content=(description) {} - meta property="description" content=(description) {} - meta property="og:description" content=(description) {} - - title { (title) } - met property="og:title" content=(title) {} - } - body { - h1 { (title) } - img src="hero.webp" width="700" height="233" {} - p { "Written at: " (now.format("%F %T")) } - @for entry in html_list { - (entry) - } - } - } - } - .into_string(); - - f.write_all(s.as_bytes())?; - std::fs::rename(temp_path, final_path)?; - } - - Ok(()) -} - static APP_USER_AGENT: &str = concat!( env!("CARGO_PKG_NAME"), "_Z7FSRRA7/", @@ -453,7 +258,7 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { let data = read_data_from_disk(&config)?; let instances = process_data(&data, &config.output, now)?; - output_html(&config.output, &instances, now)?; + output::write_html(&config.output, &config.upstreams(), &instances, now)?; Ok(()) } @@ -491,7 +296,8 @@ fn main_debug_output(cli: CliDebugOutput) -> Result<()> { let tz = &config.output.timezone; let now = Utc::now().with_timezone(tz); let instances = process_data(&data, &config.output, now).context("Failed to process data")?; - output_html(&config.output, &instances, now).context("Failed to output HTML")?; + output::write_html(&config.output, &config.upstreams(), &instances, now) + .context("Failed to output HTML")?; Ok(()) } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..250cb08 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,269 @@ +//! The code that writes HTML for web browsers to read + +use chrono::DateTime; +use std::{collections::BTreeSet, io::Write as _}; + +use crate::{EventInstance, prelude::*}; + +const CSS: &str = r#" + +"#; + +#[derive(Deserialize)] +pub(crate) struct Config { + /// Used as the OpenGraph description in meta tags + description: String, + + hide_summaries: BTreeSet, + + /// Hide all these UIDs from the final output + hide_uids: BTreeSet, + + /// Timezone to use for output (e.g. "Antarctica/South_Pole") + /// + /// + pub(crate) timezone: chrono_tz::Tz, + + /// Used as the page title and OpenGraph title in meta tags + title: String, +} + +impl Config { + pub(crate) fn filter(&self, instance: &EventInstance) -> bool { + if let Some(uid) = &instance.uid + && self.hide_uids.contains(uid) + { + return false; + } + if let Some(summary) = &instance.summary + && self.hide_summaries.contains(summary) + { + return false; + } + true + } +} + +fn calendar_link(calendar_ui: &crate::CalendarUi) -> maud::PreEscaped { + if let Some(html_url) = &calendar_ui.html_url { + maud::html! { a href=(html_url) { (calendar_ui.short_name) } } + } else { + maud::html! { (calendar_ui.short_name)} + } +} + +pub(crate) fn write_html( + config: &Config, + upstreams: &[crate::CalendarUi], + instances: &[EventInstance], + now: DateTime, +) -> Result<()> { + let today = now.date_naive(); + let mut last_month_printed: Option = None; + let mut last_date_printed = None; + let mut html_list = vec![]; + let mut day_list = vec![]; + for ei in instances { + let date = ei.dtstart.date_naive(); + let past = date < today; + let month = date.format("%B").to_string(); + match last_month_printed { + Some(ref x) if *x == month => {} + None | Some(_) => { + // FIXME: De-dupe + if !day_list.is_empty() { + html_list.push(maud::html! { + ul { @for entry in day_list { + (entry) + } } + }); + day_list = vec![]; + } + + html_list.push(maud::html! { + h2 { (month) } + }); + last_month_printed = Some(month); + } + } + if last_date_printed != Some(date) { + // FIXME: De-dupe + if !day_list.is_empty() { + html_list.push(maud::html! { + ul { @for entry in day_list { + (entry) + } } + }); + day_list = vec![]; + } + if past { + html_list.push(maud::html! { + p class="past"{ s { (date.format("%-d %A")) } } + }); + } else { + let date_s = date.format("%-d %A"); + let id = date.format("%F"); + html_list.push(maud::html! { + h3 id=(id) { (date_s) " " a href=(format!("#{id}")) title=(format!("Permalink to {date_s}")) {"🔗"} } + hr{} + }); + } + + last_date_printed = Some(date); + } + let time = ei.dtstart.time(); + let time = time + .map(|t| t.format("%l:%M %P").to_string()) + .unwrap_or_else(|| "No time listed".to_string()); + + let summary = ei + .summary + .as_deref() + .unwrap_or("Data error BXH45NAR - No summary in event"); + let summary = if let Some(url) = &ei.url { + maud::html! {a href=(url) {(summary)}} + } else { + maud::html! {(summary)} + }; + + if past { + day_list.push(maud::html! { + li class="past" { (time) " - " (summary) } + }); + } else { + // This is where the main stuff happens + + tracing::debug!(uid = ei.uid, summary = ei.summary); + day_list.push(maud::html! { + li { details { + summary { (time) " - " (summary) } + ul { + li { (calendar_link(&ei.calendar_ui)) " calendar" } + @if let Some(location) = &ei.location { + li { "Location: " (location) } + } + } + } + } }); + } + } + // FIXME: De-dupe + if !day_list.is_empty() { + html_list.push(maud::html! { + ul { @for entry in day_list { + (entry) + } } + }); + } + + std::fs::create_dir_all("output")?; + { + let temp_path = "output/calendars.html.tmp"; + let final_path = "output/calendars.html"; + let mut f = std::fs::File::create(temp_path)?; + + let description = "A list of upstream calendars used by this Wide-Angle Calendar instance"; + let title = "Upstream calendars"; + + let s = maud::html! { + (maud::PreEscaped("")) + html lang="en" { + head { + meta http-equiv="Content-Type" content="text/html; charset=utf-8" {} + meta name="viewport" content="width=device-width, initial-scale=1" {} + (maud::PreEscaped(CSS)) + + meta property="og:locale" content="en" {} + meta property="og:type" content="website" {} + + meta name="description" content=(description) {} + meta property="description" content=(description) {} + meta property="og:description" content=(description) {} + + title { (title) } + met property="og:title" content=(title) {} + } + body { + h1 { (title) } + p { + a href="index.html" { "Wide-Angle Calendar" } + " / " + a href="calendars.html" { (title) } + } + + p { "Written at: " (now.format("%F %T")) } + p { "These are the calendars that Wide-Angle Calendar pulls from." } + + ol { + @for upstream in upstreams { + li { (calendar_link(upstream)) } + } + } + } + } + } + .into_string(); + + f.write_all(s.as_bytes())?; + std::fs::rename(temp_path, final_path)?; + } + + { + let temp_path = "output/index.html.tmp"; + let final_path = "output/index.html"; + let mut f = std::fs::File::create(temp_path)?; + + let description = &config.description; + let title = &config.title; + + let s = maud::html! { + (maud::PreEscaped("")) + html lang="en" { + head { + meta http-equiv="Content-Type" content="text/html; charset=utf-8" {} + meta name="viewport" content="width=device-width, initial-scale=1" {} + (maud::PreEscaped(CSS)) + + meta property="og:locale" content="en" {} + meta property="og:type" content="website" {} + + meta name="description" content=(description) {} + meta property="description" content=(description) {} + meta property="og:description" content=(description) {} + + title { (title) } + met property="og:title" content=(title) {} + } + body { + h1 { (title) } + img src="hero.webp" width="700" height="233" {} + p { "Written at: " (now.format("%F %T")) } + p { a href = "calendars.html" { "Upstream calendars" } } + @for entry in html_list { + (entry) + } + } + } + } + .into_string(); + + f.write_all(s.as_bytes())?; + std::fs::rename(temp_path, final_path)?; + } + + Ok(()) +} diff --git a/src/wac_campfire.rs b/src/wac_campfire.rs index 49e26ae..8520b21 100644 --- a/src/wac_campfire.rs +++ b/src/wac_campfire.rs @@ -19,11 +19,12 @@ pub(crate) struct Config { #[derive(Deserialize)] struct Event { - description: String, + #[serde(alias = "description")] + _description: String, #[serde(alias = "endDate")] - end_date: Option, + _end_date: Option, #[serde(alias = "endTime")] - end_time: Option, + _end_time: Option, #[serde(alias = "eventName")] event_name: String, location: String, diff --git a/src/wac_common_ninja.rs b/src/wac_common_ninja.rs index 05380d2..6a1d085 100644 --- a/src/wac_common_ninja.rs +++ b/src/wac_common_ninja.rs @@ -36,10 +36,11 @@ impl Config { #[derive(Deserialize)] struct Event { #[serde(alias = "durationInMinutes")] - duration_in_minutes: u32, + _duration_in_minutes: u32, timestamp: i64, title: String, - description: String, + #[serde(alias = "description")] + _description: String, } #[derive(Deserialize)]