Show what a feed says it is on its page in the Directory (#130)

A feed's page in the Directory (#128) had its cover, category and latest items, but not the
feed's own description, which Apple's show page leads with: iPX read every item's description
and threw the channel's away, and the feeds table had nowhere to keep it.

It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's
subtitle, kept in feeds.description (kept when a later read has none, as title and image are),
and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a
list. The page shows it as plain text under the header, three lines of it, with More when there
is more. A description that only repeats the title is left out.

feeds.description is the first column added to a table that already exists. create_missing
looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every
open, the healthcheck's included, and an ALTER's lock is what made that time out before. The
same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when
it has changed, so its description would otherwise wait for its next item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:46:22 +00:00
parent 5058adc93e
commit ef8e2526f2
12 changed files with 135 additions and 32 deletions

View File

@@ -69,6 +69,21 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
// A column added to a table that already exists, which CREATE TABLE IF NOT EXISTS leaves
// as it was. Looked for first, and added only when it is missing: this runs on every open,
// the healthcheck's `ipx status` included, and an ALTER takes a lock that waits behind a
// busy daemon, which is what made the healthcheck time out once before.
if orm.execute_unprepared("SELECT description FROM feeds LIMIT 0").await.is_err() {
// A feed is read whole only when it has changed since the last read, so each one's
// description would wait for its next item. Forgetting how each was last seen makes
// its next read, when it is due anyway, a whole one.
for sql in [
"ALTER TABLE feeds ADD COLUMN description TEXT",
"UPDATE feeds SET etag = NULL, last_modified = NULL",
] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
Ok(())
}
@@ -314,6 +329,11 @@ impl Db {
Ok(())
}
/// What a feed says it is, for its page in the Directory.
pub async fn feed_description(&self, feed_id: &str) -> Result<Option<String>> {
Ok(feeds::Entity::find_by_id(feed_id.to_owned()).one(&self.orm).await?.and_then(|f| f.description))
}
#[tracing::instrument(skip_all)]
pub async fn feed_summary(&self, feed_id: &str) -> Result<FeedSummary> {
let mut sum = feeds::Entity::find_by_id(feed_id.to_owned())
@@ -475,13 +495,14 @@ impl Db {
ttl_mins: Option<u64>,
image: Option<&str>,
category: Option<&str>,
description: Option<&str>,
) -> Result<()> {
// category is taken as it comes, unlike title and image: a show that leaves a category
// should leave the Directory's chip too.
let o = |v: Option<&str>| sea_orm::Value::from(v.map(str::to_owned));
self.exec(
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image, category)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9)
"INSERT INTO feeds (id, url, title, etag, last_modified, last_checked, ttl_mins, last_error, image, category, description)
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET
url = excluded.url,
title = coalesce(excluded.title, feeds.title),
@@ -491,6 +512,7 @@ impl Db {
ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image),
category = excluded.category,
description = coalesce(excluded.description, feeds.description),
last_error = NULL,
error_since = NULL",
vec![
@@ -503,6 +525,7 @@ impl Db {
ttl_mins.map(|t| t as i64).into(),
o(image),
o(category),
o(description),
],
)
.await?;
@@ -2076,6 +2099,29 @@ pub fn now() -> i64 {
mod tests {
use super::*;
#[tokio::test]
async fn a_database_from_before_descriptions_gains_the_column_and_reads_each_feed_whole() {
let db = Db::memory().await.unwrap();
let record = |etag: &'static str, about: Option<&'static str>| {
let db = &db;
async move { db.record_feed("f", "https://x/f", Some("F"), Some(etag), Some("then"), None, None, None, about).await.unwrap() }
};
record("\"a\"", Some("About F")).await;
assert_eq!(db.feed_description("f").await.unwrap().as_deref(), Some("About F"));
record("\"a\"", None).await;
assert_eq!(db.feed_description("f").await.unwrap().as_deref(), Some("About F"), "kept when a read finds none");
// A database from before #130.
db.exec_for_test("ALTER TABLE feeds DROP COLUMN description").await.unwrap();
create_missing(&db.orm).await.unwrap();
assert_eq!(db.feed_description("f").await.unwrap(), None);
let st = db.http_state("f").await.unwrap();
assert!(st.etag.is_none() && st.last_modified.is_none(), "every feed is read whole next time, for its description");
// With the column there, opening the database again writes nothing.
record("\"b\"", Some("About F")).await;
create_missing(&db.orm).await.unwrap();
assert_eq!(db.http_state("f").await.unwrap().etag.as_deref(), Some("\"b\""));
}
#[tokio::test]
async fn every_sort_column_runs_and_orders_both_ways() {
let db = Db::memory().await.unwrap();