From f2a44507d49f3e333215f8c78c44f87e30e4d7d7 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Wed, 9 Sep 2026 06:01:29 +0000 Subject: [PATCH] Show the publication after the feed name, and link feeds to Miniflux for admins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readers see "Feed · Publication" on the issue, article, public and RSS pages and in the EPUB index and chapter headers; the publication is the page's site name, else its domain, and is dropped when it would repeat the feed's title. Admins get the feed name linked to its Miniflux page. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv --- src/epub/chapters.rs | 51 ++++++++++++++++-- src/web/issue.rs | 84 ++++++++++++++++++++++++++--- src/web/public.rs | 10 ++-- src/web/templates/_source.html | 1 + src/web/templates/article.html | 2 +- src/web/templates/feed_entry.html | 2 +- src/web/templates/issue_full.html | 2 +- src/web/templates/issue_public.html | 2 +- 8 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 src/web/templates/_source.html diff --git a/src/epub/chapters.rs b/src/epub/chapters.rs index f50d76e..e165993 100644 --- a/src/epub/chapters.rs +++ b/src/epub/chapters.rs @@ -11,7 +11,7 @@ use crate::comments; use crate::html::{text_escape, to_xhtml}; use crate::images; use crate::types::{ - BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote, + Article, BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION, }; use crate::world; @@ -363,6 +363,13 @@ pub fn section_names(issue: &Issue) -> Vec { names } +fn source_line(article: &Article) -> String { + match article.publication_label() { + Some(publication) => format!("{} · {publication}", article.feed_title), + None => article.feed_title.clone(), + } +} + /// "In This Issue": per section, each article's title, source, reading time and /// summary, linked to its chapter (§3.10). pub fn render_in_this_issue(issue: &Issue) -> Result { @@ -375,7 +382,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result { .map(|pick| IndexEntry { href: article_href(pick), title: pick.article.title.clone(), - source: pick.article.feed_title.clone(), + source: source_line(&pick.article), reading_minutes: pick.article.reading_minutes(), summary: summary_for(issue, pick).unwrap_or_default().to_string(), why: pick.why.clone(), @@ -437,7 +444,7 @@ pub fn render_article( hmac_secret: Option<&str>, ) -> Result { let article = &pick.article; - let mut meta_parts = vec![article.feed_title.clone()]; + let mut meta_parts = vec![source_line(article)]; if let Some(date) = published_display(pick) { meta_parts.push(date); } @@ -879,6 +886,44 @@ mod tests { assert_xml_ok(&chapter.xhtml); } + #[test] + fn article_sources_include_distinct_publications_without_repeating_the_feed() { + let mut issue = issue(); + issue.lineup.picks[0].article.publication = Some("Example Journal".into()); + issue.lineup.picks[1].article.publication = Some("Example Feed".into()); + + let index = render_in_this_issue(&issue).unwrap(); + assert!( + index + .xhtml + .contains("Example Feed · Example Journal · 6 min read") + ); + assert!(!index.xhtml.contains("Example Feed · Example Feed")); + + let article = render_article( + &issue, + &issue.lineup.picks[0], + &[], + Edition::Standard, + "https://daily.hallada.net", + None, + ) + .unwrap(); + assert!(article.xhtml.contains("Example Feed · Example Journal")); + + let matching = render_article( + &issue, + &issue.lineup.picks[1], + &[], + Edition::Standard, + "https://daily.hallada.net", + None, + ) + .unwrap(); + assert!(matching.xhtml.contains("Example Feed")); + assert!(!matching.xhtml.contains("Example Feed · Example Feed")); + } + #[test] fn article_chapter_has_header_body_and_footer() { let issue = issue(); diff --git a/src/web/issue.rs b/src/web/issue.rs index 1c3f752..1251ceb 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -12,12 +12,14 @@ use jiff::civil::Date; use serde::Deserialize; use sqlx::Row; +use crate::config::Config; use crate::db::Db; use crate::epub::chapters; use crate::pipeline::display_date; use crate::server::AppState; use crate::types::{ - ArticleId, BehindThePaper, Colophon, Edition, Editorial, Issue, IssueMeta, Lineup, Models, Pick, + Article, ArticleId, BehindThePaper, Colophon, Edition, Editorial, Issue, IssueMeta, Lineup, + Models, Pick, }; use crate::web::rate::{self, RatingWidget}; use crate::web::session::{AuthSession, Viewer}; @@ -850,12 +852,31 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc { } } +/// The "Feed · Publication" lead of an article's meta line. +#[derive(Debug)] +struct Source { + feed_title: String, + feed_href: Option, + publication: Option, +} + +impl Source { + fn new(article: &Article, config: &Config, is_admin: bool) -> Self { + Self { + feed_title: article.feed_title.clone(), + feed_href: (is_admin && article.feed_id > 0) + .then(|| config.miniflux.feed_url(article.feed_id)), + publication: article.publication_label(), + } + } +} + #[derive(Debug)] struct FullEntry { title: String, href: String, dashboard_href: String, - source: String, + source: Source, reading_minutes: i64, is_lead: bool, summary: String, @@ -926,6 +947,7 @@ struct ArticleTemplate { title: String, source_url: String, byline: Option, + source: Source, meta_line: String, why: Option, social_line: Option, @@ -979,6 +1001,7 @@ pub async fn render_full( ) -> Result { let date = view.issue.meta.date; let is_admin = viewer.role == crate::web::users::Role::Admin; + let config = state.config(); let current = if is_admin { rate::current_for_issue(state, date).await? } else { @@ -998,7 +1021,7 @@ pub async fn render_full( title: pick.article.title.clone(), href: article_href(date, pick.article.id), dashboard_href: format!("/dashboard/articles/{}", pick.article.id), - source: pick.article.feed_title.clone(), + source: Source::new(&pick.article, &config, is_admin), reading_minutes: pick.article.reading_minutes(), is_lead: pick.is_lead, summary: summary_for(&view.issue, pick) @@ -1090,6 +1113,8 @@ pub async fn article( href: article_href(date, next.article.id), }); let article = &pick.article; + let is_admin = viewer.role == crate::web::users::Role::Admin; + let config = state.config(); let active_nav = if view.is_latest { "latest" } else { "archive" }; let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav); page.flash = take_flash(&session).await?; @@ -1099,9 +1124,9 @@ pub async fn article( title: article.title.clone(), source_url: article.canonical_url.clone(), byline: article.author.as_ref().map(|author| format!("By {author}")), + source: Source::new(article, &config, is_admin), meta_line: format!( - "{} · {} words · ~{} min read", - article.feed_title, + "{} words · ~{} min read", thousands(article.word_count), article.reading_minutes() ), @@ -1117,7 +1142,7 @@ pub async fn article( .map(|discussion| crate::comments::render_xhtml(discussion, &article.title)), read_online_url: article.url.clone(), dashboard_href: format!("/dashboard/articles/{}", article.id), - rating: (viewer.role == crate::web::users::Role::Admin).then(|| { + rating: is_admin.then(|| { RatingWidget::for_issue( article.id, date, @@ -2047,6 +2072,53 @@ mod tests { assert!(response_text(missing).await.contains("Not found")); } + #[tokio::test] + async fn article_feed_links_to_miniflux_for_admins_only() { + let (_dir, db, source) = seeded_issue(true).await; + crate::web::users::add(&db, "admin", "correct horse battery", true) + .await + .unwrap(); + crate::web::users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let mut config = crate::config::Config::default(); + config.miniflux.public_url = Some("https://miniflux.example/".into()); + let app = crate::server::router(crate::server::AppState::new(db, config, None)); + let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await; + let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await; + let article = &source.lineup.picks[0].article; + let article_uri = format!("/issues/{}/articles/{}", source.meta.date, article.id); + let feed_href = format!("https://miniflux.example/feed/{}/entries", article.feed_id); + + let admin = app + .clone() + .oneshot( + Request::builder() + .uri(&article_uri) + .header(header::COOKIE, admin_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let admin = response_text(admin).await; + assert!(admin.contains(&format!("href=\"{feed_href}\""))); + + let reader = app + .oneshot( + Request::builder() + .uri(article_uri) + .header(header::COOKIE, reader_cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let reader = response_text(reader).await; + assert!(reader.contains(&article.feed_title)); + assert!(!reader.contains(&feed_href)); + } + #[tokio::test] async fn fallback_full_issue_recovers_colophon_world_and_behind() { let (_dir, db, source) = seeded_issue(false).await; diff --git a/src/web/public.rs b/src/web/public.rs index 8233394..73ba0c5 100644 --- a/src/web/public.rs +++ b/src/web/public.rs @@ -6,7 +6,7 @@ use axum_login::tower_sessions::Session; use jiff::civil::Date; use crate::server::AppState; -use crate::types::{Issue, SocialSource, domain}; +use crate::types::{Issue, SocialSource}; use crate::web::issue::{self, Download}; use crate::web::session::{AuthSession, Viewer}; use crate::web::{Html, Page, WebError}; @@ -49,7 +49,7 @@ pub struct PublicEntry { pub url: String, pub author: Option, pub source: String, - pub domain: String, + pub publication: Option, pub reading_minutes: i64, pub word_count: i64, pub summary: Option, @@ -116,7 +116,7 @@ impl From<&Issue> for PublicIssue { url: article.canonical_url.clone(), author: article.author.clone(), source: article.feed_title.clone(), - domain: domain(&article.canonical_url).unwrap_or_default(), + publication: article.publication_label(), reading_minutes: article.reading_minutes(), word_count: article.word_count, summary: pick @@ -426,6 +426,10 @@ mod tests { fn public_issue_shows_summaries_and_why_but_no_bodies() { let source = crate::epub::fixtures::issue(); let public = PublicIssue::from(&source); + assert_eq!( + public.sections[0].entries[0].publication.as_deref(), + Some("example.com") + ); let html = IssuePublicTemplate { page: Page::new("Test issue", None, "latest"), issue: public, diff --git a/src/web/templates/_source.html b/src/web/templates/_source.html new file mode 100644 index 0000000..5762225 --- /dev/null +++ b/src/web/templates/_source.html @@ -0,0 +1 @@ +{% match source.feed_href %}{% when Some with (href) %}{{ source.feed_title }}{% when None %}{{ source.feed_title }}{% endmatch %}{% match source.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %} diff --git a/src/web/templates/article.html b/src/web/templates/article.html index 6c42bb7..a79429a 100644 --- a/src/web/templates/article.html +++ b/src/web/templates/article.html @@ -1,6 +1,6 @@ {% extends "layout.html" %}{% block ears %}{{ toc.short_date }}{% endblock %}{% block content %}
{% include "_toc.html" %}
-

Article

{{ title }}

{% match byline %}{% when Some with (byline) %}

{{ byline }}

{% when None %}{% endmatch %}

{{ meta_line }}

{% match why %}{% when Some with (why) %}

Why it's here: {{ why }}

{% when None %}{% endmatch %}{% match understanding %}{% when Some with (line) %}

{{ line }}

{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}

{{ social }}

{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% if excerpt_only %}

Excerpt only — continue reading at the original site.

{% endif %}
+

Article

{{ title }}

{% match byline %}{% when Some with (byline) %}

{{ byline }}

{% when None %}{% endmatch %}

{% include "_source.html" %} · {{ meta_line }}

{% match why %}{% when Some with (why) %}

Why it's here: {{ why }}

{% when None %}{% endmatch %}{% match understanding %}{% when Some with (line) %}

{{ line }}

{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}

{{ social }}

{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% if excerpt_only %}

Excerpt only — continue reading at the original site.

{% endif %}
{{ body_html|safe }}
{% match discussion_html %}{% when Some with (discussion) %}

Discussion

{{ discussion|safe }}
{% when None %}{% endmatch %} diff --git a/src/web/templates/feed_entry.html b/src/web/templates/feed_entry.html index 7472231..ba31c70 100644 --- a/src/web/templates/feed_entry.html +++ b/src/web/templates/feed_entry.html @@ -1 +1 @@ -{% for section in issue.sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }} — {{ entry.source }} ({{ entry.domain }}){% match entry.summary %}{% when Some with (summary) %}

    {{ summary }}

    {% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}{{ link.label }}{% endfor %}{% endif %}
  • {% endfor %}
{% endfor %} +{% for section in issue.sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }} — {{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}

    {{ summary }}

    {% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}{{ link.label }}{% endfor %}{% endif %}
  • {% endfor %}
{% endfor %} diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index 745e540..3222e8a 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -5,7 +5,7 @@

The Brief

{{ front_page_html|safe }}
{% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}
{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}Read in BookOrbit{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}Download {{ downloads.primary.label }}{% else %}{% endif %}{% when None %}{% endmatch %}
{% endif %}

In This Issue

- {% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }}

    {{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · dashboard{% endif %}

    {% if !entry.summary.is_empty() %}

    {{ entry.summary }}

    {% endif %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}

    {{ line }}

    {% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
  • {% endfor %}
{% endfor %} + {% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }}

    {% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · dashboard{% endif %}

    {% if !entry.summary.is_empty() %}

    {{ entry.summary }}

    {% endif %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}

    {{ line }}

    {% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
  • {% endfor %}
{% endfor %}
{% if has_world || has_behind %}{% endif %}

Colophon

The Daily EPUB is assembled every morning from a personal feed reader.

Generated
{{ colophon.generated_at }}
Bulk model
{{ colophon.bulk_model }}
Editor model
{{ colophon.editor_model }}
Summaries model
{{ colophon.summaries_model }}
Entries considered
{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}
Candidates scored
{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}
Articles selected
{{ colophon.article_count }} across {{ colophon.section_count }} sections
Words
{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read
{% for cost in colophon.provider_costs %}
{{ cost.provider }} cost
{{ cost.cost }}
{% endfor %}
Total token cost
{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}
Generator
{{ colophon.generator_version }}
diff --git a/src/web/templates/issue_public.html b/src/web/templates/issue_public.html index 82d02b6..b501589 100644 --- a/src/web/templates/issue_public.html +++ b/src/web/templates/issue_public.html @@ -4,6 +4,6 @@

{{ issue.display_date }} · No. {{ issue.issue_number }}

{{ issue.stats_line }}

A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.

Sign in to read every article's full text and download the editions, or request access.

{% if !downloads.is_empty() %}
{% for download in downloads %}{{ download.label }} {{ download.size }}{% endfor %}
{% endif %} - {% for section in issue.sections %}

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min

{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}

Why it's here: {{ why }}

{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}

{{ line }}

{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}

{% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

{% endif %}
{% endfor %}
{% endfor %} + {% for section in issue.sections %}

{{ section.name }}

{% for entry in section.entries %}

{{ entry.title }}

{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %} · {{ entry.reading_minutes }} min

{% match entry.summary %}{% when Some with (summary) %}

{{ summary }}

{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}

Why it's here: {{ why }}

{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}

{{ line }}

{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}

{% for link in entry.comment_links %}{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}{% endfor %}

{% endif %}
{% endfor %}
{% endfor %}

Browse the archive →

{% endif %}
{% endblock %}