Add the Feeds dashboard page for reviewing feed candidates
Ranked by the shrunk mean of the linked articles' telemetry and ratings, with Add (into a chosen Miniflux category) and Dismiss per row, a status filter, a nav tab and an overview tile. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
@@ -0,0 +1,620 @@
|
|||||||
|
//! Dashboard: feed candidates (`/dashboard/feeds`, feed discovery plan §4
|
||||||
|
//! step 5).
|
||||||
|
//!
|
||||||
|
//! The discovery stage records the feeds behind aggregator-only articles in
|
||||||
|
//! `feed_candidates`; this page ranks the undecided ones by how likely the
|
||||||
|
//! operator is to enjoy them ([`discovery::score`]) and offers **Add** (which
|
||||||
|
//! subscribes through `POST /v1/feeds`) and **Dismiss**.
|
||||||
|
//!
|
||||||
|
//! Ranking is global, so the candidate view loads every undecided row, scores
|
||||||
|
//! it in Rust and paginates afterwards (plan §4 step 4). The decided views are
|
||||||
|
//! ordinary SQL pages.
|
||||||
|
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::Router;
|
||||||
|
use axum::extract::{Extension, Form, Path, Query, State};
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum_login::tower_sessions::Session;
|
||||||
|
use jiff::Timestamp;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::Row as _;
|
||||||
|
|
||||||
|
use super::jobs::set_flash;
|
||||||
|
use super::{Pager, allow_listed, db_err, dynamic_query, fmt_stored_time, page_number};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::db::Db;
|
||||||
|
use crate::discovery::{self, ArticleEvidence, Candidate};
|
||||||
|
use crate::miniflux::{MinifluxCategory, MinifluxClient};
|
||||||
|
use crate::server::AppState;
|
||||||
|
use crate::types::ArticleId;
|
||||||
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
|
use crate::web::{Html, Page, Pagination, WebError, take_flash};
|
||||||
|
|
||||||
|
const CANDIDATES_PER_PAGE: u32 = 50;
|
||||||
|
/// `feed_candidates.status` values, allow-listed for `?status=`.
|
||||||
|
const STATUSES: [&str; 3] = ["candidate", "added", "dismissed"];
|
||||||
|
/// Articles named in the "Why" column.
|
||||||
|
const WHY_ARTICLES: usize = 3;
|
||||||
|
/// `kv` key remembering the category the last Add used (plan §3).
|
||||||
|
const LAST_CATEGORY_KEY: &str = "feed_discovery_last_category";
|
||||||
|
const PATH: &str = "/dashboard/feeds";
|
||||||
|
|
||||||
|
/// Routes contributed by this page group (merged by `dashboard::router`).
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route(PATH, get(index))
|
||||||
|
.route("/dashboard/feeds/{id}/add", post(add))
|
||||||
|
.route("/dashboard/feeds/{id}/dismiss", post(dismiss))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct FeedsQuery {
|
||||||
|
status: Option<String>,
|
||||||
|
page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AddForm {
|
||||||
|
category_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One linked article, as named in the "Why" column.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WhyArticle {
|
||||||
|
id: ArticleId,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One table row.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct FeedRow {
|
||||||
|
id: i64,
|
||||||
|
/// The shrunk mean on 0–100, rounded.
|
||||||
|
score: i64,
|
||||||
|
feed_url: String,
|
||||||
|
/// The candidate's title, or its feed URL when it has none.
|
||||||
|
label: String,
|
||||||
|
host: String,
|
||||||
|
interests: Vec<String>,
|
||||||
|
articles: Vec<WhyArticle>,
|
||||||
|
article_count: usize,
|
||||||
|
first_seen: String,
|
||||||
|
last_seen: String,
|
||||||
|
decided_at: String,
|
||||||
|
/// `{miniflux.base_url}/feeds/{id}` for a candidate we subscribed to.
|
||||||
|
miniflux_href: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "dashboard/feeds.html")]
|
||||||
|
struct FeedsTemplate {
|
||||||
|
page: Page,
|
||||||
|
status: String,
|
||||||
|
rows: Vec<FeedRow>,
|
||||||
|
candidate_count: i64,
|
||||||
|
added_count: i64,
|
||||||
|
dismissed_count: i64,
|
||||||
|
categories: Vec<MinifluxCategory>,
|
||||||
|
selected_category: i64,
|
||||||
|
/// Why the category picker is empty, when it is; Add is disabled then.
|
||||||
|
categories_error: Option<String>,
|
||||||
|
pager: Pager,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthSession,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Query(query): Query<FeedsQuery>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let status = allow_listed(query.status.as_deref(), &STATUSES).unwrap_or("candidate");
|
||||||
|
let page_no = page_number(query.page);
|
||||||
|
|
||||||
|
// Candidates are ranked globally, so they are all loaded, scored, sorted
|
||||||
|
// and only then paginated (plan §4 step 4).
|
||||||
|
let (candidates, total, ranked_evidence) = if status == "candidate" {
|
||||||
|
let all = discovery::all_candidates(db).await?;
|
||||||
|
let evidence = load_evidence(db, &config, &all).await?;
|
||||||
|
let mut ranked: Vec<(Candidate, f64)> = all
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| {
|
||||||
|
let score = discovery::score(&evidence_of(&candidate, &evidence));
|
||||||
|
(candidate, score)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
ranked.sort_by(|a, b| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(Ordering::Equal)
|
||||||
|
.then_with(|| b.0.article_ids.len().cmp(&a.0.article_ids.len()))
|
||||||
|
.then_with(|| b.0.last_seen.cmp(&a.0.last_seen))
|
||||||
|
});
|
||||||
|
let total = ranked.len() as i64;
|
||||||
|
let offset = (page_no.saturating_sub(1) as usize) * CANDIDATES_PER_PAGE as usize;
|
||||||
|
let page_rows: Vec<Candidate> = ranked
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset)
|
||||||
|
.take(CANDIDATES_PER_PAGE as usize)
|
||||||
|
.map(|(candidate, _)| candidate)
|
||||||
|
.collect();
|
||||||
|
(page_rows, total, Some(evidence))
|
||||||
|
} else {
|
||||||
|
let (candidates, total) = discovery::list(db, status, page_no, CANDIDATES_PER_PAGE).await?;
|
||||||
|
(candidates, total, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let evidence = match ranked_evidence {
|
||||||
|
Some(evidence) => evidence,
|
||||||
|
None => load_evidence(db, &config, &candidates).await?,
|
||||||
|
};
|
||||||
|
let titles = article_titles(db, &candidates).await?;
|
||||||
|
let base_url = config.miniflux.base_url.trim_end_matches('/').to_string();
|
||||||
|
let rows: Vec<FeedRow> = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| row(candidate, &evidence, &titles, &config, &base_url))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The picker is only ever used by the candidate view, so only it pays for
|
||||||
|
// the categories call (plan §3).
|
||||||
|
let (categories, categories_error) = if status == "candidate" {
|
||||||
|
categories(&config).await
|
||||||
|
} else {
|
||||||
|
(Vec::new(), None)
|
||||||
|
};
|
||||||
|
let selected_category = selected_category(db, &categories).await?;
|
||||||
|
|
||||||
|
let pager = Pager::new(
|
||||||
|
Pagination {
|
||||||
|
page: page_no,
|
||||||
|
per_page: CANDIDATES_PER_PAGE,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
PATH,
|
||||||
|
&[("status", Some(status.to_string()))],
|
||||||
|
);
|
||||||
|
let mut page = Page::new("Feeds", viewer, "feeds");
|
||||||
|
page.flash = take_flash(&session).await?;
|
||||||
|
Ok(Html(FeedsTemplate {
|
||||||
|
page,
|
||||||
|
status: status.to_string(),
|
||||||
|
rows,
|
||||||
|
candidate_count: discovery::count(db, "candidate").await?,
|
||||||
|
added_count: discovery::count(db, "added").await?,
|
||||||
|
dismissed_count: discovery::count(db, "dismissed").await?,
|
||||||
|
categories,
|
||||||
|
selected_category,
|
||||||
|
categories_error,
|
||||||
|
pager,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Form(form): Form<AddForm>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let config = state.config();
|
||||||
|
let db = &state.db;
|
||||||
|
let now = Timestamp::now();
|
||||||
|
let Some(candidate) = discovery::candidate(db, id).await? else {
|
||||||
|
set_flash(&session, "error", format!("No feed candidate {id}.")).await?;
|
||||||
|
return Ok(Redirect::to(PATH).into_response());
|
||||||
|
};
|
||||||
|
let label = candidate
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| candidate.feed_url.clone());
|
||||||
|
let Some(client) = client(&config) else {
|
||||||
|
set_flash(
|
||||||
|
&session,
|
||||||
|
"error",
|
||||||
|
"Miniflux API key is not configured".into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(Redirect::to(PATH).into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
match client
|
||||||
|
.create_feed(&candidate.feed_url, form.category_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(feed_id) => {
|
||||||
|
discovery::set_status(db, id, "added", Some(feed_id), now).await?;
|
||||||
|
db.kv_set(LAST_CATEGORY_KEY, &form.category_id.to_string())
|
||||||
|
.await?;
|
||||||
|
set_flash(&session, "success", format!("Added {label}.")).await?;
|
||||||
|
}
|
||||||
|
// Already subscribed by other means: the operator's intent is met, so
|
||||||
|
// the row is decided too (plan §2).
|
||||||
|
Err(error) if error.is_duplicate_feed() => {
|
||||||
|
discovery::set_status(db, id, "added", None, now).await?;
|
||||||
|
set_flash(&session, "success", format!("{label}: {error}")).await?;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
set_flash(&session, "error", format!("{label}: {error}")).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Redirect::to(PATH).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dismiss(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(session): Extension<Session>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
) -> Result<Response, WebError> {
|
||||||
|
let db = &state.db;
|
||||||
|
let Some(candidate) = discovery::candidate(db, id).await? else {
|
||||||
|
set_flash(&session, "error", format!("No feed candidate {id}.")).await?;
|
||||||
|
return Ok(Redirect::to(PATH).into_response());
|
||||||
|
};
|
||||||
|
discovery::set_status(db, id, "dismissed", None, Timestamp::now()).await?;
|
||||||
|
let label = candidate.title.unwrap_or(candidate.feed_url);
|
||||||
|
set_flash(&session, "success", format!("Dismissed {label}.")).await?;
|
||||||
|
Ok(Redirect::to(PATH).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A Miniflux client from `[miniflux]`, or `None` when there is no API key.
|
||||||
|
fn client(config: &Config) -> Option<MinifluxClient> {
|
||||||
|
let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT).ok()?;
|
||||||
|
MinifluxClient::new(&config.miniflux, http).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/categories` for the Add picker; the error is shown as a notice on
|
||||||
|
/// the page (not a flash: nothing was submitted) and disables Add.
|
||||||
|
async fn categories(config: &Config) -> (Vec<MinifluxCategory>, Option<String>) {
|
||||||
|
let Some(client) = client(config) else {
|
||||||
|
return (
|
||||||
|
Vec::new(),
|
||||||
|
Some("Miniflux API key is not configured, so feeds cannot be added.".into()),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
match client.categories().await {
|
||||||
|
Ok(categories) => (categories, None),
|
||||||
|
Err(error) => (
|
||||||
|
Vec::new(),
|
||||||
|
Some(format!("Could not load Miniflux categories: {error}")),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The category the last Add used, when it still exists; else the first one.
|
||||||
|
async fn selected_category(db: &Db, categories: &[MinifluxCategory]) -> Result<i64, WebError> {
|
||||||
|
let last = db
|
||||||
|
.kv_get(LAST_CATEGORY_KEY)
|
||||||
|
.await?
|
||||||
|
.and_then(|value| value.trim().parse::<i64>().ok())
|
||||||
|
.filter(|id| categories.iter().any(|category| category.id == *id));
|
||||||
|
Ok(last
|
||||||
|
.or_else(|| categories.first().map(|category| category.id))
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn union_ids(candidates: &[Candidate]) -> Vec<ArticleId> {
|
||||||
|
let mut seen: HashSet<ArticleId> = HashSet::new();
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.flat_map(|candidate| candidate.article_ids.iter().copied())
|
||||||
|
.filter(|id| seen.insert(*id))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranking evidence for every article linked to `candidates`, in one query.
|
||||||
|
async fn load_evidence(
|
||||||
|
db: &Db,
|
||||||
|
config: &Config,
|
||||||
|
candidates: &[Candidate],
|
||||||
|
) -> Result<HashMap<ArticleId, ArticleEvidence>, WebError> {
|
||||||
|
Ok(discovery::load_evidence(
|
||||||
|
db,
|
||||||
|
&union_ids(candidates),
|
||||||
|
config.curation.ranking.rating_lookback_days,
|
||||||
|
)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Titles for every linked article, including the ones with no evidence (which
|
||||||
|
/// the "Why" column still lists, last).
|
||||||
|
async fn article_titles(
|
||||||
|
db: &Db,
|
||||||
|
candidates: &[Candidate],
|
||||||
|
) -> Result<HashMap<ArticleId, String>, WebError> {
|
||||||
|
let ids = union_ids(candidates);
|
||||||
|
let mut titles = HashMap::new();
|
||||||
|
for chunk in ids.chunks(500) {
|
||||||
|
let placeholders = vec!["?"; chunk.len()].join(", ");
|
||||||
|
let mut query = dynamic_query(format!(
|
||||||
|
"SELECT id, COALESCE(title, '') AS title FROM articles WHERE id IN ({placeholders})"
|
||||||
|
));
|
||||||
|
for id in chunk {
|
||||||
|
query = query.bind(*id);
|
||||||
|
}
|
||||||
|
for row in query.fetch_all(db.pool()).await.map_err(db_err)? {
|
||||||
|
titles.insert(row.get("id"), row.get("title"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(titles)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evidence_of(
|
||||||
|
candidate: &Candidate,
|
||||||
|
evidence: &HashMap<ArticleId, ArticleEvidence>,
|
||||||
|
) -> Vec<ArticleEvidence> {
|
||||||
|
candidate
|
||||||
|
.article_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| evidence.get(id).cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(
|
||||||
|
candidate: &Candidate,
|
||||||
|
evidence: &HashMap<ArticleId, ArticleEvidence>,
|
||||||
|
titles: &HashMap<ArticleId, String>,
|
||||||
|
config: &Config,
|
||||||
|
base_url: &str,
|
||||||
|
) -> FeedRow {
|
||||||
|
let mut scored = evidence_of(candidate, evidence);
|
||||||
|
let score = discovery::score(&scored);
|
||||||
|
let interests = discovery::why(&scored);
|
||||||
|
|
||||||
|
// Best evidence first; the articles nothing is known about come last, in
|
||||||
|
// title order.
|
||||||
|
scored.sort_by(|a, b| {
|
||||||
|
b.value
|
||||||
|
.partial_cmp(&a.value)
|
||||||
|
.unwrap_or(Ordering::Equal)
|
||||||
|
.then_with(|| a.title.cmp(&b.title))
|
||||||
|
});
|
||||||
|
let mut unscored: Vec<WhyArticle> = candidate
|
||||||
|
.article_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !evidence.contains_key(id))
|
||||||
|
.map(|id| WhyArticle {
|
||||||
|
id: *id,
|
||||||
|
title: titles.get(id).cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
unscored.sort_by(|a, b| a.title.cmp(&b.title));
|
||||||
|
let articles: Vec<WhyArticle> = scored
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| WhyArticle {
|
||||||
|
id: item.article_id,
|
||||||
|
title: item.title,
|
||||||
|
})
|
||||||
|
.chain(unscored)
|
||||||
|
.take(WHY_ARTICLES)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
FeedRow {
|
||||||
|
id: candidate.id,
|
||||||
|
score: score.round() as i64,
|
||||||
|
feed_url: candidate.feed_url.clone(),
|
||||||
|
label: candidate
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| candidate.feed_url.clone()),
|
||||||
|
host: candidate.host.clone(),
|
||||||
|
interests,
|
||||||
|
articles,
|
||||||
|
article_count: candidate.article_ids.len(),
|
||||||
|
first_seen: fmt_stored_time(Some(&candidate.first_seen), config),
|
||||||
|
last_seen: fmt_stored_time(Some(&candidate.last_seen), config),
|
||||||
|
decided_at: fmt_stored_time(candidate.decided_at.as_deref(), config),
|
||||||
|
miniflux_href: candidate
|
||||||
|
.miniflux_feed_id
|
||||||
|
.map(|feed_id| format!("{base_url}/feeds/{feed_id}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use axum::Json;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Method, Request, StatusCode, header};
|
||||||
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::server::router;
|
||||||
|
use crate::web::dashboard::tests::{
|
||||||
|
Seed, app_with_users, assert_admin_only, get, login_cookie, response_text, seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A candidate on `host` linked to `article_ids`.
|
||||||
|
async fn candidate(db: &Db, title: &str, host: &str, article_ids: &[ArticleId]) -> i64 {
|
||||||
|
let id = discovery::upsert_candidate(
|
||||||
|
db,
|
||||||
|
&format!("https://{host}/feed.xml"),
|
||||||
|
host,
|
||||||
|
Some(title),
|
||||||
|
"2026-09-06T04:00:00Z".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for article_id in article_ids {
|
||||||
|
discovery::link_article(db, id, *article_id).await.unwrap();
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_of(db: &Db, id: i64) -> (String, Option<i64>, Option<String>) {
|
||||||
|
let candidate = discovery::candidate(db, id).await.unwrap().unwrap();
|
||||||
|
(
|
||||||
|
candidate.status,
|
||||||
|
candidate.miniflux_feed_id,
|
||||||
|
candidate.decided_at,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Miniflux stand-in answering `POST /v1/feeds` with `body`.
|
||||||
|
async fn miniflux(status: StatusCode, body: serde_json::Value) -> String {
|
||||||
|
let handler = move || {
|
||||||
|
let body = body.clone();
|
||||||
|
async move { (status, Json(body)) }
|
||||||
|
};
|
||||||
|
let app = Router::new().route("/v1/feeds", axum::routing::post(handler));
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = axum::serve(listener, app).await;
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn app_with_miniflux(seed: &Seed, base_url: String) -> axum::Router {
|
||||||
|
crate::web::users::add(&seed.db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
crate::web::users::add(&seed.db, "admin", "correct horse battery", true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.miniflux.base_url = base_url;
|
||||||
|
config.miniflux.api_key = Some("test-key-never-logged".into());
|
||||||
|
router(AppState::new(seed.db.clone(), config, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_form(app: &axum::Router, uri: &str, cookie: &str) -> Response {
|
||||||
|
app.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||||
|
.header("sec-fetch-site", "same-origin")
|
||||||
|
.body(Body::from("category_id=3"))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn feeds_page_is_admin_only_and_empty_without_candidates() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/feeds").await;
|
||||||
|
|
||||||
|
assert!(body.contains("<h1>Feeds</h1>"), "{body}");
|
||||||
|
assert!(body.contains("No feed candidates"), "{body}");
|
||||||
|
assert!(body.contains("Candidates (0)"), "{body}");
|
||||||
|
// No API key in the test config, so Add is disabled with a notice.
|
||||||
|
assert!(
|
||||||
|
body.contains("Miniflux API key is not configured, so feeds cannot be added."),
|
||||||
|
"{body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn candidates_are_listed_best_first() {
|
||||||
|
let seed = seed().await;
|
||||||
|
// Article 1 is rated `loved` (value 1.0); article 5 only ever reached
|
||||||
|
// `triaged`, so it scores on its blend alone.
|
||||||
|
candidate(&seed.db, "Weak Blog", "weak.example", &[5]).await;
|
||||||
|
candidate(&seed.db, "Strong Blog", "strong.example", &[1]).await;
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
|
||||||
|
let body = assert_admin_only(&app, "/dashboard/feeds").await;
|
||||||
|
|
||||||
|
assert!(body.contains("Candidates (2)"), "{body}");
|
||||||
|
assert!(body.contains("Strong Blog"), "{body}");
|
||||||
|
assert!(body.contains("Weak Blog"), "{body}");
|
||||||
|
assert!(body.contains("https://strong.example/feed.xml"), "{body}");
|
||||||
|
// The rated article is named in the Why column and links to it.
|
||||||
|
assert!(body.contains("/dashboard/articles/1"), "{body}");
|
||||||
|
assert!(body.contains("Gaussian Splatting"), "{body}");
|
||||||
|
let strong = body.find("Strong Blog").unwrap();
|
||||||
|
let weak = body.find("Weak Blog").unwrap();
|
||||||
|
assert!(strong < weak, "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dismiss_decides_the_row_and_redirects() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let id = candidate(&seed.db, "Noise", "noise.example", &[2]).await;
|
||||||
|
let app = app_with_users(&seed.db).await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
|
||||||
|
let response = post_form(&app, &format!("/dashboard/feeds/{id}/dismiss"), &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get(header::LOCATION).unwrap(),
|
||||||
|
"/dashboard/feeds"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, feed_id, decided_at) = status_of(&seed.db, id).await;
|
||||||
|
assert_eq!(status, "dismissed");
|
||||||
|
assert_eq!(feed_id, None);
|
||||||
|
assert!(decided_at.is_some());
|
||||||
|
|
||||||
|
let body = response_text(get(&app, "/dashboard/feeds", Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("Dismissed Noise."), "{body}");
|
||||||
|
assert!(body.contains("Candidates (0)"), "{body}");
|
||||||
|
assert!(body.contains("Dismissed (1)"), "{body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_subscribes_and_records_the_miniflux_feed_id() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let id = candidate(&seed.db, "Strong Blog", "strong.example", &[1]).await;
|
||||||
|
let base_url = miniflux(StatusCode::CREATED, json!({"feed_id": 77})).await;
|
||||||
|
let app = app_with_miniflux(&seed, base_url).await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
|
||||||
|
let response = post_form(&app, &format!("/dashboard/feeds/{id}/add"), &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
let (status, feed_id, decided_at) = status_of(&seed.db, id).await;
|
||||||
|
assert_eq!(status, "added");
|
||||||
|
assert_eq!(feed_id, Some(77));
|
||||||
|
assert!(decided_at.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
seed.db.kv_get(LAST_CATEGORY_KEY).await.unwrap().as_deref(),
|
||||||
|
Some("3")
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = response_text(get(&app, "/dashboard/feeds", Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("Added Strong Blog."), "{body}");
|
||||||
|
let added =
|
||||||
|
response_text(get(&app, "/dashboard/feeds?status=added", Some(&admin)).await).await;
|
||||||
|
assert!(added.contains("/feeds/77"), "{added}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_duplicate_subscription_still_decides_the_row() {
|
||||||
|
let seed = seed().await;
|
||||||
|
let id = candidate(&seed.db, "Known Blog", "known.example", &[1]).await;
|
||||||
|
let base_url = miniflux(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({"error_message": "This feed already exists."}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let app = app_with_miniflux(&seed, base_url).await;
|
||||||
|
let admin = login_cookie(&app, "admin", "correct horse battery").await;
|
||||||
|
|
||||||
|
let response = post_form(&app, &format!("/dashboard/feeds/{id}/add"), &admin).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||||
|
|
||||||
|
let (status, feed_id, decided_at) = status_of(&seed.db, id).await;
|
||||||
|
assert_eq!(status, "added");
|
||||||
|
assert_eq!(feed_id, None);
|
||||||
|
assert!(decided_at.is_some());
|
||||||
|
|
||||||
|
let body = response_text(get(&app, "/dashboard/feeds", Some(&admin)).await).await;
|
||||||
|
assert!(body.contains("This feed already exists."), "{body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
//! `_signals_table.html`, the allow-list check and the LIKE-pattern escaper.
|
//! `_signals_table.html`, the allow-list check and the LIKE-pattern escaper.
|
||||||
|
|
||||||
pub mod articles;
|
pub mod articles;
|
||||||
|
pub mod feeds;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod ratings;
|
pub mod ratings;
|
||||||
@@ -44,6 +45,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.merge(runs::routes())
|
.merge(runs::routes())
|
||||||
.merge(articles::routes())
|
.merge(articles::routes())
|
||||||
.merge(ratings::routes())
|
.merge(ratings::routes())
|
||||||
|
.merge(feeds::routes())
|
||||||
.merge(profile::routes())
|
.merge(profile::routes())
|
||||||
.merge(settings::routes())
|
.merge(settings::routes())
|
||||||
.merge(jobs::routes())
|
.merge(jobs::routes())
|
||||||
@@ -422,6 +424,7 @@ struct OverviewTemplate {
|
|||||||
ratings: Vec<LabelCount>,
|
ratings: Vec<LabelCount>,
|
||||||
ratings_total: i64,
|
ratings_total: i64,
|
||||||
access_requests: i64,
|
access_requests: i64,
|
||||||
|
feed_candidates: i64,
|
||||||
unrated: Vec<UnratedPick>,
|
unrated: Vec<UnratedPick>,
|
||||||
active_jobs: Vec<JobLine>,
|
active_jobs: Vec<JobLine>,
|
||||||
finished_jobs: Vec<JobLine>,
|
finished_jobs: Vec<JobLine>,
|
||||||
@@ -448,6 +451,7 @@ async fn overview(
|
|||||||
.fetch_one(db.pool())
|
.fetch_one(db.pool())
|
||||||
.await
|
.await
|
||||||
.map_err(db_err)?;
|
.map_err(db_err)?;
|
||||||
|
let feed_candidates = crate::discovery::count(db, "candidate").await?;
|
||||||
let unrated = unrated_picks(db).await?;
|
let unrated = unrated_picks(db).await?;
|
||||||
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
|
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
|
||||||
let sparklines = overview_sparklines(db).await?;
|
let sparklines = overview_sparklines(db).await?;
|
||||||
@@ -466,6 +470,7 @@ async fn overview(
|
|||||||
ratings,
|
ratings,
|
||||||
ratings_total,
|
ratings_total,
|
||||||
access_requests,
|
access_requests,
|
||||||
|
feed_candidates,
|
||||||
unrated,
|
unrated,
|
||||||
active_jobs,
|
active_jobs,
|
||||||
finished_jobs,
|
finished_jobs,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "layout.html" %}{% block content %}<section class="dashboard">
|
||||||
|
<header class="page-head"><div>
|
||||||
|
<h1>Feeds</h1>
|
||||||
|
<p class="page-desc">Feeds behind the stories that reached us through an aggregator, ranked by how the articles they carried actually fared. Add subscribes in Miniflux; Dismiss hides the feed for good.</p>
|
||||||
|
</div><div class="page-actions"><a class="btn" href="/dashboard/articles">Articles</a><a class="btn" href="/dashboard/runs">Runs</a></div></header>
|
||||||
|
<nav class="tabs" aria-label="Candidate status"><a href="/dashboard/feeds"{% if status == "candidate" %} class="active" aria-current="page"{% endif %}>Candidates ({{ candidate_count }})</a> <a href="/dashboard/feeds?status=added"{% if status == "added" %} class="active" aria-current="page"{% endif %}>Added ({{ added_count }})</a> <a href="/dashboard/feeds?status=dismissed"{% if status == "dismissed" %} class="active" aria-current="page"{% endif %}>Dismissed ({{ dismissed_count }})</a></nav>
|
||||||
|
{% if let Some(error) = categories_error %}<p class="notice">{{ error }}</p>{% endif %}
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
<div class="scroll-x tall"><table>
|
||||||
|
<thead><tr><th class="num">Score</th><th>Feed</th><th>Why</th><th class="num">Articles</th><th>Seen</th><th><span class="sr-only">Actions</span></th></tr></thead>
|
||||||
|
<tbody>{% for row in rows %}<tr>
|
||||||
|
<td class="num tabular-nums">{{ row.score }}</td>
|
||||||
|
<td class="cell-wrap"><a href="{{ row.feed_url }}" rel="noopener" target="_blank">{{ row.label }}</a><br><span class="muted text-xs">{{ row.host }}</span></td>
|
||||||
|
<td class="cell-wrap">{% for interest in row.interests %}<span class="badge">{{ interest }}</span> {% endfor %}{% for article in row.articles %}<div class="text-xs"><a href="/dashboard/articles/{{ article.id }}">{{ article.title }}</a></div>{% endfor %}</td>
|
||||||
|
<td class="num">{{ row.article_count }}</td>
|
||||||
|
<td class="cell-tight text-muted">{{ row.first_seen }}<br>{{ row.last_seen }}</td>
|
||||||
|
<td>{% if status == "candidate" %}<div class="flex flex-wrap gap-2"><form class="form-inline" method="post" action="/dashboard/feeds/{{ row.id }}/add"><select name="category_id" aria-label="Category for {{ row.label }}"{% if categories.is_empty() %} disabled{% endif %}>{% for category in categories %}<option value="{{ category.id }}"{% if category.id == selected_category %} selected{% endif %}>{{ category.title }}</option>{% endfor %}</select><button class="btn-primary" type="submit"{% if categories.is_empty() %} disabled title="Miniflux categories are unavailable"{% endif %}>Add</button></form><form method="post" action="/dashboard/feeds/{{ row.id }}/dismiss"><button class="btn" type="submit">Dismiss</button></form></div>{% else %}<span class="muted text-xs">{{ row.decided_at }}{% if let Some(href) = row.miniflux_href %} · <a href="{{ href }}" rel="noopener" target="_blank">in Miniflux</a>{% endif %}</span>{% endif %}</td>
|
||||||
|
</tr>{% endfor %}{% if rows.is_empty() %}<tr><td colspan="6" class="text-muted">No feed candidates with this status. The discovery stage adds them during a run; <code>daily-epub feeds discover</code> backfills from recent articles.</td></tr>{% endif %}</tbody>
|
||||||
|
</table></div>
|
||||||
|
{% include "dashboard/_pager.html" %}
|
||||||
|
</section>{% endblock %}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
|
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
|
||||||
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
|
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
|
||||||
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
|
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
|
||||||
|
<div class="tile"><span class="tile-label">Feed candidates</span><span class="tile-num">{{ feed_candidates }}</span><span class="tile-delta"><a href="/dashboard/feeds">review feeds</a></span></div>
|
||||||
<div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div>
|
<div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
||||||
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "feeds" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/feeds"{% if page.active_nav == "feeds" %} aria-current="page"{% endif %}>Feeds</a>
|
||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
||||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "jobs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "jobs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user