use std::sync::OnceLock;
use std::time::Duration;
use chrono::{DateTime, Utc};
use feed_rs::model::{Entry, Feed};
use feed_rs::parser;
use regex::Regex;
use reqwest::Client;
use url::Url;
use crate::fetcher::thumbnails;
use crate::fetcher::{FetchContext, FetchError};
use crate::payload::{
Body, EntriesData, Entry as PayloadEntry, ImageData, ImageLinkedItem, ImageLinkedListData,
LinkedLine, LinkedTextBlockData, MarkdownTextBlockData, TextBlockData, TextData, TimelineData,
TimelineEvent,
};
use crate::render::Shape;
use crate::time as t;
pub(crate) const DEFAULT_COUNT: u32 = 5;
pub(crate) const MIN_COUNT: u32 = 1;
pub(crate) const MAX_COUNT: u32 = 20;
pub(crate) const MAX_BYTES: usize = 5 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const USER_AGENT: &str = concat!("splashboard/", env!("CARGO_PKG_VERSION"));
fn http() -> &'static Client {
static CLIENT: OnceLock<Client> = OnceLock::new();
CLIENT.get_or_init(|| {
Client::builder()
.user_agent(USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.gzip(true)
.build()
.expect("reqwest client should build with default config")
})
}
pub(crate) async fn fetch_bytes(url: &Url, label: &str) -> Result<Vec<u8>, FetchError> {
let res = http()
.get(url.as_str())
.send()
.await
.map_err(|e| FetchError::Failed(format!("{label} request failed: {e}")))?;
let status = res.status();
if !status.is_success() {
return Err(FetchError::Failed(format!("{label} {status}")));
}
let bytes = res
.bytes()
.await
.map_err(|e| FetchError::Failed(format!("{label} read body: {e}")))?;
if bytes.len() > MAX_BYTES {
return Err(FetchError::Failed(format!(
"{label} response too large ({} bytes, cap {MAX_BYTES})",
bytes.len()
)));
}
Ok(bytes.to_vec())
}
pub(crate) fn parse_feed(bytes: &[u8], label: &str) -> Result<Feed, FetchError> {
parser::parse(bytes).map_err(|e| FetchError::Failed(format!("{label} parse: {e}")))
}
pub(crate) fn render_body(
feed: &Feed,
count: usize,
shape: Shape,
timezone: Option<&str>,
locale: Option<&str>,
) -> Body {
let entries: Vec<&Entry> = feed.entries.iter().take(count).collect();
match shape {
Shape::Text => Body::Text(TextData {
value: entries
.first()
.map(|e| title_or_placeholder(e))
.unwrap_or_default(),
}),
Shape::TextBlock => Body::TextBlock(TextBlockData {
lines: entries
.iter()
.map(|e| line_text(e, timezone, locale))
.collect(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: entries
.iter()
.map(|e| markdown_line(e, timezone, locale))
.collect::<Vec<_>>()
.join("\n"),
}),
Shape::Entries => Body::Entries(EntriesData {
items: entries
.iter()
.map(|e| PayloadEntry {
key: title_or_placeholder(e),
value: Some(date_label(e, timezone, locale)).filter(|s| !s.is_empty()),
status: None,
})
.collect(),
}),
Shape::Timeline => Body::Timeline(TimelineData {
events: entries
.iter()
.map(|e| TimelineEvent {
timestamp: e
.published
.or(e.updated)
.map(|d| d.timestamp())
.unwrap_or(0),
title: title_or_placeholder(e),
detail: link_host(e),
status: None,
})
.collect(),
}),
_ => Body::LinkedTextBlock(LinkedTextBlockData {
items: entries
.iter()
.map(|e| LinkedLine {
text: line_text(e, timezone, locale),
url: link_for(e),
})
.collect(),
}),
}
}
pub(crate) async fn render_image_body(feed: &Feed) -> Body {
let path = match feed.entries.first().and_then(thumbnail_url_for) {
Some(url) => thumbnails::download_to_cache(&url)
.await
.ok()
.flatten()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
None => String::new(),
};
Body::Image(ImageData { path })
}
fn markdown_line(entry: &Entry, timezone: Option<&str>, locale: Option<&str>) -> String {
let date = date_label(entry, timezone, locale);
let title = title_or_placeholder(entry);
let labeled = if date.is_empty() {
title
} else {
format!("{date} {title}")
};
match link_for(entry) {
Some(url) => format!("- [{labeled}]({url})"),
None => format!("- {labeled}"),
}
}
fn link_host(entry: &Entry) -> Option<String> {
link_for(entry)
.as_deref()
.and_then(|u| Url::parse(u).ok())
.and_then(|u| u.host_str().map(str::to_string))
}
pub(crate) async fn render_image_linked(feed: &Feed, count: usize, ctx: &FetchContext) -> Body {
let entries: Vec<&Entry> = feed.entries.iter().take(count).collect();
let thumbnail_urls: Vec<Option<String>> =
entries.iter().map(|e| thumbnail_url_for(e)).collect();
let thumbnail_paths = thumbnails::download_many(&thumbnail_urls).await;
Body::ImageLinkedList(ImageLinkedListData {
items: entries
.iter()
.zip(thumbnail_paths)
.map(|(e, path)| ImageLinkedItem {
title: title_or_placeholder(e),
url: link_for(e),
thumbnail_path: path.map(|p| p.to_string_lossy().into_owned()),
subtitle: subtitle_for(e, ctx.timezone.as_deref(), ctx.locale.as_deref()),
})
.collect(),
})
}
pub(crate) fn thumbnail_url_for(entry: &Entry) -> Option<String> {
entry
.media
.iter()
.flat_map(|m| m.thumbnails.iter().map(|t| t.image.uri.clone()))
.find(|s| !s.is_empty())
.or_else(|| {
entry
.media
.iter()
.flat_map(|m| m.content.iter().filter_map(|c| c.url.as_ref()))
.map(|u| u.to_string())
.find(|s| !s.is_empty())
})
.or_else(|| {
entry
.content
.as_ref()
.and_then(|c| c.body.as_deref())
.and_then(first_inline_image_src)
})
.or_else(|| {
entry
.summary
.as_ref()
.and_then(|s| first_inline_image_src(&s.content))
})
}
pub(crate) fn first_inline_image_src(html: &str) -> Option<String> {
static IMG_SRC_RE: OnceLock<Regex> = OnceLock::new();
let re = IMG_SRC_RE
.get_or_init(|| Regex::new(r#"(?is)<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']"#).unwrap());
re.captures_iter(html)
.map(|cap| decode_html_entities(&cap[1]))
.find(|src| src.starts_with("http://") || src.starts_with("https://"))
}
fn decode_html_entities(s: &str) -> String {
s.replace("&", "&")
.replace("/", "/")
.replace("=", "=")
.replace(""", "\"")
.replace("'", "'")
}
pub(crate) fn subtitle_for(
entry: &Entry,
timezone: Option<&str>,
locale: Option<&str>,
) -> Option<String> {
let date = date_label(entry, timezone, locale);
let host = link_for(entry)
.as_deref()
.and_then(|u| Url::parse(u).ok())
.and_then(|u| u.host_str().map(str::to_string));
match (date.is_empty(), host) {
(true, None) => None,
(false, None) => Some(date),
(true, Some(h)) => Some(h),
(false, Some(h)) => Some(format!("{date} · {h}")),
}
}
pub(crate) fn line_text(entry: &Entry, timezone: Option<&str>, locale: Option<&str>) -> String {
let date = date_label(entry, timezone, locale);
let title = title_or_placeholder(entry);
if date.is_empty() {
title
} else {
format!("{date} {title}")
}
}
fn date_label(entry: &Entry, timezone: Option<&str>, locale: Option<&str>) -> String {
entry
.published
.or(entry.updated)
.map(|dt| format_short(dt, timezone, locale))
.unwrap_or_default()
}
pub(crate) fn format_short(
dt: DateTime<Utc>,
timezone: Option<&str>,
locale: Option<&str>,
) -> String {
let local = match t::parse_tz(timezone) {
Some(tz) => dt.with_timezone(&tz).fixed_offset(),
None => dt.with_timezone(&chrono::Local).fixed_offset(),
};
t::format_local(&local, "%b %d", locale)
}
fn title_or_placeholder(entry: &Entry) -> String {
entry
.title
.as_ref()
.map(|t| collapse_whitespace(&t.content))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "(no title)".into())
}
pub(crate) fn collapse_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn link_for(entry: &Entry) -> Option<String> {
let alternate = entry
.links
.iter()
.find(|l| !l.href.is_empty() && matches!(l.rel.as_deref(), None | Some("alternate")));
alternate
.or_else(|| entry.links.iter().find(|l| !l.href.is_empty()))
.map(|l| l.href.clone())
}