Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8123250b | |||
| 2c34a144ba | |||
| 669e8b5124 | |||
| 5362436766 | |||
| 0efc49519c | |||
| 57dcba2d1a | |||
| e95cccc66f | |||
| 7295be8b25 | |||
| f1b0d97b81 | |||
| ae47e31a97 | |||
| 8a309eb652 | |||
| 1a2b0d87c6 | |||
| d7fac2d0e7 | |||
| df9b7645d6 | |||
| f3825cfc57 | |||
| c0f4b0bcb2 |
69
CHANGELOG.md
69
CHANGELOG.md
@@ -10,6 +10,72 @@ The long form, with what was wrong before and how it was found, is in
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2026-09-11
|
||||
|
||||
### Added
|
||||
|
||||
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
|
||||
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
|
||||
through all three and remembers the choice.
|
||||
- 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, title, feed, file, size, published) with a Files pane beside
|
||||
it, and a status bar with the totals.
|
||||
- Mark everything read from All Subscriptions, across every feed you subscribe to
|
||||
(`POST /api/read-all`). It asks first. All Subscriptions can also check every feed from its header.
|
||||
- Click a column heading in the item table to sort by it (kept, title, feed, file type, size,
|
||||
published); click again to reverse. The server sorts, so it covers the whole list, not just the
|
||||
fifty shown, and the choice is remembered.
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
- Adding a feed scans it straight away, and an OPML import that added feeds scans them, so their
|
||||
items show without pressing Scan.
|
||||
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
||||
label, just the Download button. The retention summary says "deleted", not "reaped".
|
||||
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
|
||||
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
|
||||
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
|
||||
Download latest dialogs (save, download, cancel). The icons are Font Awesome Free, embedded as
|
||||
SVG: only the ones used, no font to download, and nothing fetched from anyone else. They
|
||||
replace font characters such as ⟳ ⤓ ↗, which came out thin and tiny and differed from font to
|
||||
font. Keep is a flag everywhere, as it was in the original, and mark unread is an envelope.
|
||||
- One meaning per icon. Minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||
dialog's confirm button carries the icon of what it does. The feed header's unsubscribe was an x
|
||||
and read as closing the page. The remaining word buttons are icons too:
|
||||
- Log, Add feed, Users, Unsubscribe and OPML.
|
||||
- Popular's Subscribe, Copy and Sign out.
|
||||
- The Subscribed label in Popular, the Directory and Add feed, which is now a green check.
|
||||
- The player's back, play, forward and close, which were font characters, and the folder arrow.
|
||||
- The toolbar's read and keep buttons show the selected item's state, with the same icons as the
|
||||
item's own buttons. Play, read and keep sit together, and Scan sits with add and unsubscribe.
|
||||
- An OPML subscription's page has the same header as a feed's, with its buttons in the same places.
|
||||
- The item table's size has its own column, apart from the file's type, and shows KB for small
|
||||
files instead of "0 MB". The Item heading is now Title.
|
||||
- A file's type is an icon (audio, video, image, PDF, torrent, other), green once it is
|
||||
downloaded and red when the download failed, with the details in its tooltip. One icon per
|
||||
row keeps the column lined up. The DOWNLOADED and PENDING labels are gone.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Playing a file from the Files pane played it twice at once, in the pane and in the player bar.
|
||||
The pane has a play button now, and the player bar is the only player.
|
||||
- The password box in Manage users was white in the dark theme.
|
||||
- An item with no date showed a stray dot in its details.
|
||||
- Escape did not close a dialog while the cursor was in one of its boxes, so Add feed, which opens
|
||||
in its URL box, could not be closed with Escape.
|
||||
|
||||
### Security
|
||||
|
||||
- Feeds from paid-feed services (Patreon, Supercast, Supporting Cast, Glow, Memberful) are never
|
||||
listed in Popular or the Directory. A Supercast feed, which keeps its key in the URL's path
|
||||
rather than the query, was being listed.
|
||||
|
||||
## [0.3.0] - 2026-09-11
|
||||
|
||||
### Added
|
||||
@@ -133,7 +199,8 @@ The long form, with what was wrong before and how it was found, is in
|
||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...main
|
||||
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
|
||||
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
||||
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -30,6 +30,20 @@ docker logs --tail 20 iPodderX
|
||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||
included. Run `pull` before `up`, because `up` reuses whatever `latest` the host already has.
|
||||
|
||||
**A build that fails with `429 Too Many Requests` on a base image** is Docker Hub rate-limiting
|
||||
this host. There is no Docker Hub login here, and the build asks about `debian:bookworm-slim` and
|
||||
`rust:1-slim-bookworm` every time unless they are already stored locally. Pull them from Google's
|
||||
mirror and tag them; the build then uses the local copies without asking Docker Hub:
|
||||
|
||||
```sh
|
||||
docker pull mirror.gcr.io/library/debian:bookworm-slim
|
||||
docker tag mirror.gcr.io/library/debian:bookworm-slim debian:bookworm-slim
|
||||
docker pull mirror.gcr.io/library/rust:1-slim-bookworm
|
||||
docker tag mirror.gcr.io/library/rust:1-slim-bookworm rust:1-slim-bookworm
|
||||
```
|
||||
|
||||
Run those again now and then, or the local copies go stale.
|
||||
|
||||
The healthcheck runs `ipx status` against the control socket, so `(healthy)` in `docker ps` means
|
||||
the worker is alive, not just the web port. The container restarts on its own after a reboot.
|
||||
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1634,7 +1634,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -118,3 +118,6 @@ npx playwright test # a real browser against a real daemon
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
The icons are [Font Awesome Free](https://fontawesome.com) 7.3.1 by @fontawesome, under
|
||||
[CC BY 4.0](https://fontawesome.com/license/free), embedded as SVG in `web/index.html`.
|
||||
|
||||
@@ -104,13 +104,15 @@ else a `401`.
|
||||
| `GET /login`, `POST /api/login`, `POST /api/logout`, `GET /api/me` | sign-in |
|
||||
| `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/feeds/{id}/entries` | paged, filtered, searchable, sortable (`sort` = kept, title, feed, type, size or published; `dir` = asc or desc) |
|
||||
| `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/read-all` | everything read in every feed you subscribe to (All Subscriptions) |
|
||||
| `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/fetch` | |
|
||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||
| `GET /api/popular`, `POST /api/popular/{id}` | what others here subscribe to (id, title, art, count; never a URL, never a private feed); subscribe by id |
|
||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
|
||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||
|
||||
@@ -6,6 +6,87 @@ reasoning lives. New write-ups go at the top.
|
||||
|
||||
See [README.md](../README.md) for what the thing is.
|
||||
|
||||
## 2026-09-11 — One meaning per icon, sortable columns, and one player
|
||||
|
||||
Ray asked for a pass over the whole UI: consistent icons, and buttons placed next to what they act
|
||||
on. The pass started from screenshots of every view and dialog, taken against the browser suite's
|
||||
own daemon and fixtures. Several things did not agree:
|
||||
|
||||
- Unsubscribe was a minus in the toolbar but an x in the feed header. There it read as "close this
|
||||
page", and x also meant Cancel in every dialog.
|
||||
- Half the dialogs still had word buttons: Log, Add feed, Users, Unsubscribe, OPML, Popular's
|
||||
Subscribe, the Copy buttons and Sign out.
|
||||
- The player bar and the folder arrow were font characters, and so was the row's "playing" marker.
|
||||
Those came out thin and varied from font to font.
|
||||
- The OPML subscription page laid out its header differently from a feed's, and All Subscriptions
|
||||
had no buttons at all.
|
||||
|
||||
The rule now is one meaning per icon. Minus unsubscribes, x closes or cancels, plus adds, subscribes
|
||||
or imports, and a dialog's confirm button carries the icon of what it does. The words go in the
|
||||
tooltip. The toolbar is grouped by what it acts on: add, unsubscribe and scan act on feeds; play,
|
||||
read and keep act on the selected item. Its read and keep buttons now show that item's state, as
|
||||
the item's own buttons already did. A browser test opens all eight dialogs and fails on any button
|
||||
without an icon and a tooltip. It also checks that the toolbar and the page use the same icon for
|
||||
the same action.
|
||||
|
||||
The pass turned up three bugs:
|
||||
|
||||
- **Escape ignored inside a dialog's text box.** The keydown handler returned early for any key
|
||||
typed in a text box, and Escape sat below that check. So Add feed, which opens with the cursor in
|
||||
its URL box, could not be closed with Escape. It showed up because the screenshot run stalled:
|
||||
the dialog stayed open, and every later click landed on its overlay until the run timed out.
|
||||
- **A white password box in the dark theme.** The input rule listed text, search and number
|
||||
fields, but not password ones.
|
||||
- **A stray "• •" under an item with no date.** Each detail carried its own trailing dot, whether
|
||||
or not the next one was there.
|
||||
|
||||
All Subscriptions gained Mark everything read, as `POST /api/read-all`. It marks the feeds from the
|
||||
person's subscriptions, which is the same set the view's query is scoped by. It asks first, since
|
||||
one click covers every feed.
|
||||
|
||||
**Sorting** happens on the server. A list arrives fifty at a time, so sorting in the page would
|
||||
only have reordered what was loaded. The column name picks one of a fixed set of SQL expressions
|
||||
(`order_sql`), so nothing the browser sends reaches the query, and an unknown name means newest
|
||||
first. Ties also fall back to newest first, so "Load more" does not skip or repeat items at a page
|
||||
boundary. A database test runs every column both ways. Size came out of the File column into its
|
||||
own, and small files show KB, since whole megabytes made every article image "0 MB".
|
||||
|
||||
**The double play.** Ray found that pressing play in the Files pane played the file twice at once.
|
||||
The pane drew its own `<audio controls>`, and its `onplay` handler also started the player bar on
|
||||
the same file. That meant two audio elements from one click. The pane now has a play button that
|
||||
hands that exact file to the player bar, the only player. `play()` now takes the file. Its check
|
||||
for resuming compares the file as well as the item, so another of an item's files starts from its
|
||||
beginning instead of carrying on with the first. A test plays from the pane and checks the page
|
||||
has one `<audio>` element.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -26,6 +107,13 @@ with everything else, so this is built around not doing that:
|
||||
the URL, or a query key containing `auth`, `token`, `key`, `secret`, `pass`, `sig`, `session`,
|
||||
`user` or `uid`. It is a heuristic, and a token hidden in the URL's path gets through. A per-feed
|
||||
`unlisted` flag is the upgrade if that happens.
|
||||
|
||||
It happened the same day. The first screenshot of the new Directory listed "Glass Cannon Live!
|
||||
Ascension (for Ray Slakinski)", a Supercast feed at `feeds.supercast.com/feeds/<key>`. Treating
|
||||
any long path segment as a key would have hidden public feeds too: acast's show ids look the
|
||||
same. So paid-feed services are named instead (Patreon, Supercast, Supporting Cast, Glow,
|
||||
Memberful), and any feed from one of them is private whatever its URL looks like. The per-feed
|
||||
flag is still the upgrade for a service not on that list.
|
||||
- Feeds from an OPML are left out. Everyone subscribed to an OPML counts every feed inside it, so
|
||||
they would bury everything anyone chose on purpose.
|
||||
|
||||
|
||||
@@ -70,11 +70,14 @@ 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.
|
||||
|
||||
**Add feed** also lists what other people on this server subscribe to, most subscribers first, as
|
||||
a place to start. It shows a title, artwork and a count, never a URL or who reads it. Feeds from an
|
||||
**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
|
||||
query. Those are someone's paid subscriptions, and listing them would let anyone here read what they
|
||||
query, or a feed from a paid-feed service such as Patreon or Supercast, which put the key in the
|
||||
path. Those are someone's paid subscriptions, and listing them would let anyone here read what they
|
||||
pay for.
|
||||
|
||||
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
|
||||
|
||||
108
src/db.rs
108
src/db.rs
@@ -609,6 +609,36 @@ 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})"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The item table's ORDER BY. The column name picks one of these fixed expressions, so nothing
|
||||
/// the caller sends reaches the query, and anything unrecognised is newest first. Ties fall back
|
||||
/// to newest first too, so a page boundary is stable across "Load more".
|
||||
///
|
||||
/// ponytail: file type and size look at the item's first and largest file. The row shows the file
|
||||
/// it summarises, which is almost always that one; sort by that one if they ever disagree.
|
||||
pub fn order_sql(col: &str, dir: &str) -> String {
|
||||
let expr = match col {
|
||||
"kept" => "coalesce(s.flagged, 0)",
|
||||
"title" => "lower(coalesce(e.title, ''))",
|
||||
"feed" => "(SELECT lower(coalesce(f.title, f.id)) FROM feeds f WHERE f.id = e.feed_id)",
|
||||
"type" => "(SELECT min(x.mime) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)",
|
||||
"size" => "(SELECT max(x.length) FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid)",
|
||||
_ => "coalesce(e.published, e.first_seen)",
|
||||
};
|
||||
let dir = if dir == "asc" { "ASC" } else { "DESC" };
|
||||
format!("{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.rowid DESC")
|
||||
}
|
||||
|
||||
/// Which slice of a feed the UI is asking for.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Filter {
|
||||
@@ -667,6 +697,21 @@ impl Db {
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_in(user_id, Some(feed_id), filter, search, offset, limit, &order_sql("published", "desc"))
|
||||
}
|
||||
|
||||
/// `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,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let like = search
|
||||
@@ -679,9 +724,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}
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.rowid DESC
|
||||
WHERE {} AND {} AND {SEARCH}
|
||||
ORDER BY {order}
|
||||
LIMIT ?4 OFFSET ?3",
|
||||
scope_sql(feed_id, 5),
|
||||
filter.sql()
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
@@ -711,14 +757,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 +786,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 +800,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 +820,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))?)
|
||||
@@ -1319,6 +1377,40 @@ pub fn now() -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
||||
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0);
|
||||
INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
|
||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, mime, length, state) VALUES
|
||||
(1,'f','a','u1','audio/mpeg',300,'pending'),(2,'g','b','u2','image/png',10,'pending'),
|
||||
(3,'f','c','u3','video/mp4',2000,'pending');",
|
||||
)
|
||||
.unwrap();
|
||||
let order = |col: &str, dir: &str| -> Vec<String> {
|
||||
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|e| e.guid)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(order("title", "asc"), ["b", "a", "c"], "Apple, banana, cherry: case folded");
|
||||
assert_eq!(order("title", "desc"), ["c", "a", "b"]);
|
||||
assert_eq!(order("feed", "asc"), ["b", "c", "a"], "Aardvark, then Zebra's newest first");
|
||||
assert_eq!(order("type", "asc"), ["a", "b", "c"], "audio, image, video");
|
||||
assert_eq!(order("size", "desc"), ["c", "a", "b"]);
|
||||
assert_eq!(order("published", "desc"), ["c", "b", "a"]);
|
||||
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).unwrap();
|
||||
assert_eq!(order("kept", "desc")[0], "a");
|
||||
// An unknown column or direction is newest first; the name itself never reaches the SQL.
|
||||
assert_eq!(order("title; DROP TABLE entries", "sideways"), ["c", "b", "a"]);
|
||||
assert!(!order_sql("x'; --", "asc").contains("x'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
@@ -62,10 +62,10 @@ impl Event {
|
||||
Event::DownloadDone { path, .. } => format!(" saved {path}"),
|
||||
Event::DownloadError { url, msg, .. } => format!(" failed {url}: {msg}"),
|
||||
Event::Reaped { path, bytes } => {
|
||||
format!("reap {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
format!("deleted {path} ({:.1} MB)", *bytes as f64 / 1_048_576.0)
|
||||
}
|
||||
Event::ReapDone { files, bytes } => format!(
|
||||
"reaped {files} file(s), {:.1} MB",
|
||||
"deleted {files} old file(s), {:.1} MB",
|
||||
*bytes as f64 / 1_048_576.0
|
||||
),
|
||||
Event::Status { feeds, pending, downloaded } => {
|
||||
|
||||
120
src/web.rs
120
src/web.rs
@@ -65,9 +65,11 @@ 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))
|
||||
.route("/api/read-all", post(read_all_mine))
|
||||
.route("/api/feeds/{id}/download-latest", post(download_latest))
|
||||
.route("/api/enclosures/{id}/download", post(download_now))
|
||||
.route("/api/enclosures/{id}", delete(delete_file))
|
||||
@@ -75,6 +77,7 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/opml", get(export_opml).post(import_opml))
|
||||
.route("/api/settings", get(get_settings).patch(patch_settings))
|
||||
.route("/api/popular", get(get_popular))
|
||||
.route("/api/directory", get(get_directory))
|
||||
.route("/api/popular/{id}", post(subscribe_popular))
|
||||
.route("/api/users", get(list_users).post(add_user))
|
||||
.route("/api/users/{id}", patch(patch_user).delete(remove_user))
|
||||
@@ -537,14 +540,20 @@ async fn feeds(
|
||||
/// A feed that carries a credential is someone's paid or private subscription. Listing it
|
||||
/// would let anyone signed in subscribe to it and read what they pay for.
|
||||
///
|
||||
/// ponytail: a heuristic. A token hidden in the URL's path gets through; a per-feed
|
||||
/// `unlisted` flag is the upgrade if that ever happens.
|
||||
/// ponytail: a heuristic. A key hidden in the path of a host not in `PAID_HOSTS` gets
|
||||
/// through; a per-feed `unlisted` flag is the upgrade if that happens again.
|
||||
fn looks_private(feed: &crate::config::Feed) -> bool {
|
||||
if feed.username.is_some() || feed.password.is_some() || feed.password_env.is_some() {
|
||||
return true;
|
||||
}
|
||||
let Ok(u) = url::Url::parse(&feed.url) else { return true };
|
||||
!u.username().is_empty()
|
||||
// Paid-feed services put the subscriber's key in the path as often as in the query, and a
|
||||
// long path segment alone proves nothing (acast's public show ids look the same). So a
|
||||
// feed from one of them is private whatever its URL looks like. Supercast is why this
|
||||
// exists: `feeds.supercast.com/feeds/<key>` reached the directory before it did.
|
||||
let host = u.host_str().unwrap_or("");
|
||||
PAID_HOSTS.iter().any(|h| host == *h || host.ends_with(&format!(".{h}")))
|
||||
|| !u.username().is_empty()
|
||||
|| u.password().is_some()
|
||||
|| u.query_pairs().any(|(k, _)| {
|
||||
let k = k.to_ascii_lowercase();
|
||||
@@ -554,6 +563,10 @@ fn looks_private(feed: &crate::config::Feed) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Services whose feeds are always one subscriber's own.
|
||||
const PAID_HOSTS: &[&str] =
|
||||
&["patreon.com", "supercast.com", "supportingcast.fm", "glow.fm", "memberful.com"];
|
||||
|
||||
/// Only an id, a title, artwork and a count: never a URL, which is where a key would be.
|
||||
#[derive(Serialize)]
|
||||
struct PopularRow {
|
||||
@@ -561,10 +574,13 @@ struct PopularRow {
|
||||
title: Option<String>,
|
||||
image: Option<String>,
|
||||
subscribers: i64,
|
||||
/// Yours already. Everyone counts, you included, so your own feeds are listed too.
|
||||
subscribed: bool,
|
||||
}
|
||||
|
||||
/// What other people here subscribe to that you don't, most subscribers first. What the
|
||||
/// Add feed screen offers, and all that `subscribe_popular` will subscribe you to.
|
||||
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
||||
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||
/// `subscribe_popular` will subscribe you to.
|
||||
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
let db = &state.ctx.db;
|
||||
let mine: std::collections::HashSet<String> =
|
||||
@@ -576,14 +592,14 @@ fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed
|
||||
// inside, which would bury everything anyone chose on purpose.
|
||||
let from_opml = s.managed || s.cfg.group.is_some();
|
||||
if n == 0 || from_opml || mine.contains(&s.id) || looks_private(&s.cfg) {
|
||||
if n == 0 || from_opml || looks_private(&s.cfg) {
|
||||
continue;
|
||||
}
|
||||
let sum = db.feed_summary(&s.id)?;
|
||||
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n });
|
||||
let subscribed = mine.contains(&s.id);
|
||||
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed });
|
||||
}
|
||||
let name = |p: &PopularRow| p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase();
|
||||
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| name(a).cmp(&name(b))));
|
||||
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -592,10 +608,24 @@ async fn get_popular(
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id)?;
|
||||
rows.truncate(20);
|
||||
rows.truncate(10);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// Every feed that may be listed, A to Z.
|
||||
async fn get_directory(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||
let mut rows = popular(&state, user.id)?;
|
||||
rows.sort_by_key(sort_name);
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
fn sort_name(p: &PopularRow) -> String {
|
||||
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
|
||||
}
|
||||
|
||||
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
|
||||
/// a guessed id cannot reach a private feed.
|
||||
async fn subscribe_popular(
|
||||
@@ -695,6 +725,11 @@ mod tests {
|
||||
// Patreon's shape: the key is a query parameter.
|
||||
assert!(looks_private(&f("https://www.patreon.com/rss/x?auth=abc123&show=2073588")));
|
||||
assert!(looks_private(&f("https://example.com/rss?api_key=abc")));
|
||||
// Paid-feed services put the key in the path; the host gives them away.
|
||||
assert!(looks_private(&f("https://feeds.supercast.com/feeds/abcdefghijklmnopqrstuvwx")));
|
||||
assert!(looks_private(&f("https://someshow.supportingcast.fm/content/abc123.rss")));
|
||||
// A long path segment alone is not a key: acast's public show ids look the same.
|
||||
assert!(!looks_private(&f("https://feeds.acast.com/public/shows/0123456789abcdef01234567")));
|
||||
assert!(looks_private(&f("https://ray:hunter2@example.com/rss")));
|
||||
assert!(looks_private(&f("not a url")), "unparseable is not safe to list");
|
||||
let mut basic = f("https://example.com/rss");
|
||||
@@ -789,6 +824,12 @@ struct Page {
|
||||
filter: Option<String>,
|
||||
#[serde(default)]
|
||||
q: Option<String>,
|
||||
/// A column name and asc or desc. Anything unrecognised is newest first: the name picks a
|
||||
/// fixed expression in the query and never reaches it itself.
|
||||
#[serde(default)]
|
||||
sort: Option<String>,
|
||||
#[serde(default)]
|
||||
dir: Option<String>,
|
||||
}
|
||||
|
||||
fn fifty() -> i64 {
|
||||
@@ -806,20 +847,42 @@ 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 order = crate::db::order_sql(
|
||||
page.sort.as_deref().unwrap_or("published"),
|
||||
page.dir.as_deref().unwrap_or("desc"),
|
||||
);
|
||||
let mut rows =
|
||||
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?;
|
||||
// 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 }))
|
||||
}
|
||||
|
||||
@@ -846,6 +909,7 @@ async fn add_feed(
|
||||
{
|
||||
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
|
||||
state.ctx.db.subscribe(user.id, &existing.id)?;
|
||||
scan_soon(&state, Some(existing.id.clone())).await;
|
||||
return Ok(Json(
|
||||
serde_json::json!({ "id": existing.id, "existing": already }),
|
||||
));
|
||||
@@ -854,9 +918,20 @@ async fn add_feed(
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
state.ctx.db.subscribe(user.id, &id)?;
|
||||
scan_soon(&state, Some(id.clone())).await;
|
||||
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
|
||||
}
|
||||
|
||||
/// Queues a scan, so a feed just added shows its items without anyone pressing Scan now.
|
||||
/// `None` scans whatever is due, which a feed never checked always is. The add has
|
||||
/// succeeded either way, so a daemon not taking commands is only logged.
|
||||
async fn scan_soon(state: &WebState, feed: Option<String>) {
|
||||
let force = feed.is_some();
|
||||
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() {
|
||||
tracing::warn!("could not queue a scan: the daemon is not accepting commands");
|
||||
}
|
||||
}
|
||||
|
||||
/// Absent means "leave alone"; JSON `null` means "clear this".
|
||||
///
|
||||
/// That distinction needs `double_option`: serde maps `null` onto the *outer* `None` for a
|
||||
@@ -1197,6 +1272,18 @@ async fn read_all(
|
||||
Ok(Json(serde_json::json!({ "marked": n })))
|
||||
}
|
||||
|
||||
/// Everything read in every feed you subscribe to: exactly what All Subscriptions lists, since
|
||||
/// that view is scoped by the same subscriptions.
|
||||
async fn read_all_mine(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let ids: Vec<String> =
|
||||
state.ctx.db.subscriptions_for(user.id)?.into_iter().map(|s| s.feed_id).collect();
|
||||
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
|
||||
Ok(Json(serde_json::json!({ "marked": n })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HowMany {
|
||||
#[serde(default = "five")]
|
||||
@@ -1285,6 +1372,9 @@ async fn import_opml(
|
||||
let doc = opml::OPML::from_str(&body.xml)
|
||||
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
|
||||
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id)?;
|
||||
if added > 0 {
|
||||
scan_soon(&state, None).await;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "added": added, "already": already })))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(){},
|
||||
@@ -46,7 +46,10 @@ const ctx = {
|
||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||
: String(url).includes('/api/users')
|
||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||
: []),
|
||||
: /\/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 () {},
|
||||
@@ -83,6 +86,9 @@ const drive = [
|
||||
['prefsModal', () => ctx.prefsModal()],
|
||||
['usersModal', () => ctx.usersModal()],
|
||||
['opmlModal', () => ctx.opmlModal()],
|
||||
['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.
|
||||
|
||||
@@ -29,6 +29,18 @@ test('the theme toggle actually changes the theme', async ({ page }) => {
|
||||
.not.toBe(before);
|
||||
});
|
||||
|
||||
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
||||
expect(await theme()).toBe('classic');
|
||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Dark/);
|
||||
|
||||
await page.reload();
|
||||
await expect.poll(theme).toBe('classic');
|
||||
// The 2004 Mac app set its type in Lucida Grande.
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||
});
|
||||
|
||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#modal.on')).toBeVisible();
|
||||
@@ -69,20 +81,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();
|
||||
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).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 [data-a="play"]')).toBeVisible();
|
||||
await expect(page.locator('#files .encbox [title="Save to this computer"]')).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 [data-a="play"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||
@@ -96,12 +109,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 [data-a="play"]')).toHaveCount(0);
|
||||
// What it is and that it is here: one icon, green, with the words in its tooltip.
|
||||
await expect(page.locator('#files .encbox .kind.here')).toHaveAttribute('title', 'image, 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 [title="Save to this computer"]')).toBeVisible();
|
||||
const view = page.locator('#files a[title="View in a new tab"]');
|
||||
await expect(view).toHaveAttribute('target', '_blank');
|
||||
await expect(view).toHaveAttribute('rel', /noopener/);
|
||||
await expect(view).toHaveAttribute('href', /\/media\/\d+/);
|
||||
@@ -115,9 +128,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).locator('.kind[title^="image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the filter tabs change what is listed', async ({ page }) => {
|
||||
@@ -135,7 +148,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
||||
|
||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.btn', { hasText: 'Settings' }).first().click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
||||
await expect(page.locator('#scopy')).toBeVisible();
|
||||
|
||||
@@ -212,7 +225,7 @@ test('inside an OPML, feeds with unread items are listed first', async ({ page }
|
||||
await side.getByText('Grouped Show').click();
|
||||
const gs = page.locator('.ep', { hasText: 'Grouped Ep' });
|
||||
await gs.click();
|
||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||
await page.locator('#detail button[title="Mark unread"]').click();
|
||||
await expect(gs).not.toHaveClass(/read/);
|
||||
|
||||
// Aardvark comes first alphabetically and in the OPML, so only the unread sort puts
|
||||
@@ -238,7 +251,7 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
||||
await expect(folder.locator('.badge')).not.toHaveText('0');
|
||||
await folder.click();
|
||||
|
||||
await page.locator('#content .acts button', { hasText: 'Mark all read' }).click();
|
||||
await page.locator('#content .acts [data-a="read"]').click();
|
||||
await expect(folder.locator('.badge')).toHaveText('0');
|
||||
});
|
||||
|
||||
@@ -280,9 +293,9 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
// Another test may have opened this item already, so start from a known state: the
|
||||
// toggle in the text below flips it back -- which used to recurse until the stack blew.
|
||||
await row().click();
|
||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||
await page.locator('#detail button[title="Mark unread"]').click();
|
||||
await expect(row()).not.toHaveClass(/read/);
|
||||
await expect(page.locator('#detail button', { hasText: 'Mark read' })).toBeVisible();
|
||||
await expect(page.locator('#detail button[title="Mark read"]')).toBeVisible();
|
||||
|
||||
// Opening it is reading it.
|
||||
await row().click();
|
||||
@@ -291,6 +304,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,8 +387,9 @@ 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' });
|
||||
await expect(del).toHaveText('Delete for everyone');
|
||||
// An icon now; what it does, and to whom, is in its tooltip.
|
||||
const del = page.locator('#files button[data-a="del"]');
|
||||
await expect(del).toHaveAttribute('title', /^Delete for everyone \(shared with 1 other person/);
|
||||
|
||||
// Two prompts: the page's own, then the server's, because someone else has not played
|
||||
// it. Accept the first, decline the second, and the file must survive.
|
||||
@@ -417,7 +455,7 @@ test('Settings exports your OPML and imports a pasted one', async ({ page }) =>
|
||||
await page.locator('#prefs').click();
|
||||
const [dl] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.locator('#modalCard a', { hasText: 'Export OPML' }).click(),
|
||||
page.locator('#modalCard a[title="Export OPML"]').click(),
|
||||
]);
|
||||
expect(dl.suggestedFilename()).toBe('ipx-subscriptions.opml');
|
||||
const out = require('fs').readFileSync(await dl.path(), 'utf8');
|
||||
@@ -545,7 +583,7 @@ test('ipx import subscribes the admin, and ipx export writes the feeds out', asy
|
||||
expect(xml).toContain('http://127.0.0.1:8792/two.xml');
|
||||
});
|
||||
|
||||
test('Add feed offers what other people here read, but never a private feed', async ({ browser }) => {
|
||||
test('Popular lists what everyone here reads, but never a private feed', async ({ browser }) => {
|
||||
const { execFileSync } = require('child_process');
|
||||
const setup = require('./global-setup');
|
||||
const env = {
|
||||
@@ -566,7 +604,7 @@ test('Add feed offers what other people here read, but never a private feed', as
|
||||
await piper.locator('button[type=submit]').click();
|
||||
await expect(piper.locator('#feedlist')).toContainText('No feeds.');
|
||||
|
||||
await piper.locator('#addFeed').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.
|
||||
@@ -579,9 +617,164 @@ test('Add feed offers what other people here read, but never a private feed', as
|
||||
expect(listed).not.toContain('.xml');
|
||||
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
||||
|
||||
await offered.filter({ hasText: 'Test Show' }).locator('button', { hasText: 'Subscribe' }).click();
|
||||
// Popular is the top ten of the directory, and the directory is every listed feed, A to Z.
|
||||
const dir = await (await piper.request.get('/api/directory')).json();
|
||||
const top = await (await piper.request.get('/api/popular')).json();
|
||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||
expect(names).toEqual([...names].sort());
|
||||
expect(top.length).toBe(Math.min(10, dir.length));
|
||||
expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true);
|
||||
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.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);
|
||||
|
||||
const row = async () =>
|
||||
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
||||
const before = await row();
|
||||
expect(before.subscribed).toBe(false);
|
||||
await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||
// Once it is yours, it is no longer offered.
|
||||
expect(await (await piper.request.get('/api/popular')).text()).not.toContain('"test-show"');
|
||||
|
||||
// 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('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
await expect(offered.filter({ hasText: 'Test Show' }).locator('[title^="Subscribed"]')).toBeVisible();
|
||||
await expect(offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]')).toHaveCount(0);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
test('adding a feed scans it straight away', async ({ page }) => {
|
||||
await page.locator('#addFeed').click();
|
||||
await page.locator('#nurl').fill('http://127.0.0.1:8792/fresh.xml');
|
||||
await page.locator('#nsave').click();
|
||||
// Nobody pressed Scan. The scheduler's tick is a minute, so this is the add scanning it.
|
||||
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
||||
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
||||
page.on('dialog', d => d.accept());
|
||||
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
||||
const row = page.locator('.ep', { hasText: 'An Article' });
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await row.click();
|
||||
await page.locator('#files button[data-a="del"]').click();
|
||||
|
||||
// No "reaped", no chip at all: just the way to get it again.
|
||||
await expect(row.locator('.kind.here')).toHaveCount(0);
|
||||
await expect(row).not.toContainText(/reaped/i);
|
||||
await row.click();
|
||||
await expect(page.locator('#files')).not.toContainText(/reaped/i);
|
||||
await expect(page.locator('#files button[title="Download to the server"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
||||
const icon = loc => loc.locator('svg path').first().getAttribute('d');
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
|
||||
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
||||
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
||||
|
||||
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
|
||||
// a change made from the toolbar.
|
||||
await page.locator('.ep').first().click();
|
||||
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
||||
await icon(page.locator(`#detail [data-a="${a}"]`))];
|
||||
for (const a of ['read', 'flag']) { const [tb, own] = await pair(a); expect(tb).toBe(own); }
|
||||
const [kept] = await pair('flag');
|
||||
await page.locator('#tbFlag').click();
|
||||
await expect.poll(async () => { const [tb, own] = await pair('flag'); return tb === own && tb !== kept; }).toBe(true);
|
||||
await page.locator('#tbFlag').click(); // leave it as it was
|
||||
await expect.poll(async () => (await pair('flag'))[0]).toBe(kept);
|
||||
|
||||
// Every button in every dialog is an icon with its words in the tooltip.
|
||||
const dialogs = [
|
||||
() => page.locator('#addFeed').click(),
|
||||
() => page.locator('#prefs').click(),
|
||||
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
|
||||
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
||||
() => page.locator('#logs').click(),
|
||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||
];
|
||||
for (const open of dialogs) {
|
||||
await open();
|
||||
const btns = page.locator('#modalCard .btn');
|
||||
await expect(btns.first()).toBeVisible();
|
||||
for (const b of await btns.all()) {
|
||||
await expect(b.locator('svg')).toHaveCount(1);
|
||||
await expect(b).toHaveAttribute('title', /\S/);
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('#modal.on')).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('All Subscriptions marks everything read, across every feed', async ({ page }) => {
|
||||
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
||||
await all.click();
|
||||
// Earlier tests read things; make sure something is unread. Opening an item reads it, and
|
||||
// its own button makes it unread again.
|
||||
await page.locator('.ep').first().click();
|
||||
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
||||
await expect(all.locator('.badge')).not.toHaveText('0');
|
||||
|
||||
page.once('dialog', d => d.accept());
|
||||
await page.locator('#content .acts [data-a="readall"]').click();
|
||||
await expect(all.locator('.badge')).toHaveText('0');
|
||||
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||
await expect(page.locator('.ep')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the item table sorts by any column, both ways, and remembers', async ({ page }) => {
|
||||
const all = page.locator('#feedlist .place', { hasText: 'All Subscriptions' });
|
||||
await all.click();
|
||||
const head = k => page.locator(`#list .ephead [data-sort="${k}"]`);
|
||||
const titles = () => page.locator('#eps .ep .t').allTextContents();
|
||||
// Byte order on lower case, which is what SQLite gives for lower(...).
|
||||
const cmp = (a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
|
||||
const sorted = (t, dir) => JSON.stringify(t) === JSON.stringify([...t].sort((a, b) => cmp(a, b) * dir));
|
||||
await expect(page.locator('#eps .ep').nth(2)).toBeVisible({ timeout: 20_000 });
|
||||
expect(new Set(await titles()).size).toBeGreaterThan(2); // or both orders would prove nothing
|
||||
|
||||
await expect(head('title')).toHaveText('Title');
|
||||
await head('title').click();
|
||||
await expect.poll(async () => sorted(await titles(), 1)).toBe(true);
|
||||
await head('title').click();
|
||||
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
||||
|
||||
// Kept across a reload.
|
||||
await page.reload();
|
||||
await all.click();
|
||||
await expect(head('title').locator('.arr.desc')).toBeVisible();
|
||||
await expect.poll(async () => sorted(await titles(), -1)).toBe(true);
|
||||
|
||||
// Size has its own column; the file column is just what the file is.
|
||||
await expect(page.locator('#eps .ep .size', { hasText: /\d/ }).first()).toBeVisible();
|
||||
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('play in the Files pane plays once, in the player bar', async ({ page }) => {
|
||||
// Regression: the pane had an <audio> of its own, and playing it started the player bar too,
|
||||
// so the same file played twice at once.
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await page.locator('.ep', { has: page.locator('.kind.here') }).first().click();
|
||||
await page.locator('#files [data-a="play"]').click();
|
||||
await expect(page.locator('#player')).toBeVisible();
|
||||
await expect(page.locator('audio')).toHaveCount(1); // the player bar's, and nothing else
|
||||
await page.locator('#pclose').click();
|
||||
});
|
||||
|
||||
5
tests/ui/fixtures/fresh.xml
Normal file
5
tests/ui/fixtures/fresh.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>Fresh Show</title><link>http://127.0.0.1:8792/</link>
|
||||
<description>Added in the browser suite, and scanned by adding it.</description>
|
||||
<item><title>Fresh Ep</title><guid>fresh-1</guid><description>x</description></item>
|
||||
</channel></rss>
|
||||
824
web/index.html
824
web/index.html
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user