use errand::parse::feed::{Feed, FeedEntry, parse as parse_feed_xml, strip_html_tags};
use inker::{
Block, DocumentDiagnostic, DocumentProvenance, DocumentTrustState, Engine, EngineDocument,
EngineError, EngineInput,
};
use serde::Deserialize;
pub const ENGINE_ID: &str = "nematic.feed";
pub struct FeedEngine;
impl FeedEngine {
pub fn new() -> Self {
Self
}
}
impl Default for FeedEngine {
fn default() -> Self {
Self::new()
}
}
impl Engine for FeedEngine {
fn engine_id(&self) -> &str {
ENGINE_ID
}
fn render(&self, input: &EngineInput) -> Result<EngineDocument, EngineError> {
let is_json = looks_like_json_feed(input);
let feed = if is_json {
parse_json(&input.body)?
} else {
parse_feed_xml(&input.body).map_err(|e| EngineError::InvalidContent(e.to_string()))?
};
let title = feed.title.clone();
let lang = feed.lang.clone();
let (blocks, diagnostics) = build_document_blocks(feed);
let default_content_type = if is_json {
"application/feed+json"
} else {
"application/feed+xml"
};
Ok(EngineDocument {
address: input.address.clone(),
title,
content_type: input
.content_type
.clone()
.unwrap_or_else(|| default_content_type.to_string()),
lang,
provenance: DocumentProvenance::for_engine(self.engine_id(), &input.address),
trust: DocumentTrustState::Unknown,
diagnostics,
blocks,
})
}
}
fn looks_like_json_feed(input: &EngineInput) -> bool {
if let Some(content_type) = &input.content_type {
let lowered = content_type.to_ascii_lowercase();
if lowered.contains("json") {
return true;
}
if lowered.contains("xml") || lowered.contains("rss") || lowered.contains("atom") {
return false;
}
}
input.body.trim_start().starts_with('{')
}
#[derive(Deserialize)]
struct JsonFeed {
title: Option<String>,
home_page_url: Option<String>,
feed_url: Option<String>,
description: Option<String>,
language: Option<String>,
#[serde(default)]
items: Vec<JsonFeedItem>,
}
#[derive(Deserialize)]
struct JsonFeedItem {
id: Option<String>,
url: Option<String>,
external_url: Option<String>,
title: Option<String>,
summary: Option<String>,
content_text: Option<String>,
content_html: Option<String>,
date_published: Option<String>,
date_modified: Option<String>,
}
fn parse_json(body: &str) -> Result<Feed, EngineError> {
let feed: JsonFeed = serde_json::from_str(body)
.map_err(|err| EngineError::InvalidContent(format!("JSON Feed parse failed: {err}")))?;
let mut out = Feed {
title: trimmed_some(feed.title),
subtitle: trimmed_some(feed.description).map(|text| strip_html_tags(&text)),
link: trimmed_some(feed.home_page_url).or_else(|| trimmed_some(feed.feed_url)),
lang: trimmed_some(feed.language),
..Feed::default()
};
for item in feed.items {
let title = trimmed_some(item.title).or_else(|| trimmed_some(item.id));
let link = trimmed_some(item.url).or_else(|| trimmed_some(item.external_url));
let date = trimmed_some(item.date_published).or_else(|| trimmed_some(item.date_modified));
let summary = trimmed_some(item.summary)
.or_else(|| trimmed_some(item.content_text))
.or_else(|| trimmed_some(item.content_html))
.map(|raw| {
if raw.contains('<') {
out.html_stripped += 1;
strip_html_tags(&raw)
} else {
raw
}
});
if title.is_some() || link.is_some() || summary.is_some() {
out.entries.push(FeedEntry {
title,
link,
date,
summary,
});
}
}
Ok(out)
}
fn trimmed_some(value: Option<String>) -> Option<String> {
value
.map(|text| text.trim().to_string())
.filter(|text| !text.is_empty())
}
fn build_document_blocks(feed: Feed) -> (Vec<Block>, Vec<DocumentDiagnostic>) {
let mut blocks = Vec::with_capacity(feed.entries.len() + 1);
let header_has_content = feed.title.is_some() || feed.subtitle.is_some() || feed.link.is_some();
if header_has_content {
blocks.push(Block::FeedHeader {
title: feed.title.clone().unwrap_or_default(),
subtitle: feed.subtitle,
summary: None,
source_url: feed.link,
});
}
for entry in feed.entries {
blocks.push(Block::FeedEntry {
title: entry.title.unwrap_or_default(),
date: entry.date,
summary: entry.summary,
article_url: entry.link,
source_url: None,
});
}
let mut diagnostics = Vec::new();
if feed.html_stripped > 0 {
let n = feed.html_stripped;
let entries = if n == 1 { "entry" } else { "entries" };
diagnostics.push(DocumentDiagnostic::DegradedRendering(format!(
"stripped HTML from {n} {entries}"
)));
}
(blocks, diagnostics)
}
#[cfg(test)]
mod tests;