Compare commits

...

2 commits

Author SHA1 Message Date
_
bc99011bb6 good enough for MVP 2025-09-11 05:56:03 +00:00
_
8bfd102f79 got something showable for Facebook feeds 2025-09-11 05:26:28 +00:00
2 changed files with 409 additions and 44 deletions

View file

@ -37,6 +37,7 @@ struct Config {
common_ninjas: Vec<wac_common_ninja::Config>, common_ninjas: Vec<wac_common_ninja::Config>,
icals: Vec<wac_ical::Config>, icals: Vec<wac_ical::Config>,
output: output::Config, output: output::Config,
feeds: Vec<SimpleDownload>,
} }
impl Config { impl Config {
@ -52,10 +53,11 @@ impl Config {
.chain(self.icals.iter().map(|ical| ical.dl.clone())) .chain(self.icals.iter().map(|ical| ical.dl.clone()))
} }
fn upstreams(&self) -> Vec<CalendarUi> { fn upstream_calendars(&self) -> Vec<CalendarUi> {
let Self { let Self {
campfires, campfires,
common_ninjas, common_ninjas,
feeds: _,
icals, icals,
output: _, output: _,
} = self; } = self;
@ -161,7 +163,7 @@ struct Data {
icals: Vec<wac_ical::Calendar>, icals: Vec<wac_ical::Calendar>,
} }
fn read_data_from_disk(config: &Config) -> Result<Data> { fn read_calendars(config: &Config) -> Result<Data> {
Ok(Data { Ok(Data {
campfires: config campfires: config
.campfires .campfires
@ -256,9 +258,19 @@ async fn do_everything(cli: &CliAuto) -> Result<()> {
std::fs::rename(&temp_path, &dl.file_path)?; std::fs::rename(&temp_path, &dl.file_path)?;
} }
let data = read_data_from_disk(&config)?; let events = {
let instances = process_data(&data, &config.output, now)?; let cal_data = read_calendars(&config)?;
output::write_html(&config.output, &config.upstreams(), &instances, now)?; 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(()) Ok(())
} }
@ -280,31 +292,40 @@ fn main_auto(cli: CliAuto) -> Result<()> {
} }
#[derive(clap::Parser)] #[derive(clap::Parser)]
struct CliDebugOutput { struct CliDebugEvents {
#[arg(long)] #[arg(long)]
config: Utf8PathBuf, config: Utf8PathBuf,
} }
fn main_debug_output(cli: CliDebugOutput) -> Result<()> { fn main_debug_events(cli: CliDebugEvents) -> Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
tracing::info!("Started tracing"); tracing::info!("Started tracing");
let config = std::fs::read_to_string(&cli.config).context("Failed to read config file")?; 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 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 tz = &config.output.timezone;
let now = Utc::now().with_timezone(tz); let now = Utc::now().with_timezone(tz);
let instances = process_data(&data, &config.output, now).context("Failed to process data")?; let data = read_calendars(&config).context("Failed to read data from disk")?;
output::write_html(&config.output, &config.upstreams(), &instances, now)
.context("Failed to output HTML")?; 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(()) Ok(())
} }
#[derive(clap::Parser)] #[derive(clap::Parser)]
struct CliDebugRss { struct CliDebugFeed {
paths: Vec<Utf8PathBuf>, #[arg(long)]
config: Utf8PathBuf,
} }
/// Wraps rss::Item in our own type suitable for merging /// Wraps rss::Item in our own type suitable for merging
@ -314,12 +335,11 @@ pub(crate) struct FeedItem {
inner: rss::Item, inner: rss::Item,
} }
fn main_debug_rss(cli: CliDebugRss) -> Result<()> { fn read_feeds(config: &Config) -> Result<Vec<FeedItem>> {
let mut items = Vec::new(); let mut items = Vec::new();
let now = Utc::now();
for path in &cli.paths { for feed in &config.feeds {
let s = std::fs::read(path)?; 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 = rss::Channel::read_from(std::io::BufReader::new(std::io::Cursor::new(s)))?;
let channel_title = channel.title.clone(); 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!();
} }
std::fs::create_dir_all("output")?; fn main_debug_feed(cli: CliDebugFeed) -> Result<()> {
output::atomic_write( tracing_subscriber::fmt::init();
"output/feed.html", tracing::info!("Started tracing");
&output::feed_page(&items, now.with_timezone(&chrono_tz::UTC)), 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))?;
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(()) Ok(())
} }
#[derive(clap::Subcommand)] #[derive(clap::Subcommand)]
enum Commands { enum Commands {
Auto(CliAuto), Auto(CliAuto),
DebugEvents(CliDebugEvents),
DebugFeed(CliDebugFeed),
DebugOutput(CliDebugOutput), DebugOutput(CliDebugOutput),
DebugRss(CliDebugRss),
} }
#[derive(clap::Parser)] #[derive(clap::Parser)]
@ -378,7 +433,8 @@ fn main() -> Result<()> {
match cli.command { match cli.command {
Commands::Auto(x) => main_auto(x), 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::DebugOutput(x) => main_debug_output(x),
Commands::DebugRss(x) => main_debug_rss(x),
} }
} }

View file

@ -74,7 +74,10 @@ fn calendar_link(calendar_ui: &crate::CalendarUi) -> maud::PreEscaped<String> {
} }
} }
fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime<chrono_tz::Tz>) -> String { pub(crate) fn calendars_page(
upstreams: &[crate::CalendarUi],
now: DateTime<chrono_tz::Tz>,
) -> String {
let description = "A list of upstream calendars used by this Wide-Angle Calendar instance"; let description = "A list of upstream calendars used by this Wide-Angle Calendar instance";
let title = "Upstream calendars"; let title = "Upstream calendars";
@ -98,13 +101,13 @@ fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime<chrono_tz::Tz>)
} }
body { body {
h1 { (title) } h1 { (title) }
p { "Written at: " (now.format("%F %T")) }
p { p {
a href="index.html" { "Wide-Angle Calendar" } a href="index.html" { "Wide-Angle Calendar" }
" / " " / "
a href="calendars.html" { (title) } a href="calendars.html" { (title) }
} }
p { "Written at: " (now.format("%F %T")) }
p { "These are the calendars that Wide-Angle Calendar pulls from." } p { "These are the calendars that Wide-Angle Calendar pulls from." }
ol { ol {
@ -118,11 +121,103 @@ fn calendars_page(upstreams: &[crate::CalendarUi], now: DateTime<chrono_tz::Tz>)
.into_string() .into_string()
} }
pub(crate) fn feed_page(_feed_items: &[crate::FeedItem], _now: DateTime<chrono_tz::Tz>) -> String { pub(crate) fn feed_page(feed_items: &[crate::FeedItem], now: DateTime<chrono_tz::Tz>) -> String {
todo!() 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 => {}
} }
fn index_page( 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("<!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 { "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()
}
pub(crate) fn index_page(
config: &Config, config: &Config,
instances: &[EventInstance], instances: &[EventInstance],
now: DateTime<chrono_tz::Tz>, now: DateTime<chrono_tz::Tz>,
@ -248,7 +343,13 @@ fn index_page(
h1 { (title) } h1 { (title) }
img src="hero.webp" width="700" height="233" {} img src="hero.webp" width="700" height="233" {}
p { "Written at: " (now.format("%F %T")) } 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 { @for entry in html_list {
(entry) (entry)
} }
@ -267,16 +368,224 @@ pub(crate) fn atomic_write(path: &str, content: &str) -> Result<()> {
pub(crate) fn write_html( pub(crate) fn write_html(
config: &Config, config: &Config,
// feed_items: &[crate::FeedItem], feed_items: &[crate::FeedItem],
upstreams: &[crate::CalendarUi], upstream_calendars: &[crate::CalendarUi],
instances: &[EventInstance], events: &[EventInstance],
now: DateTime<chrono_tz::Tz>, now: DateTime<chrono_tz::Tz>,
) -> Result<()> { ) -> Result<()> {
std::fs::create_dir_all("output")?; std::fs::create_dir_all("output")?;
atomic_write("output/calendars.html", &calendars_page(upstreams, now))?; atomic_write(
// atomic_write("output/feed.html", &feed_page(feed_items, now)?)?; "output/calendars.html",
atomic_write("output/index.html", &index_page(config, instances, now))?; &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(()) 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<T> {
pub(crate) month: Option<String>,
pub(crate) date: NaiveDate,
pub(crate) items: Vec<T>,
}
// Debug only if T: Debug
impl<T: fmt::Debug> fmt::Debug for DataChunk<T> {
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<T: PartialEq> PartialEq for DataChunk<T> {
fn eq(&self, other: &Self) -> bool {
self.month == other.month && self.date == other.date && self.items == other.items
}
}
pub(crate) enum Output<T> {
Data(DataChunk<T>),
EndOfInput,
NeedInput,
}
// Debug only if T: Debug
impl<T: fmt::Debug> fmt::Debug for Output<T> {
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<T: PartialEq> PartialEq for Output<T> {
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<T> {
data: VecDeque<DataChunk<T>>,
flushed: bool,
last_month_printed: Option<String>,
}
impl<T> StateMachine<T> {
pub(crate) fn pull(&mut self) -> Output<T> {
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");
}
}