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:
@@ -27,6 +27,9 @@ pub struct Entry {
|
||||
pub link: Option<String>,
|
||||
pub published: Option<i64>,
|
||||
pub description: Option<String>,
|
||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||
pub markdown: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub explicit: bool,
|
||||
/// Episode artwork; falls back to the feed's in the UI.
|
||||
@@ -770,6 +773,8 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
||||
// Content wins over description, as __getEntries preferred entry.content. The
|
||||
// source namespace's Markdown copy only when there is neither (#284).
|
||||
description: notes(item.content(), item.description(), source_markdown(item)),
|
||||
markdown: non_empty(source_markdown(item))
|
||||
.filter(|_| non_empty(item.content()).or_else(|| non_empty(item.description())).is_some()),
|
||||
categories: item
|
||||
.categories()
|
||||
.iter()
|
||||
@@ -839,6 +844,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
||||
link: alt.map(str::to_owned),
|
||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||
description: notes(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str()), None),
|
||||
markdown: None,
|
||||
categories: e.categories().iter().map(|c| c.term().to_owned()).collect(),
|
||||
explicit: false,
|
||||
image: None,
|
||||
@@ -966,7 +972,8 @@ fn is_markdown(s: &str) -> bool {
|
||||
!tag && (s.contains("](") || s.contains("**") || s.lines().any(block))
|
||||
}
|
||||
|
||||
fn markdown_html(md: &str) -> String {
|
||||
/// Markdown made HTML, uncleaned: whoever shows it cleans it as any feed's HTML.
|
||||
pub fn markdown_html(md: &str) -> String {
|
||||
use pulldown_cmark::{Options, Parser, html};
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, Parser::new_ext(md, Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH));
|
||||
@@ -1423,6 +1430,15 @@ Some **bold** and [a link](https://x.example/).</description></item>
|
||||
assert_eq!(got[3].as_deref(), Some("1984. Plain text, left as it is."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_sent_beside_html_is_kept_for_the_page_to_switch_to() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0" xmlns:source="https://source.scripting.com/"><channel><title>R</title>
|
||||
<item><guid>both</guid><description><p>Hi</p></description><source:markdown>*Hi*</source:markdown></item>
|
||||
<item><guid>alone</guid><source:markdown>*Hi*</source:markdown></item></channel></rss>"#;
|
||||
let got: Vec<_> = parse(xml).unwrap().entries.into_iter().map(|e| e.markdown).collect();
|
||||
assert_eq!(got, [Some("*Hi*".to_owned()), None], "only beside HTML; alone, it is the description (#284)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||
|
||||
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- An item whose feed sends Markdown beside its HTML, as Scripting News does with `<source:markdown>`, keeps it: `markdown` and `markdown_html` in the API's items (#285).
|
||||
- An item written in Markdown shows formatted, not as Markdown's raw characters: a description in Markdown, or, with no other text, RSS's `<source:markdown>`. HTML a feed sends is left as it is (#284).
|
||||
- A show from an OPML subscription or a Patreon creator can be kept: `kept` on `PATCH /api/feeds/{id}` and in the feed list. When the OPML drops a kept show, or you leave the OPML, it stays on your list as a feed of its own (#280).
|
||||
- `/metrics` counts Signals: items judged and fitting, calls to Jev by how they ended, the time they took and the tokens TypeSafe counted, time since the judge's last pass, and keys refused; a failed call to Jev shows as a failed trace (#270, #275).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -78,6 +78,9 @@ pub mod entries {
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
pub first_seen: i64,
|
||||
/// The item's Markdown, when its feed sends it beside the HTML (#285).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub markdown: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub image: Option<String>,
|
||||
pub duration: Option<i64>,
|
||||
|
||||
@@ -1517,9 +1517,7 @@ async fn entry_page(
|
||||
step("sanitize").in_scope(|| {
|
||||
let mut sanitizer = feed_sanitizer();
|
||||
for row in &mut rows {
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(clean_description(&mut sanitizer, d, row.link.as_deref()));
|
||||
}
|
||||
clean_row(&mut sanitizer, row);
|
||||
}
|
||||
});
|
||||
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
|
||||
@@ -1543,12 +1541,21 @@ async fn one_entry(
|
||||
let mut rows = [row];
|
||||
crate::deadair::for_reader(&state.ctx, user.id, &mut rows).await?;
|
||||
let [mut row] = rows;
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(clean_description(&mut feed_sanitizer(), d, row.link.as_deref()));
|
||||
}
|
||||
clean_row(&mut feed_sanitizer(), &mut row);
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
/// An item's text as the page may show it: the feed's HTML cleaned, and its Markdown, where it
|
||||
/// sent one, made HTML and cleaned the same way (#285).
|
||||
fn clean_row(sanitizer: &mut ammonia::Builder<'static>, row: &mut crate::db::EntryRow) {
|
||||
let link = row.link.clone();
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(clean_description(sanitizer, d, link.as_deref()));
|
||||
}
|
||||
row.markdown_html =
|
||||
row.markdown.as_deref().map(|m| clean_description(sanitizer, &ipx_feed::markdown_html(m), link.as_deref()));
|
||||
}
|
||||
|
||||
/// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||
fn feed_sanitizer() -> ammonia::Builder<'static> {
|
||||
let mut b = ammonia::Builder::new();
|
||||
|
||||
@@ -519,6 +519,8 @@ export const ipx = {
|
||||
['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option<String>', ''], ['link', 'Option<String>', ''],
|
||||
['published', 'Option<i64>', ''],
|
||||
['description', 'Option<String>', 'Show notes, HTML, already sanitized.'],
|
||||
['markdown', 'Option<String>', 'The item’s Markdown as written, when its feed sends Markdown beside its HTML (RSS’s source:markdown).'],
|
||||
['markdown_html', 'Option<String>', 'That Markdown made HTML, sanitized as description is.'],
|
||||
['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'],
|
||||
['image', 'Option<String>', ''], ['duration', 'Option<i64>', 'Seconds.'],
|
||||
['episode', 'Option<i64>', ''], ['season', 'Option<i64>', ''],
|
||||
|
||||
@@ -521,6 +521,35 @@ test('a show inside an OPML can be kept, and only it', async ({ page }) => {
|
||||
await expect(page.locator('#content [data-a="keep"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an item sent as HTML and Markdown switches between them', async ({ page }) => {
|
||||
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
||||
.then((r) => r.json().catch(() => null)), [p, init]);
|
||||
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/markdown.xml' }) });
|
||||
const id = added.id || added.feed?.id || 'markdown-blog';
|
||||
await page.reload();
|
||||
await page.locator('#feedlist .feed', { hasText: 'Markdown Blog' }).click({ timeout: 20_000 });
|
||||
await page.locator('#content .tabs button[data-f="all"]').click();
|
||||
await page.locator('.ep', { hasText: 'Both Ways' }).click();
|
||||
|
||||
// The publisher's HTML first; the choice is between the title and the item's buttons (#285).
|
||||
const view = page.locator('#detail .dmeta .tview');
|
||||
await expect(view).toHaveValue('html');
|
||||
await expect(page.locator('#detail .dbody b')).toHaveText('HTML');
|
||||
await view.selectOption('markdown');
|
||||
await expect(page.locator('#detail .dbody strong')).toHaveText('Markdown');
|
||||
await view.selectOption('source');
|
||||
await expect(page.locator('#detail .dbody pre.mdsrc')).toHaveText('The **Markdown** copy.');
|
||||
// Kept for the next item; set back so nothing after this sees it.
|
||||
expect(await page.evaluate(() => localStorage.getItem('ipx.textview'))).toBe('source');
|
||||
await view.selectOption('html');
|
||||
|
||||
// An item with no Markdown has no choice to make.
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await page.locator('.ep').first().click();
|
||||
await expect(page.locator('#detail .tview')).toHaveCount(0);
|
||||
await api('/api/feeds/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
});
|
||||
|
||||
test('marking an OPML subscription read covers the feeds inside it', async ({ page }) => {
|
||||
await page.evaluate(() =>
|
||||
fetch('/api/fetch', {
|
||||
|
||||
8
tests/ui/fixtures/markdown.xml
Normal file
8
tests/ui/fixtures/markdown.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0" xmlns:source="https://source.scripting.com/">
|
||||
<channel><title>Markdown Blog</title><link>http://127.0.0.1:8792/</link><description>HTML and Markdown both, as Scripting News sends.</description>
|
||||
<item><title>Both Ways</title><guid>md-1</guid>
|
||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description><p>The <b>HTML</b> copy.</p></description>
|
||||
<source:markdown>The **Markdown** copy.</source:markdown></item>
|
||||
</channel></rss>
|
||||
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- An item sent as both HTML and Markdown, as Scripting News's are, has a choice beside its buttons to show it as the publisher's HTML, as the Markdown, or as the Markdown source, kept from one item to the next (#285).
|
||||
- A show inside an OPML subscription has a Keep button, a plus, on its page, so it stays on your list as a show of its own if the OPML drops it or you unsubscribe from the OPML (#280).
|
||||
- Admin, Server: a setting to give everyone Signals on the server's TypeSafe key (#271).
|
||||
- Admin, Server: how many days back a new Signal reads (#276).
|
||||
|
||||
@@ -848,6 +848,8 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
/* The reader's buttons go as one: as items of the line itself, a long feed name took them onto
|
||||
the next line one at a time (#146). */
|
||||
.dacts{display:flex;gap:inherit;flex:none}
|
||||
.dmeta .tview{width:auto;padding:3px 6px;font-size:12px}
|
||||
.dbody .mdsrc{white-space:pre-wrap;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
#detail .encbox{margin:0 0 10px}
|
||||
.dbody{font-size:14.5px;line-height:1.65;overflow-wrap:anywhere;color:var(--fg)}
|
||||
.dbody img{max-width:100%;height:auto;border-radius:6px}
|
||||
|
||||
@@ -376,6 +376,12 @@ function showDetail(e: Entry|null){
|
||||
box.innerHTML=detailHtml(e);
|
||||
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||
$<HTMLButtonElement>('#dback').onclick=()=>showDetail(null);
|
||||
const view=box.querySelector<HTMLSelectElement>('.tview');
|
||||
if(view) view.onchange=()=>{
|
||||
textView=view.value;
|
||||
try{ localStorage.setItem('ipx.textview',textView); }catch{}
|
||||
box.querySelector('.dbody')!.innerHTML=textOf(e);
|
||||
};
|
||||
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||
b.onclick=()=>epAction(b.dataset.a!,e,null,b.dataset.enc?Number(b.dataset.enc):undefined));
|
||||
syncPlayButtons();
|
||||
@@ -401,6 +407,8 @@ function detailHtml(e: Entry){
|
||||
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration),
|
||||
e.enclosures.some(x=>x.deadair_pending)&&DEAD_PENDING]
|
||||
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||
${e.markdown?`<select class="tview" aria-label="Show the text as">${TEXT_VIEWS.map(([v,label])=>
|
||||
`<option value="${v}"${v===textView?' selected':''}>${label}</option>`).join('')}</select>`:''}
|
||||
<span class="dacts"><button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||
@@ -410,7 +418,18 @@ function detailHtml(e: Entry){
|
||||
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}</span>
|
||||
</div>
|
||||
${narrow?encs:''}
|
||||
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||
<div class="dbody">${textOf(e)}</div>`;
|
||||
}
|
||||
|
||||
/// How an item whose feed sends Markdown beside its HTML is shown, kept from one item to the
|
||||
/// next (#285): the publisher's HTML, the Markdown made HTML, or the Markdown as written.
|
||||
const TEXT_VIEWS=[['html','HTML'],['markdown','Markdown'],['source','Markdown source']];
|
||||
let textView=(()=>{ try{ return localStorage.getItem('ipx.textview'); }catch{ return null; } })()||'html';
|
||||
function textOf(e: Entry){
|
||||
// Both cleaned server-side with ammonia, as the description is.
|
||||
if(e.markdown&&textView==='markdown'&&e.markdown_html) return e.markdown_html;
|
||||
if(e.markdown&&textView==='source') return `<pre class="mdsrc">${esc(e.markdown)}</pre>`;
|
||||
return (e.description&&e.description.trim())||'<em>No show notes.</em>';
|
||||
}
|
||||
|
||||
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||
|
||||
@@ -49,6 +49,9 @@ interface Entry {
|
||||
link: string | null;
|
||||
published: number | null;
|
||||
description: string | null;
|
||||
/** The item's Markdown as written, when its feed sends it beside the HTML, and made HTML (#285). */
|
||||
markdown: string | null;
|
||||
markdown_html: string | null;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
image: string | null;
|
||||
|
||||
Reference in New Issue
Block a user