The original iPodderX layout: toolbar, places, item table, Files pane

- A toolbar across the window with the original's groups: add and
  unsubscribe, play, mark read and keep for the selected item, scan, a
  search box for what is showing, and Settings and Log (admins only).
- Directory, Popular and All Subscriptions sit at the top of the feed
  list and open in the main pane; the Popular and Directory buttons and
  their dialogs are gone.
- All Subscriptions lists every item from every feed you subscribe to:
  GET /api/entries, the per-feed query with its scope widened. The
  enclosure lookup after it matches files to rows by feed and guid, since
  a page can now span feeds.
- Items are a table (unread, kept, item, feed, file, published) with a
  Files pane beside it, the text below, and a status bar with totals. On
  a phone the files follow the text and the table is title and date.
- Tests: enclosures are checked in #files; the toolbar's read, keep and
  play act on the selected item; All Subscriptions holds only your feeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
2026-09-11 14:39:05 +00:00
parent f3825cfc57
commit df9b7645d6
9 changed files with 429 additions and 165 deletions

View File

@@ -609,6 +609,17 @@ pub struct EntryRow {
const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2
OR lower(coalesce(e.description, '')) LIKE ?2)";
/// Which feeds a query covers: one, or every feed the person subscribes to. Both forms
/// mention `?1`, since binding a parameter the statement does not use is an error.
fn scope_sql(feed_id: Option<&str>, user_param: u8) -> String {
match feed_id {
Some(_) => "e.feed_id = ?1".into(),
None => format!(
"?1 IS NULL AND e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = ?{user_param})"
),
}
}
/// Which slice of a feed the UI is asking for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Filter {
@@ -667,6 +678,20 @@ impl Db {
search: Option<&str>,
offset: i64,
limit: i64,
) -> Result<Vec<EntryRow>> {
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit)
}
/// `entries` for one feed, or across every feed the person subscribes to when `feed_id`
/// is None: the All Subscriptions view.
pub fn entries_in(
&self,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
offset: i64,
limit: i64,
) -> Result<Vec<EntryRow>> {
let conn = self.conn.lock().unwrap();
let like = search
@@ -679,9 +704,10 @@ impl Db {
FROM entries e
LEFT JOIN entry_state s
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = ?1 AND {} AND {SEARCH}
WHERE {} AND {} AND {SEARCH}
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
LIMIT ?4 OFFSET ?3",
scope_sql(feed_id, 5),
filter.sql()
);
let mut stmt = conn.prepare(&sql)?;
@@ -711,14 +737,14 @@ impl Db {
return Ok(rows);
}
// Only the guids on this page, so a feed with thousands of entries stays cheap.
// Only the guids on this page, so a feed with thousands of entries stays cheap. A page
// can span feeds, so each file is matched to its row by feed as well as guid, below.
let placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id"
FROM enclosures WHERE guid IN ({placeholders}) ORDER BY id"
);
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1);
params.push(&feed_id);
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len());
for row in &rows {
params.push(&row.guid);
}
@@ -740,7 +766,7 @@ impl Db {
.collect::<rusqlite::Result<Vec<_>>>()?;
for enc in encs {
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) {
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid && r.feed_id == enc.feed_id) {
row.enclosures.push(enc);
}
}
@@ -754,6 +780,17 @@ impl Db {
feed_id: &str,
filter: Filter,
search: Option<&str>,
) -> Result<i64> {
self.count_in(user_id, Some(feed_id), filter, search)
}
/// `count_entries` for one feed, or across every feed the person subscribes to.
pub fn count_in(
&self,
user_id: i64,
feed_id: Option<&str>,
filter: Filter,
search: Option<&str>,
) -> Result<i64> {
let conn = self.conn.lock().unwrap();
let like = search
@@ -763,7 +800,8 @@ impl Db {
"SELECT count(*) FROM entries e
LEFT JOIN entry_state s
ON s.user_id = ?3 AND s.feed_id = e.feed_id AND s.guid = e.guid
WHERE e.feed_id = ?1 AND {} AND {SEARCH}",
WHERE {} AND {} AND {SEARCH}",
scope_sql(feed_id, 3),
filter.sql()
);
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?)

View File

@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries", get(all_entries))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
.route("/api/feeds/{id}/read-all", post(read_all))
@@ -824,20 +825,37 @@ async fn entries(
Path(id): Path<String>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
entry_page(&state, user.id, Some(&id), &page)
}
/// Every subscribed feed's items together, newest first: All Subscriptions.
async fn all_entries(
State(state): State<WebState>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
entry_page(&state, user.id, None, &page)
}
/// One feed's page of items, or every subscribed feed's when `feed` is None.
fn entry_page(
state: &WebState,
user_id: i64,
feed: Option<&str>,
page: &Page,
) -> Result<Json<EntryPage>, ApiError> {
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let mut rows = state
.ctx
.db
.entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?;
let db = &state.ctx.db;
let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
}
}
let total = state.ctx.db.count_entries(user.id, &id, filter, search)?;
let total = db.count_in(user_id, feed, filter, search)?;
Ok(Json(EntryPage { total, entries: rows }))
}