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:
16
CHANGELOG.md
16
CHANGELOG.md
@@ -12,16 +12,18 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- A Popular button at the top of the feed list opens the popular list without going through
|
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
|
||||||
Add feed.
|
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
|
||||||
- A Directory button lists every feed anyone on this server subscribes to, A to Z
|
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
|
||||||
(`GET /api/directory`), with the same rules as Popular.
|
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
|
### Changed
|
||||||
|
|
||||||
- Popular shows the top 10, not 20.
|
- Popular shows the top 10, not 20, and counts everyone, you included. Your own feeds stay on it,
|
||||||
- The popular list counts everyone, you included. Your own feeds stay on it, marked Subscribed,
|
marked Subscribed, and clicking one opens it.
|
||||||
and clicking one opens it.
|
|
||||||
|
|
||||||
## [0.3.0] - 2026-09-11
|
## [0.3.0] - 2026-09-11
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ else a `401`.
|
|||||||
| `GET /api/feeds`, `POST /api/feeds` | your subscriptions; subscribe |
|
| `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 |
|
| `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/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/feeds/{id}/read-all`, `POST /api/feeds/{id}/download-latest` | |
|
||||||
| `POST /api/entries/{feed}/{guid}/flags`, `…/position` | your read, starred, position |
|
| `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 |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
|
|||||||
@@ -6,6 +6,32 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
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
|
## 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
|
The old iPodderX had a directory of podcasts and a top-feeds list. The open-sourced engine shows how
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
||||||
most subscribers on this server, you included. **Directory**, beside it, lists every one of them
|
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
||||||
A to Z. Your own feeds are marked Subscribed.
|
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
|
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
|
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
|
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the
|
||||||
|
|||||||
52
src/db.rs
52
src/db.rs
@@ -609,6 +609,17 @@ pub struct EntryRow {
|
|||||||
const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2
|
const SEARCH: &str = "(?2 = '' OR lower(coalesce(e.title, '')) LIKE ?2
|
||||||
OR lower(coalesce(e.description, '')) 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.
|
/// Which slice of a feed the UI is asking for.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Filter {
|
pub enum Filter {
|
||||||
@@ -667,6 +678,20 @@ impl Db {
|
|||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
limit: 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>> {
|
) -> Result<Vec<EntryRow>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let like = search
|
let like = search
|
||||||
@@ -679,9 +704,10 @@ impl Db {
|
|||||||
FROM entries e
|
FROM entries e
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
ON s.user_id = ?5 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
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
|
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||||
LIMIT ?4 OFFSET ?3",
|
LIMIT ?4 OFFSET ?3",
|
||||||
|
scope_sql(feed_id, 5),
|
||||||
filter.sql()
|
filter.sql()
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
@@ -711,14 +737,14 @@ impl Db {
|
|||||||
return Ok(rows);
|
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 placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
|
"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);
|
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len());
|
||||||
params.push(&feed_id);
|
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
params.push(&row.guid);
|
params.push(&row.guid);
|
||||||
}
|
}
|
||||||
@@ -740,7 +766,7 @@ impl Db {
|
|||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
for enc in encs {
|
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);
|
row.enclosures.push(enc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,6 +780,17 @@ impl Db {
|
|||||||
feed_id: &str,
|
feed_id: &str,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
search: Option<&str>,
|
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> {
|
) -> Result<i64> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let like = search
|
let like = search
|
||||||
@@ -763,7 +800,8 @@ impl Db {
|
|||||||
"SELECT count(*) FROM entries e
|
"SELECT count(*) FROM entries e
|
||||||
LEFT JOIN entry_state s
|
LEFT JOIN entry_state s
|
||||||
ON s.user_id = ?3 AND s.feed_id = e.feed_id AND s.guid = e.guid
|
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()
|
filter.sql()
|
||||||
);
|
);
|
||||||
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?)
|
Ok(conn.query_row(&sql, rusqlite::params![feed_id, like, user_id], |r| r.get(0))?)
|
||||||
|
|||||||
28
src/web.rs
28
src/web.rs
@@ -65,6 +65,7 @@ pub fn router(state: WebState) -> Router {
|
|||||||
.route("/api/feeds", get(feeds).post(add_feed))
|
.route("/api/feeds", get(feeds).post(add_feed))
|
||||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||||
.route("/api/feeds/{id}/entries", get(entries))
|
.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}/flags", post(set_flags))
|
||||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||||
.route("/api/feeds/{id}/read-all", post(read_all))
|
.route("/api/feeds/{id}/read-all", post(read_all))
|
||||||
@@ -824,20 +825,37 @@ async fn entries(
|
|||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Query(page): Query<Page>,
|
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> {
|
) -> Result<Json<EntryPage>, ApiError> {
|
||||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
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 search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||||
let mut rows = state
|
let db = &state.ctx.db;
|
||||||
.ctx
|
let mut rows = db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
||||||
.db
|
|
||||||
.entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?;
|
|
||||||
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
|
||||||
for row in &mut rows {
|
for row in &mut rows {
|
||||||
if let Some(d) = &row.description {
|
if let Some(d) = &row.description {
|
||||||
row.description = Some(ammonia::clean(d));
|
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 }))
|
Ok(Json(EntryPage { total, entries: rows }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
||||||
|
|
||||||
const missing = [];
|
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,
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
@@ -49,7 +49,7 @@ const ctx = {
|
|||||||
: /\/api\/(popular|directory)/.test(String(url))
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
: []),
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
}),
|
}),
|
||||||
EventSource: function () { this.close = () => {}; },
|
EventSource: function () { this.close = () => {}; },
|
||||||
MediaMetadata: function () {},
|
MediaMetadata: function () {},
|
||||||
@@ -86,8 +86,9 @@ const drive = [
|
|||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
['usersModal', () => ctx.usersModal()],
|
['usersModal', () => ctx.usersModal()],
|
||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['listedModal (popular)', () => ctx.listedModal('Popular', 'Top ten.', '/api/popular')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['listedModal (directory)', () => ctx.listedModal('Directory', 'A to Z.', '/api/directory')],
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||||
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['logsModal', () => ctx.logsModal()],
|
['logsModal', () => ctx.logsModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `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.
|
// of a vm context the way var and function declarations do.
|
||||||
|
|||||||
@@ -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 page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
||||||
// The enclosure travels with the item, into the same pane.
|
// The enclosure goes to the Files pane beside the list, as the original's did.
|
||||||
await expect(page.locator('#detail .encbox')).toHaveCount(1);
|
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
|
// 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.
|
// its chip rather than assuming which episode the daemon happened to fetch.
|
||||||
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
const downloaded = page.locator('.ep', { hasText: 'downloaded' }).first();
|
||||||
await downloaded.click();
|
await downloaded.click();
|
||||||
await expect(page.locator('#detail audio')).toBeVisible();
|
await expect(page.locator('#files audio')).toBeVisible();
|
||||||
await expect(page.locator('#detail .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
await expect(page.locator('#files .encbox .btn', { hasText: 'Save' })).toBeVisible();
|
||||||
|
|
||||||
// Selecting another item replaces the pane rather than stacking.
|
// Selecting another item replaces the pane rather than stacking.
|
||||||
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
await page.locator('.ep', { hasText: 'First Episode' }).click();
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('First Episode');
|
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 }) => {
|
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 row.click();
|
||||||
|
|
||||||
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
||||||
await expect(page.locator('#detail audio')).toHaveCount(0);
|
await expect(page.locator('#files audio')).toHaveCount(0);
|
||||||
await expect(page.locator('#detail .encbox')).toContainText('image');
|
await expect(page.locator('#files .encbox')).toContainText('image');
|
||||||
await expect(page.locator('#detail .encbox')).toContainText('downloaded');
|
await expect(page.locator('#files .encbox')).toContainText('downloaded');
|
||||||
// Still offered as a file, just not as an episode: viewable and keepable.
|
// Still offered as a file, just not as an episode: viewable and keepable.
|
||||||
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
|
await expect(page.locator('#files .btn', { hasText: 'Save' })).toBeVisible();
|
||||||
const view = page.locator('#detail a', { hasText: 'View' });
|
const view = page.locator('#files a', { hasText: 'View' });
|
||||||
await expect(view).toHaveAttribute('target', '_blank');
|
await expect(view).toHaveAttribute('target', '_blank');
|
||||||
await expect(view).toHaveAttribute('rel', /noopener/);
|
await expect(view).toHaveAttribute('rel', /noopener/);
|
||||||
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
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 expect(row).toContainText('+1 more file');
|
||||||
|
|
||||||
await row.click();
|
await row.click();
|
||||||
// The pane below lists every one: the audio and the image.
|
// The Files pane lists every one: the audio and the image.
|
||||||
await expect(page.locator('#detail .encbox')).toHaveCount(2);
|
await expect(page.locator('#files .encbox')).toHaveCount(2);
|
||||||
await expect(page.locator('#detail .encbox').nth(1)).toContainText('image');
|
await expect(page.locator('#files .encbox').nth(1)).toContainText('image');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the filter tabs change what is listed', async ({ page }) => {
|
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([]);
|
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 }) => {
|
test('a second person has their own feeds and their own read state', async ({ browser }) => {
|
||||||
const { execFileSync } = require('child_process');
|
const { execFileSync } = require('child_process');
|
||||||
const setup = require('./global-setup');
|
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 expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
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');
|
await expect(del).toHaveText('Delete for everyone');
|
||||||
|
|
||||||
// Two prompts: the page's own, then the server's, because someone else has not played
|
// 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 piper.locator('button[type=submit]').click();
|
||||||
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
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');
|
const offered = piper.locator('#popular .childrow');
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
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.
|
// 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');
|
expect(dir.map(p => p.id)).not.toContain('paid-show');
|
||||||
|
|
||||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
// Subscribe from the directory this time; the popular list shares the same rows.
|
||||||
await piper.keyboard.press('Escape');
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await piper.locator('#directoryFeeds').click();
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
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.
|
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||||
expect(await row()).toMatchObject({ subscribed: true, subscribers: before.subscribers + 1 });
|
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');
|
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();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|||||||
386
web/index.html
386
web/index.html
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user