Inter, one file where WordPress listed two, and a pin heading on line

Inter (#11): the pages are set in Inter's variable font, served from the
binary at /inter.woff2 as the icon is, with its OFL licence beside it in
web/. Classic keeps Lucida Grande, the 2004 app's face.

Double audio (#12): WordPress numbers each audio player on a page by
adding ?_=N to its file's URL, so a post that embeds the file it encloses
listed it twice, and it was downloaded twice. The parser keeps the first
of an item's enclosures that differ only by that number. At startup the
repeats already stored fold into the first; where only the repeat had
been downloaded its file moves to the first rather than being deleted.

Pin heading (#13): the rows' icon buttons kept the browser's side
padding, which pushed their 16px icon 3px right of centre, and the
heading's icon sat at the left of its column. Both are centred now, and
the heading row takes the pixel of border the rows have, so every
heading sits over its column.

Closes #11, closes #12, closes #13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 17:31:03 +00:00
parent b83523fc92
commit 49dafedbbc
10 changed files with 289 additions and 7 deletions

View File

@@ -1345,6 +1345,58 @@ impl Db {
Ok(())
}
/// Folds enclosures of one item that `key` says are the same file into the first of them, for
/// WordPress's numbered player URLs (`feed::same_file_key`). The first is the one the parser
/// keeps, so it keeps its row, taking a repeat's file if it has none of its own; the repeats'
/// rows go. Returns how many went and the copies left spare, for the caller to delete.
pub fn merge_repeated_enclosures(&self, key: impl Fn(&str) -> String) -> Result<(usize, Vec<String>)> {
use std::collections::hash_map::Entry;
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
let rows: Vec<(i64, String, String, String, Option<String>)> = {
let mut stmt = tx.prepare(
"SELECT id, feed_id, guid, url, path FROM enclosures
WHERE (feed_id, guid) IN
(SELECT feed_id, guid FROM enclosures WHERE url GLOB '*[?&]_=[0-9]*')
ORDER BY id",
)?;
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)))?
.collect::<rusqlite::Result<_>>()?
};
// The first row of each file, and whether it has the file on disk yet.
let mut first: std::collections::HashMap<(String, String, String), (i64, bool)> = Default::default();
let (mut gone, mut spare) = (0, vec![]);
for (id, feed, guid, url, path) in rows {
match first.entry((feed, guid, key(&url))) {
Entry::Vacant(v) => {
v.insert((id, path.is_some()));
}
Entry::Occupied(mut o) => {
let (keep, has) = o.get_mut();
if let Some(p) = path {
if *has {
spare.push(p);
} else {
// The only copy is the repeat's: Rands' episode 97 was downloaded
// under its ?_=2 URL alone.
tx.execute(
"UPDATE enclosures SET (path, state, bytes_done, downloaded_at) =
(SELECT path, state, bytes_done, downloaded_at FROM enclosures WHERE id = ?2)
WHERE id = ?1",
params![*keep, id],
)?;
*has = true;
}
}
tx.execute("DELETE FROM enclosures WHERE id = ?1", [id])?;
gone += 1;
}
}
}
tx.commit()?;
Ok((gone, spare))
}
/// Stops treating a feed as derived, because it now has its own config entry.
pub fn unmanage(&self, id: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -1469,6 +1521,39 @@ pub fn now() -> i64 {
mod tests {
use super::*;
#[test]
fn a_file_wordpress_listed_twice_is_folded_into_one() {
let db = Db::memory().unwrap();
db.exec_for_test(
"INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','a','https://x/a.mp3','/d/a-2.mp3','done'),
(2,'f','a','https://x/a.mp3?_=2','/d/a.mp3','done'),
(3,'f','b','https://x/b.mp3',NULL,'reaped'),
(4,'f','b','https://x/b.mp3?_=2','/d/b.mp3','done'),
(5,'f','c','https://x/c.mp3?_=1','/d/c.mp3','done'),
(6,'f','d','https://x/d1.mp3?_=1','/d/d1.mp3','done'),
(7,'f','d','https://x/d2.mp3?_=2','/d/d2.mp3','done');",
)
.unwrap();
let key = crate::feed::same_file_key;
assert_eq!(db.merge_repeated_enclosures(key).unwrap(), (2, vec!["/d/a.mp3".to_string()]));
{
let conn = db.conn.lock().unwrap();
let ids: Vec<i64> = conn
.prepare("SELECT id FROM enclosures ORDER BY id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(ids, [1, 3, 5, 6, 7], "a lone ?_=1 and two different files stay");
let (path, state): (String, String) =
conn.query_row("SELECT path, state FROM enclosures WHERE id = 3", [], |r| Ok((r.get(0)?, r.get(1)?))).unwrap();
assert_eq!((path.as_str(), state.as_str()), ("/d/b.mp3", "done"), "the only copy moves, not deleted");
}
assert_eq!(db.merge_repeated_enclosures(key).unwrap(), (0, vec![]), "and only once");
}
#[test]
fn adding_category_drops_validators_once_and_keeps_the_schedule() {
let conn = Connection::open_in_memory().unwrap();

View File

@@ -480,7 +480,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.filter_map(|(idx, item)| {
// Straight from the XML, so an item with several keeps all of them. Falls
// back to the parsed one if the scan and the parser disagree on item count.
let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
item.enclosure()
.into_iter()
.map(|e| Enclosure {
@@ -491,6 +491,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
.filter(|e| !e.url.is_empty())
.collect()
});
drop_player_repeats(&mut enclosures);
let guid = pick_guid(
item.guid().map(|g| g.value()),
@@ -555,7 +556,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
.iter()
.filter_map(|e| {
// Atom carries enclosures as <link rel="enclosure">.
let enclosures: Vec<Enclosure> = e
let mut enclosures: Vec<Enclosure> = e
.links()
.iter()
.filter(|l| l.rel() == "enclosure")
@@ -566,6 +567,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
})
.filter(|e| !e.url.is_empty())
.collect();
drop_player_repeats(&mut enclosures);
let alt = e
.links()
@@ -631,6 +633,31 @@ fn non_empty(s: Option<&str>) -> Option<String> {
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
}
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
let mut seen = std::collections::HashSet::new();
encs.retain(|e| seen.insert(same_file_key(&e.url)));
}
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
/// from different files.
pub fn same_file_key(url: &str) -> String {
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
let kept: Vec<(String, String)> = u
.query_pairs()
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if kept.is_empty() {
u.set_query(None);
} else {
u.query_pairs_mut().clear().extend_pairs(kept);
}
u.to_string()
}
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
/// the XML parser with its HTML entities intact: The Verge's "Meta&#8217;s" reached the page as
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
@@ -777,6 +804,21 @@ mod tests {
);
}
#[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>
<item><title>The Promotion Paradox</title><guid>p</guid>
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
<enclosure url="https://x/other.mp3?_=3&amp;key=k" length="1" type="audio/mpeg"/>
</item></channel></rss>"#;
let urls: Vec<String> =
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
}
#[test]
fn titles_are_read_as_text_not_html() {
// The Verge: an Atom title of type="html", its entity inside CDATA.

View File

@@ -398,6 +398,21 @@ async fn daemon(
Err(e) => tracing::warn!(error = ?e, "could not retire feeds whose OPML is no longer in config"),
}
// Before the parser knew WordPress's numbered player URLs, a file it listed twice was
// downloaded twice. The repeats fold into the first, and their spare copies are deleted.
match ctx.db.merge_repeated_enclosures(feed::same_file_key) {
Ok((0, _)) => {}
Ok((n, spare)) => {
for path in &spare {
if let Err(e) = std::fs::remove_file(path) {
tracing::warn!(path, error = %e, "could not delete a spare copy");
}
}
tracing::info!(enclosures = n, files = spare.len(), "folded files WordPress listed twice");
}
Err(e) => tracing::warn!(error = ?e, "could not fold files WordPress listed twice"),
}
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;

View File

@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
.route("/login", get(login_page))
.route("/api/login", post(login))
.route("/icon.png", get(icon))
.route("/inter.woff2", get(inter))
.layer(middleware::from_fn(access_log))
.with_state(state)
}
@@ -452,6 +453,16 @@ async fn icon() -> impl IntoResponse {
)
}
/// Inter, the pages' typeface, served from the binary as the icon is, so neither page loads
/// anything from anyone else. Outside the auth layer for the sign-in page. Its licence, the SIL
/// Open Font License, is web/Inter-LICENSE.txt.
async fn inter() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2"), (header::CACHE_CONTROL, "max-age=604800")],
include_bytes!("../web/InterVariable.woff2").as_slice(),
)
}
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {