diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a64906..cd65b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ The long form, with what was wrong before and how it was found, is in - The database no longer records when accounts, subscriptions and sign-ins were created. Nothing ever read it, and an existing database drops the columns on its next start. +### Fixed + +- Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of + HTML: the item's other copy of its notes is shown instead. Daily Meditation Podcast had 57. + ## [0.5.1] - 2026-09-12 ### Fixed diff --git a/src/feed.rs b/src/feed.rs index 8419601..f3f5564 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -340,7 +340,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { link: non_empty(item.link()), published: item.pub_date().and_then(parse_date), // Content wins over description, as __getEntries preferred entry.content. - description: non_empty(item.content()).or_else(|| non_empty(item.description())), + description: body(item.content(), item.description()), categories: item .categories() .iter() @@ -406,11 +406,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed { title: non_empty(Some(e.title().as_str())), link: alt.map(str::to_owned), published: e.published().or(Some(e.updated())).map(|d| d.timestamp()), - description: e - .content() - .and_then(|c| c.value()) - .or_else(|| e.summary().map(|s| s.as_str())) - .map(str::to_owned), + description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())), categories: e.categories().iter().map(|c| c.term().to_owned()).collect(), explicit: false, image: None, @@ -455,6 +451,25 @@ fn non_empty(s: Option<&str>) -> Option { s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned) } +/// An item's show notes: its full body when that is whole, else its description. +/// +/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name +/// pasted from a web app (`[&:has([data-writing-block])>*]:pointer-events-auto`), so the body +/// began halfway through a tag and the page showed the rest of the tag as text. The same item's +/// `description` was whole. With no description to fall back on, a damaged body beats none. +fn body(content: Option<&str>, description: Option<&str>) -> Option { + non_empty(content) + .filter(|c| !starts_mid_tag(c)) + .or_else(|| non_empty(description)) + .or_else(|| non_empty(content)) +} + +/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose +/// start was cut off. +fn starts_mid_tag(html: &str) -> bool { + html[..html.find('<').unwrap_or(html.len())].contains("\">") +} + /// The picture to show beside an item, in order of how deliberate it is: /// `itunes:image`, then Media RSS `media:thumbnail`, then a `media:content` that is an /// image, and finally an image enclosure -- which is how a blog's article picture arrives @@ -548,6 +563,18 @@ mod tests { ); } + #[test] + fn a_body_cut_off_mid_tag_gives_way_to_the_description() { + // How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag. + let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant">

What if

"#; + let whole = r#"

What if

"#; + assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole)); + assert_eq!(body(Some("

Notes

"), Some("Summary")).as_deref(), Some("

Notes

"), "a whole body wins"); + assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags.")); + assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none"); + assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary")); + } + #[test] fn feed_level_explicit_overrides_entries() { let xml = br#"