Compare commits
2 commits
dd0ad8d538
...
ef0d32f0b7
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ef0d32f0b7 | ||
![]() |
830f45423f |
4 changed files with 306 additions and 229 deletions
254
src/main.rs
254
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<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(¶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<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
269
src/output.rs
Normal 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(())
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue