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 %}