diff --git a/src/main.rs b/src/main.rs index 12688de..b601fb2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ struct Config { common_ninjas: Vec, icals: Vec, output: output::Config, + feeds: Vec, } impl Config { @@ -52,10 +53,11 @@ impl Config { .chain(self.icals.iter().map(|ical| ical.dl.clone())) } - fn upstreams(&self) -> Vec { + fn upstream_calendars(&self) -> Vec { let Self { campfires, common_ninjas, + feeds: _, icals, output: _, } = self; @@ -161,7 +163,7 @@ struct Data { icals: Vec, } -fn read_data_from_disk(config: &Config) -> Result { +fn read_calendars(config: &Config) -> Result { Ok(Data { campfires: config .campfires @@ -256,9 +258,19 @@ async fn do_everything(cli: &CliAuto) -> Result<()> { std::fs::rename(&temp_path, &dl.file_path)?; } - let data = read_data_from_disk(&config)?; - let instances = process_data(&data, &config.output, now)?; - output::write_html(&config.output, &config.upstreams(), &instances, now)?; + let events = { + let cal_data = read_calendars(&config)?; + process_data(&cal_data, &config.output, now)? + }; + let feed_items = read_feeds(&config)?; + + output::write_html( + &config.output, + &feed_items, + &config.upstream_calendars(), + &events, + now, + )?; Ok(()) } @@ -280,31 +292,40 @@ fn main_auto(cli: CliAuto) -> Result<()> { } #[derive(clap::Parser)] -struct CliDebugOutput { +struct CliDebugEvents { #[arg(long)] config: Utf8PathBuf, } -fn main_debug_output(cli: CliDebugOutput) -> Result<()> { +fn main_debug_events(cli: CliDebugEvents) -> Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Started tracing"); let config = std::fs::read_to_string(&cli.config).context("Failed to read config file")?; let config: Config = toml::from_str(&config).context("Failed to parse config file")?; - let data = read_data_from_disk(&config).context("Failed to read data from disk")?; - 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")?; + let data = read_calendars(&config).context("Failed to read data from disk")?; + + let events = process_data(&data, &config.output, now).context("Failed to process data")?; + + std::fs::create_dir_all("output")?; + output::atomic_write( + "output/calendars.html", + &output::calendars_page(&config.upstream_calendars(), now), + )?; + output::atomic_write( + "output/index.html", + &output::index_page(&config.output, &events, now), + )?; Ok(()) } #[derive(clap::Parser)] -struct CliDebugRss { - paths: Vec, +struct CliDebugFeed { + #[arg(long)] + config: Utf8PathBuf, } /// Wraps rss::Item in our own type suitable for merging @@ -314,12 +335,11 @@ pub(crate) struct FeedItem { inner: rss::Item, } -fn main_debug_rss(cli: CliDebugRss) -> Result<()> { +fn read_feeds(config: &Config) -> Result> { let mut items = Vec::new(); - let now = Utc::now(); - for path in &cli.paths { - let s = std::fs::read(path)?; + for feed in &config.feeds { + let s = std::fs::read(&feed.file_path)?; let channel = rss::Channel::read_from(std::io::BufReader::new(std::io::Cursor::new(s)))?; let channel_title = channel.title.clone(); @@ -340,30 +360,65 @@ fn main_debug_rss(cli: CliDebugRss) -> Result<()> { } } - items.sort_by_key(|item| item.date); + items.sort_by_key(|item| std::cmp::Reverse(item.date)); + Ok(items) +} - for item in items.iter().rev() { - println!("{}", item.channel_title); - println!("{}", item.inner.title.as_ref().unwrap()); - println!("{}", item.date.to_rfc3339()); - println!("{}", item.inner.link.as_ref().unwrap()); - println!(); - } +fn main_debug_feed(cli: CliDebugFeed) -> Result<()> { + tracing_subscriber::fmt::init(); + tracing::info!("Started tracing"); + let config = std::fs::read_to_string(&cli.config).context("Failed to read config file")?; + let config: Config = toml::from_str(&config).context("Failed to parse config file")?; + + let tz = &config.output.timezone; + let now = Utc::now().with_timezone(tz); + let items = read_feeds(&config)?; std::fs::create_dir_all("output")?; - output::atomic_write( - "output/feed.html", - &output::feed_page(&items, now.with_timezone(&chrono_tz::UTC)), - )?; + output::atomic_write("output/feed.html", &output::feed_page(&items, now))?; Ok(()) } +#[derive(clap::Parser)] +struct CliDebugOutput { + #[arg(long)] + config: Utf8PathBuf, +} + +fn main_debug_output(cli: CliDebugOutput) -> Result<()> { + tracing_subscriber::fmt::init(); + tracing::info!("Started tracing"); + let config = std::fs::read_to_string(&cli.config).context("Failed to read config file")?; + let config: Config = toml::from_str(&config).context("Failed to parse config file")?; + + let tz = &config.output.timezone; + let now = Utc::now().with_timezone(tz); + + let events = { + let data = read_calendars(&config).context("Failed to read calendars from disk")?; + process_data(&data, &config.output, now).context("Failed to process data")? + }; + + let feed_items = read_feeds(&config)?; + + std::fs::create_dir_all("output")?; + output::write_html( + &config.output, + &feed_items, + &config.upstream_calendars(), + &events, + now, + )?; + Ok(()) +} + #[derive(clap::Subcommand)] enum Commands { Auto(CliAuto), + DebugEvents(CliDebugEvents), + DebugFeed(CliDebugFeed), DebugOutput(CliDebugOutput), - DebugRss(CliDebugRss), } #[derive(clap::Parser)] @@ -378,7 +433,8 @@ fn main() -> Result<()> { match cli.command { Commands::Auto(x) => main_auto(x), + Commands::DebugEvents(x) => main_debug_events(x), + Commands::DebugFeed(x) => main_debug_feed(x), Commands::DebugOutput(x) => main_debug_output(x), - Commands::DebugRss(x) => main_debug_rss(x), } } diff --git a/src/output.rs b/src/output.rs index 61fb772..b861300 100644 --- a/src/output.rs +++ b/src/output.rs @@ -74,7 +74,10 @@ fn calendar_link(calendar_ui: &crate::CalendarUi) -> maud::PreEscaped { } } -fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime) -> String { +pub(crate) fn calendars_page( + upstreams: &[crate::CalendarUi], + now: DateTime, +) -> String { let description = "A list of upstream calendars used by this Wide-Angle Calendar instance"; let title = "Upstream calendars"; @@ -98,13 +101,13 @@ fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime) } body { h1 { (title) } + p { "Written at: " (now.format("%F %T")) } 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 { @@ -118,11 +121,103 @@ fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime) .into_string() } -pub(crate) fn feed_page(_feed_items: &[crate::FeedItem], _now: DateTime) -> String { - todo!() +pub(crate) fn feed_page(feed_items: &[crate::FeedItem], now: DateTime) -> String { + let mut feed_items = feed_items.iter(); + let mut machine = date_machine::StateMachine::default(); + let mut html_list = vec![]; + + loop { + match machine.pull() { + date_machine::Output::Data(date_machine::DataChunk { month, date, items }) => { + if let Some(month) = month { + html_list.push(maud::html! { h2 { (month) } }); + } + html_list.push(maud::html! { + (day_link(date)) + hr{} + ul { + @for item in items { + li { + (item) + } + } + } + }); + } + date_machine::Output::EndOfInput => break, + date_machine::Output::NeedInput => {} + } + + if let Some(item) = feed_items.next() { + let desc = item + .inner + .description + .as_ref() + .map(|desc| match desc.get(0..100) { + None => desc.to_string(), + Some(short_desc) => format!("{short_desc} ..."), + }); + + machine.push( + item.date.date_naive(), + maud::html! { + @if let Some(link) = &item.inner.link { + a href = (link) { (item.inner.title.as_deref().unwrap_or_default()) } + } @else { + (item.inner.title.as_deref().unwrap_or_default()) + } + ul { + @if let Some(desc) = desc { + li { (desc) } + } + li { (item.channel_title) } + } + }, + ); + } else { + machine.flush(); + } + } + + let description = "A feed merged from several input feeds"; + let title = "Feed"; + + 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 { "Written at: " (now.format("%F %T")) " (But it may lag by several hours - Not for ICE spotting)" } + p { + a href="index.html" { "Wide-Angle Calendar" } + " / " + a href="feed.html" { (title) } + } + @for entry in html_list { + (entry) + } + } + } + } + .into_string() } -fn index_page( +pub(crate) fn index_page( config: &Config, instances: &[EventInstance], now: DateTime, @@ -248,7 +343,13 @@ fn index_page( h1 { (title) } img src="hero.webp" width="700" height="233" {} p { "Written at: " (now.format("%F %T")) } - p { a href = "calendars.html" { "Upstream calendars" } } + p { + "Sub-pages:" + ul { + li { a href = "calendars.html" { "Upstream calendars" } } + li { a href = "feed.html" { "Feed" } } + } + } @for entry in html_list { (entry) } @@ -267,16 +368,224 @@ pub(crate) fn atomic_write(path: &str, content: &str) -> Result<()> { pub(crate) fn write_html( config: &Config, - // feed_items: &[crate::FeedItem], - upstreams: &[crate::CalendarUi], - instances: &[EventInstance], + feed_items: &[crate::FeedItem], + upstream_calendars: &[crate::CalendarUi], + events: &[EventInstance], now: DateTime, ) -> Result<()> { std::fs::create_dir_all("output")?; - atomic_write("output/calendars.html", &calendars_page(upstreams, now))?; - // atomic_write("output/feed.html", &feed_page(feed_items, now)?)?; - atomic_write("output/index.html", &index_page(config, instances, now))?; + atomic_write( + "output/calendars.html", + &calendars_page(upstream_calendars, now), + )?; + atomic_write("output/feed.html", &feed_page(feed_items, now))?; + atomic_write("output/index.html", &index_page(config, events, now))?; Ok(()) } + +/// Takes in a stream of items with dates, and formats them out categorized by month and date. +mod date_machine { + use chrono::NaiveDate; + use std::{cmp::PartialEq, collections::VecDeque, fmt}; + + pub(crate) struct DataChunk { + pub(crate) month: Option, + pub(crate) date: NaiveDate, + pub(crate) items: Vec, + } + + // Debug only if T: Debug + impl fmt::Debug for DataChunk { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DataChunk") + .field("month", &self.month) + .field("date", &self.date) + .field("items", &self.items) + .finish() + } + } + + // PartialEq only if T: PartialEq + impl PartialEq for DataChunk { + fn eq(&self, other: &Self) -> bool { + self.month == other.month && self.date == other.date && self.items == other.items + } + } + + pub(crate) enum Output { + Data(DataChunk), + EndOfInput, + NeedInput, + } + + // Debug only if T: Debug + impl fmt::Debug for Output { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Output::Data(chunk) => f.debug_tuple("Data").field(chunk).finish(), + Output::EndOfInput => f.write_str("EndOfInput"), + Output::NeedInput => f.write_str("NeedInput"), + } + } + } + + // PartialEq only if T: PartialEq + impl PartialEq for Output { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Output::Data(a), Output::Data(b)) => a == b, + (Output::EndOfInput, Output::EndOfInput) => true, + (Output::NeedInput, Output::NeedInput) => true, + _ => false, + } + } + } + + #[derive(Default)] + pub(crate) struct StateMachine { + data: VecDeque>, + flushed: bool, + last_month_printed: Option, + } + + impl StateMachine { + pub(crate) fn pull(&mut self) -> Output { + if self.data.len() >= 2 + && let Some(chunk) = self.data.pop_front() + { + if chunk.month.is_some() && self.last_month_printed != chunk.month { + self.last_month_printed = chunk.month.clone(); + } + return Output::Data(chunk); + } + if self.flushed { + if let Some(chunk) = self.data.pop_front() { + return Output::Data(chunk); + } + return Output::EndOfInput; + } + Output::NeedInput + } + + pub(crate) fn push(&mut self, date: NaiveDate, item: T) { + let month = Some(date.format("%B").to_string()); + let month = if month == self.last_month_printed { + None + } else { + month + }; + if let Some(chunk) = self.data.front_mut() { + if chunk.date == date { + chunk.items.push(item); + } else if chunk.month == month { + self.data.push_back(DataChunk { + month: None, + date, + items: vec![item], + }); + } else { + self.data.push_back(DataChunk { + month, + date, + items: vec![item], + }); + } + } else { + self.data.push_back(DataChunk { + month, + date, + items: vec![item], + }); + } + } + + pub(crate) fn flush(&mut self) { + self.flushed = true; + } + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + + #[test] + fn date_state_machine() { + use super::date_machine::*; + + let mut machine: StateMachine<&str> = StateMachine::default(); + let mut items = vec![ + (2025, 09, 10, "alpha"), + (2025, 09, 10, "bravo"), + (2025, 09, 11, "charlie"), + (2025, 09, 12, "delta"), + (2025, 09, 13, "echo"), + (2025, 10, 1, "foxtrot"), + (2025, 10, 2, "golf"), + (2025, 10, 3, "hotel"), + ] + .into_iter(); + let mut output = vec![]; + + loop { + match machine.pull() { + Output::Data(chunk) => { + output.push(chunk); + continue; + } + Output::EndOfInput => break, + Output::NeedInput => {} + } + + if let Some((y, m, d, item)) = items.next() { + let date = NaiveDate::from_ymd_opt(y, m, d).unwrap(); + machine.push(date, item); + } else { + machine.flush(); + } + } + + let expected = vec![ + DataChunk { + month: Some("September".to_string()), + date: NaiveDate::from_ymd_opt(2025, 09, 10).unwrap(), + items: vec!["alpha", "bravo"], + }, + DataChunk { + month: None, + date: NaiveDate::from_ymd_opt(2025, 09, 11).unwrap(), + items: vec!["charlie"], + }, + DataChunk { + month: None, + date: NaiveDate::from_ymd_opt(2025, 09, 12).unwrap(), + items: vec!["delta"], + }, + DataChunk { + month: None, + date: NaiveDate::from_ymd_opt(2025, 09, 13).unwrap(), + items: vec!["echo"], + }, + DataChunk { + month: Some("October".to_string()), + date: NaiveDate::from_ymd_opt(2025, 10, 1).unwrap(), + items: vec!["foxtrot"], + }, + DataChunk { + month: None, + date: NaiveDate::from_ymd_opt(2025, 10, 2).unwrap(), + items: vec!["golf"], + }, + DataChunk { + month: None, + date: NaiveDate::from_ymd_opt(2025, 10, 3).unwrap(), + items: vec!["hotel"], + }, + ]; + assert_eq!(output, expected, "{output:#?} != {expected:#?}"); + + assert_eq!("abc".get(0..100).unwrap_or("abc"), "abc"); + } +}