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

@@ -12,16 +12,18 @@ The long form, with what was wrong before and how it was found, is in
### Added
- A Popular button at the top of the feed list opens the popular list without going through
Add feed.
- A Directory button lists every feed anyone on this server subscribes to, A to Z
(`GET /api/directory`), with the same rules as Popular.
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
Directory lists every feed anyone here subscribes to, A to Z (`GET /api/directory`). All
Subscriptions lists every item from every feed you subscribe to (`GET /api/entries`).
- Items show as a table (unread, kept, item, feed, file, published) with a Files pane beside it,
and a status bar with the totals.
### Changed
- Popular shows the top 10, not 20.
- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed,
and clicking one opens it.
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
marked Subscribed, and clicking one opens it.
## [0.3.0] - 2026-09-11

View File

@@ -105,6 +105,7 @@ else a `401`.
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
| `PATCH /api/feeds/{id}`, `DELETE /api/feeds/{id}` | your settings or (admin) the feed's; unsubscribe |
| `GET /api/feeds/{id}/entries` | paged, filtered, searchable |
| `GET /api/entries` | the same, across every feed you subscribe to (All Subscriptions) |
| `POST /api/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position |
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |

View File

@@ -6,6 +6,32 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is.
## 2026-09-11 — The original's layout
Ray pointed at a screenshot of the Mac app (techpp.com, 2012) and asked for its panes, its
toolbar, and a Directory that lives in the feed list rather than behind a button. What it had, and
what ipx now does:
- A toolbar across the window: subscribe and unsubscribe, play, flag, refresh, and a search box
scoped to the feed on show. ipx's has the same groups, acting on the selected feed and item, with
Settings and Log at the right end, for admins only.
- A source list opening with Directory, Playlist Builder and All Subscriptions above the feeds.
ipx has Directory, Popular and All Subscriptions there, opening in the main pane. Playlist
Builder is left out, since nothing here builds playlists.
- The entries as a table, with a Files pane beside it and the entry below. The columns are unread,
kept, the item, its feed, its file and when. Sorting by column is not done yet.
- A status bar with the totals for what is on show.
All Subscriptions needed one new endpoint, `GET /api/entries`. It is the per-feed query with
`feed_id = ?` swapped for the person's subscriptions. The enclosure lookup that follows it used to
filter by feed as well; a page can now span feeds, so each file is matched to its row by feed and
guid instead.
A phone has no room for a pane beside the table, so there the files follow the item's text in the
full-screen reader, and the table drops to title and date.
---
## 2026-09-11 — Popular on this server
The old iPodderX had a directory of podcasts and a top-feeds list. The open-sourced engine shows how

View File

@@ -70,9 +70,9 @@ list with their own read state. Unsubscribing removes it from their list alone;
subscriber leaves does the feed stop being scanned, and even then its files and history stay, so
re-subscribing does not pull the back catalogue again.
**Popular**, at the top of the feed list and in the Add feed dialog, lists the ten feeds with the
most subscribers on this server, you included. **Directory**, beside it, lists every one of them
A to Z. Your own feeds are marked Subscribed.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. Feeds from an
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the

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 }))
}

View File

@@ -15,7 +15,7 @@ const script = html.split('<script>')[1].split('</script>')[0];
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
const missing = [];
const el = (name) => new Proxy({ style: {}, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
@@ -49,7 +49,7 @@ const ctx = {
: /\/api\/(popular|directory)/.test(String(url))
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
: []),
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
}),
EventSource: function () { this.close = () => {}; },
MediaMetadata: function () {},
@@ -86,8 +86,9 @@ const drive = [
['prefsModal', () => ctx.prefsModal()],
['usersModal', () => ctx.usersModal()],
['opmlModal', () => ctx.opmlModal()],
['listedModal (popular)', () => ctx.listedModal('Popular', 'Top ten.', '/api/popular')],
['listedModal (directory)', () => ctx.listedModal('Directory', 'A to Z.', '/api/directory')],
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
['logsModal', () => ctx.logsModal()],
// `const S` is not reachable from here: top-level const/let do not become properties
// of a vm context the way var and function declarations do.

View File

@@ -69,20 +69,21 @@ test('the three panes are there and the item text lands in the bottom one', asyn
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
// The enclosure travels with the item, into the same pane.
await expect(page.locator('#detail .encbox')).toHaveCount(1);
// The enclosure goes to the Files pane beside the list, as the original's did.
await expect(page.locator('#files')).toBeVisible();
await expect(page.locator('#files .encbox')).toHaveCount(1);
// Only the downloaded one gets a player, and max_new_per_check is 1, so find it by
// its chip rather than assuming which episode the daemon happened to fetch.
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
await downloaded.click();
await expect(page.locator('#detail audio')).toBeVisible();
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
await expect(page.locator('#files audio')).toBeVisible();
await expect(page.locator('#files .encbox .btn', { hasText: 'Save' })).toBeVisible();
// Selecting another item replaces the pane rather than stacking.
await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
await expect(page.locator('#detail audio')).toHaveCount(0);
await expect(page.locator('#files audio')).toHaveCount(0);
});
test('a downloaded file that is not audio gets no player', async ({ page }) => {
@@ -96,12 +97,12 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => {
await row.click();
await expect(page.locator('#detail .dt')).toHaveText('An Article');
await expect(page.locator('#detail audio')).toHaveCount(0);
await expect(page.locator('#detail .encbox')).toContainText('image');
await expect(page.locator('#detail .encbox')).toContainText('downloaded');
await expect(page.locator('#files audio')).toHaveCount(0);
await expect(page.locator('#files .encbox')).toContainText('image');
await expect(page.locator('#files .encbox')).toContainText('downloaded');
// Still offered as a file, just not as an episode: viewable and keepable.
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
const view = page.locator('#detail a', { hasText: 'View' });
await expect(page.locator('#files .btn', { hasText: 'Save' })).toBeVisible();
const view = page.locator('#files a', { hasText: 'View' });
await expect(view).toHaveAttribute('target', '_blank');
await expect(view).toHaveAttribute('rel', /noopener/);
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
@@ -115,9 +116,9 @@ test('an item with several enclosures lists them all', async ({ page }) => {
await expect(row).toContainText('+1 more file');
await row.click();
// The pane below lists every one: the audio and the image.
await expect(page.locator('#detail .encbox')).toHaveCount(2);
await expect(page.locator('#detail .encbox').nth(1)).toContainText('image');
// The Files pane lists every one: the audio and the image.
await expect(page.locator('#files .encbox')).toHaveCount(2);
await expect(page.locator('#files .encbox').nth(1)).toContainText('image');
});
test('the filter tabs change what is listed', async ({ page }) => {
@@ -291,6 +292,30 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
expect(errors).toEqual([]);
});
test('the toolbar acts on the selected item', async ({ page }) => {
await page.getByText('Test Show').click();
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 });
// Nothing selected, nothing to act on.
await expect(page.locator('#tbRead')).toBeDisabled();
await row().click(); // opening it reads it
await expect(row()).toHaveClass(/read/);
await page.locator('#tbRead').click();
await expect(row()).not.toHaveClass(/read/);
await page.locator('#tbFlag').click();
await expect(row().locator('.fl')).toHaveClass(/on/);
await page.locator('#tbFlag').click(); // and back, so later tests see it unkept
await expect(row().locator('.fl')).not.toHaveClass(/on/);
// Second Episode is the one the daemon downloaded, so it plays from the toolbar.
await expect(page.locator('#tbPlay')).toBeEnabled();
await page.locator('#tbPlay').click();
await expect(page.locator('#player')).toBeVisible();
await page.locator('#pclose').click();
});
test('a second person has their own feeds and their own read state', async ({ browser }) => {
const { execFileSync } = require('child_process');
const setup = require('./global-setup');
@@ -350,7 +375,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
await expect(row).toBeVisible({ timeout: 20_000 });
await row.click();
const del = page.locator('#detail button', { hasText: 'Delete' });
const del = page.locator('#files button', { hasText: 'Delete' });
await expect(del).toHaveText('Delete for everyone');
// Two prompts: the page's own, then the server's, because someone else has not played
@@ -566,7 +591,7 @@ test('Popular lists what everyone here reads, but never a private feed', async (
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
await piper.locator('#popularFeeds').click();
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
const offered = piper.locator('#popular .childrow');
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
@@ -589,8 +614,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
expect(dir.map(p => p.id)).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows.
await piper.keyboard.press('Escape');
await piper.locator('#directoryFeeds').click();
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
@@ -603,7 +628,16 @@ test('Popular lists what everyone here reads, but never a private feed', async (
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
await piper.locator('#popularFeeds').click();
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
await expect(offered.filter({ hasText: 'Test Show' })).toContainText('Subscribed');
// All Subscriptions is every item from piper's feeds and only those: the admin's Picture
// Blog is not among them.
await piper.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
const first = piper.locator('.ep', { hasText: 'First Episode' });
await expect(first).toBeVisible({ timeout: 20_000 });
await expect(first.locator('.fd')).toHaveText('Test Show');
await expect(piper.locator('.ep', { hasText: 'An Article' })).toHaveCount(0);
await expect(piper.locator('#count')).toContainText('All Subscriptions:');
await ctx.close();
});

File diff suppressed because one or more lines are too long