Add admin dashboard links on article pages and a single download menu
Signed-in issue and article pages now carry an admin-only link to the article's dashboard page. The separate Download EPUB / X4 EPUB / XTC buttons become one "Download EPUB" button (Standard edition first) with a no-JS <details> menu listing every available format and its size. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
@@ -199,9 +199,11 @@ The server is both the public newspaper index and the private operator UI. An
|
||||
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
|
||||
lines and outbound comment links; article bodies, the Brief, the World Briefing
|
||||
and comments stay private. A signed-in `user`
|
||||
sees complete issues and article chapters and can download artifacts. An
|
||||
`admin` can additionally rate articles and use every `/dashboard/*` page,
|
||||
including settings and jobs. Personalization is shared across accounts for now.
|
||||
sees complete issues and article chapters and can download available formats
|
||||
from a single download menu. An
|
||||
`admin` can additionally follow direct dashboard links from issue entries and
|
||||
article chapters, rate articles, and use every `/dashboard/*` page, including
|
||||
settings and jobs. Personalization is shared across accounts for now.
|
||||
|
||||
Accounts are deliberately managed on the host, not in the browser. Usernames
|
||||
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
|
||||
|
||||
+116
-12
@@ -31,12 +31,19 @@ pub struct Download {
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
/// Available issue downloads, with the Standard EPUB first when it exists.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Downloads {
|
||||
primary: Download,
|
||||
others: Vec<Download>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IssueView {
|
||||
pub issue: Issue,
|
||||
/// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded.
|
||||
pub read_href: Option<String>,
|
||||
pub downloads: Vec<Download>,
|
||||
pub downloads: Option<Downloads>,
|
||||
pub from_json: bool,
|
||||
pub world_html: Option<String>,
|
||||
pub is_latest: bool,
|
||||
@@ -271,7 +278,7 @@ pub async fn load(
|
||||
);
|
||||
let read_href = (config.bookorbit.is_active() && standard_download.is_some())
|
||||
.then(|| format!("/issues/{date}/read"));
|
||||
let downloads = [
|
||||
let mut available_downloads = [
|
||||
standard_download,
|
||||
download(
|
||||
"X4 EPUB",
|
||||
@@ -286,8 +293,11 @@ pub async fn load(
|
||||
download("XTC", row.xtc_path.as_deref(), None, "xtc"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
.flatten();
|
||||
let downloads = available_downloads.next().map(|primary| Downloads {
|
||||
primary,
|
||||
others: available_downloads.collect(),
|
||||
});
|
||||
Ok(Some(IssueView {
|
||||
issue,
|
||||
read_href,
|
||||
@@ -842,6 +852,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
struct FullEntry {
|
||||
title: String,
|
||||
href: String,
|
||||
dashboard_href: String,
|
||||
source: String,
|
||||
reading_minutes: i64,
|
||||
is_lead: bool,
|
||||
@@ -890,7 +901,7 @@ struct IssueFullTemplate {
|
||||
stats_line: String,
|
||||
front_page_html: String,
|
||||
read_href: Option<String>,
|
||||
downloads: Vec<Download>,
|
||||
downloads: Option<Downloads>,
|
||||
sections: Vec<FullSection>,
|
||||
has_world: bool,
|
||||
has_behind: bool,
|
||||
@@ -920,6 +931,7 @@ struct ArticleTemplate {
|
||||
body_html: String,
|
||||
discussion_html: Option<String>,
|
||||
read_online_url: String,
|
||||
dashboard_href: String,
|
||||
rating: Option<RatingWidget>,
|
||||
previous: Option<ArticleLink>,
|
||||
next: Option<ArticleLink>,
|
||||
@@ -981,6 +993,7 @@ pub async fn render_full(
|
||||
.map(|pick| FullEntry {
|
||||
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(),
|
||||
reading_minutes: pick.article.reading_minutes(),
|
||||
is_lead: pick.is_lead,
|
||||
@@ -1097,6 +1110,7 @@ pub async fn article(
|
||||
.as_ref()
|
||||
.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(|| {
|
||||
RatingWidget::for_issue(
|
||||
article.id,
|
||||
@@ -1949,6 +1963,8 @@ mod tests {
|
||||
assert!(!issue.contains("Was this a good pick?"));
|
||||
|
||||
let article_id = source.lineup.picks[0].article.id;
|
||||
let dashboard_href = format!("/dashboard/articles/{article_id}");
|
||||
assert!(!issue.contains(&dashboard_href));
|
||||
let article = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
@@ -1971,6 +1987,7 @@ mod tests {
|
||||
assert!(article.contains("referrerpolicy=\"no-referrer\""));
|
||||
assert!(article.contains("A Niche Delight"));
|
||||
assert!(article.contains("rel=\"next\""));
|
||||
assert!(!article.contains(&dashboard_href));
|
||||
|
||||
let world = app
|
||||
.clone()
|
||||
@@ -2156,19 +2173,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_are_listed_only_while_the_files_exist() {
|
||||
async fn single_nonstandard_download_renders_as_the_primary_without_a_menu() {
|
||||
let (dir, db, source) = seeded_issue(true).await;
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let epub_dir = dir.path().join("epubs");
|
||||
std::fs::create_dir(&epub_dir).unwrap();
|
||||
let standard = epub_dir.join(crate::publish::issue_filename(
|
||||
let x4 = epub_dir.join(crate::publish::issue_filename(
|
||||
source.meta.date,
|
||||
Edition::Standard,
|
||||
Edition::X4,
|
||||
"epub",
|
||||
));
|
||||
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
|
||||
std::fs::write(&x4, vec![0; 2 * 1024]).unwrap();
|
||||
let mut config = crate::config::Config::default();
|
||||
config.publish.epub_dir = epub_dir;
|
||||
let app = crate::server::router(crate::server::AppState::new(db, config, None));
|
||||
@@ -2184,11 +2201,79 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains("Download EPUB"));
|
||||
assert!(issue.contains("2.0 KB"));
|
||||
assert!(issue.contains(">Download X4 EPUB</a>"));
|
||||
assert!(!issue.contains("2048 bytes"));
|
||||
assert!(!issue.contains("Download X4 EPUB"));
|
||||
assert!(!issue.contains(">Download EPUB</a>"));
|
||||
assert!(!issue.contains("Download XTC"));
|
||||
assert!(!issue.contains("Choose download format"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_menu_lists_all_formats_with_standard_first() {
|
||||
let (dir, db, source) = seeded_issue(true).await;
|
||||
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||
.await
|
||||
.unwrap();
|
||||
let epub_dir = dir.path().join("epubs");
|
||||
let xtc_dir = dir.path().join("xtc");
|
||||
std::fs::create_dir(&epub_dir).unwrap();
|
||||
std::fs::create_dir(&xtc_dir).unwrap();
|
||||
let standard = epub_dir.join(crate::publish::issue_filename(
|
||||
source.meta.date,
|
||||
Edition::Standard,
|
||||
"epub",
|
||||
));
|
||||
let x4 = epub_dir.join(crate::publish::issue_filename(
|
||||
source.meta.date,
|
||||
Edition::X4,
|
||||
"epub",
|
||||
));
|
||||
let xtc = xtc_dir.join("issue.xtc");
|
||||
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
|
||||
std::fs::write(&x4, vec![0; 3 * 1024]).unwrap();
|
||||
std::fs::write(&xtc, vec![0; 4 * 1024]).unwrap();
|
||||
sqlx::query("UPDATE issues SET xtc_path = ? WHERE date = ?")
|
||||
.bind(xtc.to_string_lossy().as_ref())
|
||||
.bind(source.meta.date.to_string())
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut config = crate::config::Config::default();
|
||||
config.publish.epub_dir = epub_dir;
|
||||
let loaded = load(&db, &config, source.meta.date).await.unwrap().unwrap();
|
||||
let downloads = loaded.downloads.as_ref().unwrap();
|
||||
assert_eq!(downloads.primary.label, "EPUB");
|
||||
assert_eq!(
|
||||
downloads
|
||||
.others
|
||||
.iter()
|
||||
.map(|download| download.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["X4 EPUB", "XTC"]
|
||||
);
|
||||
|
||||
let app = crate::server::router(crate::server::AppState::new(db, config, None));
|
||||
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
let issue = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{}", source.meta.date))
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let issue = response_text(issue).await;
|
||||
assert!(issue.contains(">Download EPUB</a>"));
|
||||
assert!(issue.contains("Choose download format"));
|
||||
assert!(issue.contains("Standard EPUB"));
|
||||
assert!(issue.contains("X4 EPUB"));
|
||||
assert!(issue.contains("XTC"));
|
||||
assert!(issue.contains("2.0 KB"));
|
||||
assert!(issue.contains("3.0 KB"));
|
||||
assert!(issue.contains("4.0 KB"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2423,6 +2508,7 @@ mod tests {
|
||||
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
|
||||
let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||
let article_id = source.lineup.picks[0].article.id;
|
||||
let dashboard_href = format!("/dashboard/articles/{article_id}");
|
||||
|
||||
let json_response = app
|
||||
.clone()
|
||||
@@ -2474,6 +2560,24 @@ mod tests {
|
||||
let admin_issue = response_text(admin_issue).await;
|
||||
assert!(admin_issue.contains("Was this a good pick?"));
|
||||
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
|
||||
assert!(admin_issue.contains(&dashboard_href));
|
||||
|
||||
let admin_article = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!(
|
||||
"/issues/{}/articles/{article_id}",
|
||||
source.meta.date
|
||||
))
|
||||
.header(header::COOKIE, &admin_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(admin_article.status(), StatusCode::OK);
|
||||
assert!(response_text(admin_article).await.contains(&dashboard_href));
|
||||
|
||||
let admin_behind = app
|
||||
.clone()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,6 @@
|
||||
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
|
||||
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
|
||||
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
|
||||
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
||||
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
|
||||
</article>
|
||||
</div>{% endblock %}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
||||
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
|
||||
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||
{% if read_href.is_some() || !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||
{% if read_href.is_some() || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %}
|
||||
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
|
||||
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||
</section>
|
||||
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
||||
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% 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 %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||
|
||||
Reference in New Issue
Block a user