Compare commits

...

2 commits

Author SHA1 Message Date
_
ef0d32f0b7 add upstream calendars page 2025-09-11 01:49:26 +00:00
_
830f45423f extract output module 2025-09-11 01:27:08 +00:00
4 changed files with 306 additions and 229 deletions

View file

@ -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<String>,
/// Hide all these UIDs from the final output
hide_uids: BTreeSet<String>,
/// Timezone to use for output (e.g. "Antarctica/South_Pole")
///
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
timezone: chrono_tz::Tz,
/// Used as the page title and OpenGraph title in meta tags
title: String,
}
#[derive(Deserialize)]
struct Config {
campfires: Vec<wac_campfire::Config>,
common_ninjas: Vec<wac_common_ninja::Config>,
icals: Vec<wac_ical::Config>,
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<CalendarUi> {
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<String>,
}
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<wac_campfire::Calendar>,
@ -197,7 +183,7 @@ fn read_data_from_disk(config: &Config) -> Result<Data> {
fn process_data<'a>(
data: &'a Data,
config_output: &'a ConfigOutput,
config_output: &'a output::Config,
now: DateTime<chrono_tz::Tz>,
) -> Result<Vec<EventInstance>> {
let params = Parameters::new(now)?;
@ -208,7 +194,7 @@ fn process_data<'a>(
for ev in campfire
.event_instances(&params)?
.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(&params)?
.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(&params)?
.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<chrono_tz::Tz>,
) -> Result<()> {
let today = now.date_naive();
let mut last_month_printed: Option<String> = 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#"
<style>
body {
font-family: sans-serif;
font-size: 14pt;
line-height: 1.6;
max-width: 700px;
}
img {
max-width: 100%;
height: auto;
}
.past {
color: #888;
}
</style>
"#;
let description = &config.description;
let title = &config.title;
let s = maud::html! {
(maud::PreEscaped("<!DOCTYPE html>"))
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(())
}

269
src/output.rs Normal file
View file

@ -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#"
<style>
body {
font-family: sans-serif;
font-size: 14pt;
line-height: 1.6;
max-width: 700px;
}
img {
max-width: 100%;
height: auto;
}
.past {
color: #888;
}
</style>
"#;
#[derive(Deserialize)]
pub(crate) struct Config {
/// Used as the OpenGraph description in meta tags
description: String,
hide_summaries: BTreeSet<String>,
/// Hide all these UIDs from the final output
hide_uids: BTreeSet<String>,
/// Timezone to use for output (e.g. "Antarctica/South_Pole")
///
/// <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
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<String> {
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<chrono_tz::Tz>,
) -> Result<()> {
let today = now.date_naive();
let mut last_month_printed: Option<String> = 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("<!DOCTYPE html>"))
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("<!DOCTYPE html>"))
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(())
}

View file

@ -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<String>,
_end_date: Option<String>,
#[serde(alias = "endTime")]
end_time: Option<String>,
_end_time: Option<String>,
#[serde(alias = "eventName")]
event_name: String,
location: String,

View file

@ -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)]