diff --git a/src/main.rs b/src/main.rs index 45e1320..368500e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,12 @@ use chrono::DateTime; use clap::Parser as _; -use std::time::Duration; +use std::{collections::BTreeSet, io::Write as _, time::Duration}; use prelude::*; #[cfg(test)] mod tests; -mod output; mod prelude; mod wac_campfire; mod wac_common_ninja; @@ -31,12 +30,31 @@ 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: output::Config, + output: ConfigOutput, } impl Config { @@ -51,26 +69,6 @@ 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)] @@ -154,6 +152,22 @@ 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, @@ -183,7 +197,7 @@ fn read_data_from_disk(config: &Config) -> Result { fn process_data<'a>( data: &'a Data, - config_output: &'a output::Config, + config_output: &'a ConfigOutput, now: DateTime, ) -> Result> { let params = Parameters::new(now)?; @@ -194,7 +208,7 @@ fn process_data<'a>( for ev in campfire .event_instances(¶ms)? .into_iter() - .filter(|x| config_output.filter(x)) + .filter(|x| x.filter(config_output)) { instances.push(ev); } @@ -204,7 +218,7 @@ fn process_data<'a>( for ev in common_ninja .event_instances(¶ms)? .into_iter() - .filter(|x| config_output.filter(x)) + .filter(|x| x.filter(config_output)) { instances.push(ev); } @@ -214,7 +228,7 @@ fn process_data<'a>( for ev in ical .event_instances(¶ms)? .into_iter() - .filter(|x| config_output.filter(x)) + .filter(|x| x.filter(config_output)) { instances.push(ev); } @@ -224,6 +238,187 @@ 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/", @@ -258,7 +453,7 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { let data = read_data_from_disk(&config)?; let instances = process_data(&data, &config.output, now)?; - output::write_html(&config.output, &config.upstreams(), &instances, now)?; + output_html(&config.output, &instances, now)?; Ok(()) } @@ -296,8 +491,7 @@ 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::write_html(&config.output, &config.upstreams(), &instances, now) - .context("Failed to output HTML")?; + output_html(&config.output, &instances, now).context("Failed to output HTML")?; Ok(()) } diff --git a/src/output.rs b/src/output.rs deleted file mode 100644 index 250cb08..0000000 --- a/src/output.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! 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 8520b21..49e26ae 100644 --- a/src/wac_campfire.rs +++ b/src/wac_campfire.rs @@ -19,12 +19,11 @@ pub(crate) struct Config { #[derive(Deserialize)] struct Event { - #[serde(alias = "description")] - _description: String, + 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 6a1d085..05380d2 100644 --- a/src/wac_common_ninja.rs +++ b/src/wac_common_ninja.rs @@ -36,11 +36,10 @@ impl Config { #[derive(Deserialize)] struct Event { #[serde(alias = "durationInMinutes")] - _duration_in_minutes: u32, + duration_in_minutes: u32, timestamp: i64, title: String, - #[serde(alias = "description")] - _description: String, + description: String, } #[derive(Deserialize)]