diff --git a/src/reader/sync.rs b/src/reader/sync.rs index 8b860b5..1ac0908 100644 --- a/src/reader/sync.rs +++ b/src/reader/sync.rs @@ -38,6 +38,28 @@ fn image_src_is_resolvable(element: &scraper::ElementRef) -> bool { } } +fn escape_html_attr(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +// Some feeds (e.g. Deutsche Welle) don't embed an in the item content at +// all — they carry the article image as an RSS instead. Build an +// tag from it so those feeds get a preview image too. +fn enclosure_image_html(item: &Item) -> Option { + let enclosure = item.enclosure()?; + if !enclosure.mime_type().to_lowercase().starts_with("image/") { + return None; + } + Some(format!( + r#""#, + escape_html_attr(enclosure.url()) + )) +} + fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { let item_title = item.title.clone().unwrap(); log::info!("Create feed item: {}", item_title); @@ -48,9 +70,17 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { let mut content = "".to_string(); let selector_img = Selector::parse("img").unwrap(); - if let Some(image) = frag.select(&selector_img).find(image_src_is_resolvable) { - content.push_str(&image.html()); - content.push_str("
"); + match frag.select(&selector_img).find(image_src_is_resolvable) { + Some(image) => { + content.push_str(&image.html()); + content.push_str("
"); + } + None => { + if let Some(image_html) = enclosure_image_html(&item) { + content.push_str(&image_html); + content.push_str("
"); + } + } } for node in frag.tree.nodes() { @@ -176,6 +206,48 @@ mod tests { assert!(html.select(&selector).find(image_src_is_resolvable).is_none()); } + #[test] + fn enclosure_image_html_builds_img_for_image_enclosures() { + let mut item = Item::default(); + item.set_enclosure(rss::Enclosure { + url: "https://static.dw.com/image/73880499_302.jpg".to_string(), + length: "2000".to_string(), + mime_type: "image/jpeg".to_string(), + }); + + assert_eq!( + Some(r#""#.to_string()), + enclosure_image_html(&item) + ); + } + + #[test] + fn enclosure_image_html_ignores_non_image_enclosures() { + let mut item = Item::default(); + item.set_enclosure(rss::Enclosure { + url: "https://example.test/episode.mp3".to_string(), + length: "2000".to_string(), + mime_type: "audio/mpeg".to_string(), + }); + + assert_eq!(None, enclosure_image_html(&item)); + } + + #[test] + fn enclosure_image_html_escapes_url_attribute() { + let mut item = Item::default(); + item.set_enclosure(rss::Enclosure { + url: "https://example.test/img.jpg?a=1&b=\"x\"".to_string(), + length: "2000".to_string(), + mime_type: "image/jpeg".to_string(), + }); + + assert_eq!( + Some(r#""#.to_string()), + enclosure_image_html(&item) + ); + } + #[actix_web::test] async fn create_feed_item_does_not_duplicate_existing_items() { let mut connection = establish_connection(); diff --git a/vue/index.html b/vue/index.html index 891c171..d033333 100644 --- a/vue/index.html +++ b/vue/index.html @@ -3,7 +3,8 @@ - + + RSS-Reader diff --git a/vue/public/favicon.ico b/vue/public/favicon.ico index df36fcf..0878ef5 100644 Binary files a/vue/public/favicon.ico and b/vue/public/favicon.ico differ diff --git a/vue/public/favicon.svg b/vue/public/favicon.svg new file mode 100644 index 0000000..d617e7b --- /dev/null +++ b/vue/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/vue/src/assets/main.css b/vue/src/assets/main.css index c423616..3f77094 100644 --- a/vue/src/assets/main.css +++ b/vue/src/assets/main.css @@ -95,7 +95,7 @@ a, .feed-content { font-family: Georgia, 'Times New Roman', Times, serif; font-size: clamp(1rem, 3.5vw, 1.25rem); - padding: 1em; + padding: 0 1em 1em; overflow-wrap: break-word; } @@ -105,11 +105,11 @@ a, } .feed-content p { - padding: 1em; + padding: 0.5em 0; } .feed-content h3 { - padding: 1em; + padding: 0.5em 0; font-size: clamp(1rem, 3vw, 1.3rem); font-weight: bold; }