Switch an item between its HTML and its Markdown (#285)

A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.

The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 18:39:16 +00:00
parent d16460267f
commit d5ffe5d042
12 changed files with 131 additions and 14 deletions

View File

@@ -103,6 +103,12 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
}
}
if orm.execute_unprepared("SELECT markdown FROM entries LIMIT 0").await.is_err() {
// Stored items get theirs as a scan finds them told differently, by the Markdown alone.
orm.execute_unprepared("ALTER TABLE entries ADD COLUMN markdown TEXT")
.await
.context("adding entries.markdown")?;
}
if orm.execute_unprepared("SELECT body_hash FROM feeds LIMIT 0").await.is_err() {
for sql in ["ALTER TABLE feeds ADD COLUMN body_hash TEXT", "ALTER TABLE feeds ADD COLUMN same_since BIGINT"] {
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
@@ -734,8 +740,8 @@ impl Db {
.exec(
"INSERT INTO entries
(feed_id, guid, title, link, published, description, first_seen,
image, duration, episode, season)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
image, duration, episode, season, markdown)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING",
vec![
feed_id.into(),
@@ -749,6 +755,7 @@ impl Db {
e.duration.into(),
e.episode.into(),
e.season.into(),
e.markdown.clone().into(),
],
)
.await?;
@@ -760,7 +767,8 @@ impl Db {
image = coalesce($5, image),
duration = coalesce($6, duration),
episode = coalesce($7, episode),
season = coalesce($8, season)
season = coalesce($8, season),
markdown = coalesce($9, markdown)
WHERE feed_id = $1 AND guid = $2",
vec![
feed_id.into(),
@@ -771,6 +779,7 @@ impl Db {
e.duration.into(),
e.episode.into(),
e.season.into(),
e.markdown.clone().into(),
],
)
.await?;
@@ -1131,6 +1140,10 @@ pub struct EntryRow {
pub link: Option<String>,
pub published: Option<i64>,
pub description: Option<String>,
/// The item's Markdown as written, when its feed sends it beside the HTML, and that Markdown
/// made HTML and cleaned: the page switches between the three (#285).
pub markdown: Option<String>,
pub markdown_html: Option<String>,
pub read: bool,
pub flagged: bool,
pub image: Option<String>,
@@ -1409,7 +1422,7 @@ impl Db {
let (limit, offset) = (a.p(limit), a.p(offset));
let user = a.p(user_id);
let sql = format!(
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description,
"SELECT e.guid, e.feed_id, e.title, e.link, e.published, e.description, e.markdown,
coalesce(s.read, false) AS read, coalesce(s.flagged, false) AS flagged, e.image,
coalesce(s.duration, e.duration) AS duration,
e.episode, e.season, coalesce(s.position, 0) AS position
@@ -1430,6 +1443,8 @@ impl Db {
link: r.try_get("", "link")?,
published: r.try_get("", "published")?,
description: r.try_get("", "description")?,
markdown: r.try_get("", "markdown")?,
markdown_html: None,
read: r.try_get("", "read")?,
flagged: r.try_get("", "flagged")?,
image: r.try_get("", "image")?,
@@ -2338,11 +2353,12 @@ impl Db {
let feed = a.p(feed_id);
let guids: Vec<String> = entries.iter().map(|e| a.p(e.guid.clone())).collect();
let sql = format!(
"SELECT guid, title, description, image, duration, episode, season FROM entries
"SELECT guid, title, description, image, duration, episode, season, markdown FROM entries
WHERE feed_id = {feed} AND guid IN ({})",
guids.join(", ")
);
type Stored = (Option<String>, Option<String>, Option<String>, Option<i64>, Option<i64>, Option<i64>);
type Stored =
(Option<String>, Option<String>, Option<String>, Option<i64>, Option<i64>, Option<i64>, Option<String>);
let mut stored = std::collections::HashMap::<String, Stored>::new();
for r in self.rows(&sql, a.0).await? {
stored.insert(
@@ -2354,6 +2370,7 @@ impl Db {
r.try_get("", "duration")?,
r.try_get("", "episode")?,
r.try_get("", "season")?,
r.try_get("", "markdown")?,
),
);
}
@@ -2370,6 +2387,7 @@ impl Db {
|| differs(&e.duration, &s.3)
|| differs(&e.episode, &s.4)
|| differs(&e.season, &s.5)
|| differs(&e.markdown, &s.6)
})
})
.map(|e| e.guid.clone())
@@ -3315,6 +3333,14 @@ mod tests {
.unwrap();
assert_eq!(changed, ["a", "b"].map(String::from).into());
assert!(db.changed_items("f", &[]).await.unwrap().is_empty());
// A Markdown copy the feed starts sending is a change, and is stored (#285).
let md = crate::feed::Entry { markdown: Some("*as it was*".into()), ..item("c", "C", Some("as it was")) };
assert_eq!(db.changed_items("f", std::slice::from_ref(&md)).await.unwrap(), ["c"].map(String::from).into());
assert!(!db.record_entry("f", &md).await.unwrap(), "rewritten, not new");
let order = order_sql("published", "desc");
let rows = db.entries_in(1, Some("f"), Filter::All, None, 0, 50, &order).await.unwrap();
assert_eq!(rows.iter().find(|r| r.guid == "c").unwrap().markdown.as_deref(), Some("*as it was*"));
}
#[tokio::test]