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:
85
src/db.rs
85
src/db.rs
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user