Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30edff248 | |||
| be3820bbbd | |||
| 51ce0bf9eb | |||
| 6ec900e456 | |||
| d4304869b6 |
64
CHANGELOG.md
64
CHANGELOG.md
@@ -10,6 +10,66 @@ The long form, with what was wrong before and how it was found, is in
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.4] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- Currently Listening, below Popular: episodes you started and have not finished, across every
|
||||
feed you subscribe to. Tap one to pick up where you left off.
|
||||
- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's
|
||||
light/dark setting. All four are now also in Settings, as a dropdown next to the header
|
||||
button's one-click-at-a-time toggle -- the same setting either way.
|
||||
|
||||
### Changed
|
||||
|
||||
- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered
|
||||
across a reload or a new visit. A feed you no longer subscribe to, or a first visit with
|
||||
nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard
|
||||
refresh. The page sized itself with `100vh`, which iOS Safari measures against the address
|
||||
bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport
|
||||
as the bar shows and hides.
|
||||
|
||||
## [0.5.3] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- A feed that has been failing for a day shows a plain-English reason in the sidebar and on its
|
||||
own page, sorted from a 404, a 401/403, a 402, a name that no longer resolves, or a web page in
|
||||
place of the feed -- with Unsubscribe or, when the page links its new feed, Use the new address.
|
||||
A feed that fails once and reads fine again within a day is never flagged.
|
||||
|
||||
### Changed
|
||||
|
||||
- Unsubscribing from the last person's OPML or Patreon subscription now retires the feeds it
|
||||
listed, the same as a feed the list itself drops: removed if nothing was downloaded, kept and
|
||||
marked orphaned otherwise. Until now they stayed in the database and kept being scanned hourly
|
||||
with auto-download on, which is how 922 defunct `davewiner` feeds outlived the OPML that listed
|
||||
them.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A feed whose XML uses a bare `&` instead of `&` (kcpw, both feedland feeds) is now read
|
||||
instead of refused.
|
||||
- A feed URL that now serves a web page says so, and names the feed the page links to when it has
|
||||
one, instead of a raw XML parser error.
|
||||
- A publisher answering with an empty body (British Antarctic Survey's 202) is read as nothing new
|
||||
to report, not a parse failure.
|
||||
- A link in an item's show notes opens in a new tab instead of navigating away from ipx.
|
||||
- A video file plays as video, in a small floating pane above the player bar, instead of silently
|
||||
as sound only.
|
||||
- On the Unread tab, opening an item no longer makes it disappear from the list -- it stays until
|
||||
you open a different one, even if a scan finishes and refreshes the list while it is open.
|
||||
- Subscribe and Unsubscribe have their own icons (a circled check and a circled minus) instead of
|
||||
sharing the generic plus and minus used for adding feeds, users and imports.
|
||||
- Settings no longer disappears for a non-admin account. It was hiding the whole Settings modal
|
||||
along with the log and the users screen, but a non-admin has settings of their own in there --
|
||||
their subscriptions' Export and Import, and the schedule and quota are worth seeing even without
|
||||
a say in them. Only the log and the users screen, which the server also refuses them, are gone.
|
||||
|
||||
## [0.5.2] - 2026-09-12
|
||||
|
||||
### Added
|
||||
@@ -295,7 +355,9 @@ 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.5.2...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...main
|
||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "0.5.2"
|
||||
version = "0.5.4"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "0.5.2"
|
||||
version = "0.5.4"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
70
TODO.md
70
TODO.md
@@ -1,27 +1,57 @@
|
||||
# To do
|
||||
|
||||
## Trim the state database
|
||||
## Errors in the log
|
||||
|
||||
From an audit of the database layer and a read-only copy of production on 2026-09-12. The data
|
||||
itself was clean: no leftover tables or indexes, 47 free pages, one stray `entry_state` row.
|
||||
Check each against the code before cutting it.
|
||||
From the production log and the feeds' stored errors on 2026-09-13. The Docker log only reached back
|
||||
to 12:32 UTC, so the list comes from `feeds.last_error`: 57 of 1,059 feeds. None of it is ipx's
|
||||
User-Agent; a browser gets the same answers.
|
||||
|
||||
- [x] **`migrate()`'s add list.** All eight columns arrived in 0.2.0, and 0.5.0 only supports
|
||||
upgrades from 0.3.0 on. Drop the list and its loop; keep the `retired` drop loop, which a
|
||||
database coming from 0.4.0 still needs. (`src/db.rs`)
|
||||
- [x] **`Db::subscribed_feed_ids`.** No callers; its doc says the scanner walks it, and it does not.
|
||||
(`src/db.rs`)
|
||||
- [x] **`Db::subscriber_count`.** One caller, which only asks whether it is above zero:
|
||||
`subscriber_counts()?.contains_key(&id)`. (`src/db.rs`, `src/web.rs`)
|
||||
- [x] **The `created` columns** on `users`, `subscriptions` and `sessions`. Written on every insert,
|
||||
never read. Add them to `retired` and drop them from the inserts. (`src/db.rs`)
|
||||
- [x] **`Managed.orphaned`.** Selected by `managed_feeds()` on every call and never read;
|
||||
`FeedSummary.orphaned` is what the UI uses. (`src/db.rs`)
|
||||
- [x] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from
|
||||
`davewiner` (lists.opml.org/davefeeds.xml). The list left `config.toml` about 14 hours before this
|
||||
was written, but its 922 feeds are still in the database and still scanned every hour, with
|
||||
auto-download on: `subscriptions()` adds every derived feed, and with no parent to copy from,
|
||||
`auto_download` falls back to `true` (`src/main.rs:937`). `docs/users.md` says a feed stops being
|
||||
scanned when its last subscriber leaves. Skip a derived feed whose parent is not in config, and
|
||||
when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list
|
||||
dropped: remove those with nothing downloaded, mark the rest orphaned. `remove_feed` and `ipx rm`
|
||||
both leave them behind today. (`src/main.rs`, `src/web.rs`)
|
||||
- [x] **Read feeds with a bare `&`.** kcpw has `<link>https://kcpw.org/?post_type=post&p=125715</link>`,
|
||||
and both feedland feeds have the same fault. Strict XML refuses them; browsers and other readers do
|
||||
not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
|
||||
as `&`. Nobody subscribes to these three now, but the next feed like them will fail the same
|
||||
way. (`src/feed.rs`)
|
||||
- [x] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not
|
||||
begin with an rss tag) and not Atom (...)". Each one checked was a web page: the feed moved and its
|
||||
old URL redirects to the site, or the domain lapsed. Say "got a web page, not a feed", and when the
|
||||
page links a feed (`<link rel="alternate" type="application/rss+xml">`), name it. That link found
|
||||
the new feed for om.co, ms.now, Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and
|
||||
Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
|
||||
as a parse failure. (`src/feed.rs`)
|
||||
- [x] **Show a publisher's error in the UI.** Today a failing feed shows its raw error in red only
|
||||
once you open it (`web/index.html:877`, `:960`); the OPML view marks a failing child "error"
|
||||
(`:988`), and the sidebar shows nothing. Mark a failing feed in the sidebar too, and say whose
|
||||
problem it is and what to do, in plain words: a 404 means the publisher took the feed down or moved
|
||||
it, 401 and 403 that the site refuses ipx, 402 that the feed needs a paid plan, a name that no
|
||||
longer resolves that the site is gone, and a web page instead of a feed that it moved (with the new
|
||||
URL when the page links one). Offer Unsubscribe, or Use the new address. Keep timeouts, 5xx and
|
||||
429 out of it, and flag a feed only once it has failed for a day: macmanx failed once and read
|
||||
fine an hour later. That needs the time a feed started failing, kept beside `last_error` and
|
||||
cleared when a check succeeds. (`web/index.html`, `src/db.rs`, `src/main.rs`)
|
||||
- [ ] **Westenberg.** The only failing feed anyone subscribes to. The Hacker News stars list still
|
||||
gives `joanwestenberg.com/rss`, which is a 404; the feed is now `joanwestenberg.com/feed`.
|
||||
Nothing for ipx to fix; subscribe to the new URL directly.
|
||||
|
||||
## Popular
|
||||
## Other Fixes and Features
|
||||
|
||||
- [x] **Popular lists feeds the way Directory does**: the feeds inside an OPML or a Patreon
|
||||
creator, never the collection itself. (`src/web.rs`, `web/index.html`)
|
||||
- [x] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions
|
||||
- [x] When clicking any link it should open in a new tab
|
||||
- [x] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh — likely fixed (100dvh instead of 100vh), unverified on a real device; reopen if it still happens
|
||||
- [x] video files play as audio files, they should play as video.
|
||||
- [x] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes
|
||||
- [x] Below Popular, have a currently listening section to show what podcasts have been started and not finnished
|
||||
- [x] Update subscribe/unsubscribe icons to be circle-minus (unsubscribe) and circle-check (subscribe)
|
||||
- [x] If I'm on the Unread tab, and I click to read an item the entry in the list will disappear. it should remain until I click to another item.
|
||||
|
||||
After these: `cargo test`, `node tests/page-smoke.js`, `npx playwright test`. Copy `state.db`
|
||||
aside before deploying: `migrate()` drops columns on the first start.
|
||||
## Directory Overhaul
|
||||
|
||||
- [ ] Directory needs to be more functional, with categories and a more interesting layout. use /frontend-design to help
|
||||
|
||||
@@ -6,6 +6,28 @@ reasoning lives. New write-ups go at the top.
|
||||
|
||||
See [README.md](../README.md) for what the thing is.
|
||||
|
||||
## 2026-09-14 — Settings, for everyone with an account
|
||||
|
||||
A user reported that Settings disappeared shortly after they signed in: it showed for a moment,
|
||||
then was gone. `#prefs` sat inside the same `.tgroup` as `#logs`, and `api('/api/me')` hid the
|
||||
whole group -- `$('#admintools').hidden=true` -- the moment it learned the account was not an
|
||||
admin. Nothing wrong with that check timing; it was hiding the wrong thing.
|
||||
|
||||
The Settings modal is not actually all-or-nothing. `GET /api/settings`, and Export and Import
|
||||
OPML, carry no admin check server-side -- `export_opml` and `import_opml` work from a user's own
|
||||
subscriptions, and the schedule/quota page is read-only information, not a control. Only the
|
||||
`PATCH` that changes those settings, and the Users screen behind it, return 403 for anyone but an
|
||||
admin. The comment above the old hide -- "scanning, quotas, accounts and the log are the
|
||||
operator's business" -- was wrong about quotas and half wrong about accounts: reading them is
|
||||
everyone's; changing them is the operator's.
|
||||
|
||||
`prefsModal()` now branches on `S.me.admin` the way the per-feed settings modal already does for
|
||||
its URL field: a non-admin gets the schedule and quota as text, Subscriptions (Export/Import)
|
||||
in full, and no Users section or Save button. Only `#logs` stays hidden, since the log names every
|
||||
account and every failed sign-in. The browser test for a second account asserted the old
|
||||
behaviour outright (`#prefs` hidden, not an admin) rather than what the server actually allows;
|
||||
fixing the UI meant fixing the test's premise too, not just the assertion.
|
||||
|
||||
## 2026-09-12 — Healthy while busy
|
||||
|
||||
After a deploy the container sat at "starting" for a minute, and Docker's health log showed two
|
||||
|
||||
80
src/db.rs
80
src/db.rs
@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS feeds (
|
||||
last_checked INTEGER,
|
||||
ttl_mins INTEGER,
|
||||
last_error TEXT,
|
||||
-- When the current run of failures began; NULL while the feed is healthy. Kept
|
||||
-- through repeated failures so the UI can tell a blip (macmanx: failed once, fine an
|
||||
-- hour later) from a feed that has been down for a day.
|
||||
error_since INTEGER,
|
||||
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||
orphaned INTEGER NOT NULL DEFAULT 0,
|
||||
-- The OPML subscription this feed came from.
|
||||
@@ -165,6 +169,7 @@ fn migrate(conn: &Connection) -> Result<()> {
|
||||
// and it came back the same day with `last_login` beside it.
|
||||
("users", "created", "INTEGER"),
|
||||
("users", "last_login", "INTEGER"),
|
||||
("feeds", "error_since", "INTEGER"),
|
||||
];
|
||||
let retired: &[(&str, &str)] = &[
|
||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||
@@ -208,6 +213,8 @@ pub struct FeedSummary {
|
||||
pub orphaned: bool,
|
||||
pub last_checked: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
/// When this run of failures began; see the `error_since` column.
|
||||
pub error_since: Option<i64>,
|
||||
pub entries: i64,
|
||||
pub downloaded: i64,
|
||||
}
|
||||
@@ -249,7 +256,7 @@ impl Db {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut sum: FeedSummary = conn
|
||||
.query_row(
|
||||
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0)
|
||||
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0), error_since
|
||||
FROM feeds WHERE id = ?1",
|
||||
[feed_id],
|
||||
|r| {
|
||||
@@ -259,6 +266,7 @@ impl Db {
|
||||
last_checked: r.get(2)?,
|
||||
last_error: r.get(3)?,
|
||||
orphaned: r.get::<_, i64>(4)? != 0,
|
||||
error_since: r.get(5)?,
|
||||
..Default::default()
|
||||
})
|
||||
},
|
||||
@@ -333,7 +341,8 @@ impl Db {
|
||||
last_checked = excluded.last_checked,
|
||||
ttl_mins = excluded.ttl_mins,
|
||||
image = coalesce(excluded.image, feeds.image),
|
||||
last_error = NULL",
|
||||
last_error = NULL,
|
||||
error_since = NULL",
|
||||
rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -344,7 +353,8 @@ impl Db {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = NULL",
|
||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked,
|
||||
last_error = NULL, error_since = NULL",
|
||||
rusqlite::params![feed_id, url, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -363,10 +373,15 @@ impl Db {
|
||||
|
||||
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = now();
|
||||
conn.execute(
|
||||
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error",
|
||||
rusqlite::params![feed_id, url, now(), msg],
|
||||
"INSERT INTO feeds (id, url, last_checked, last_error, error_since)
|
||||
VALUES (?1, ?2, ?3, ?4, ?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_checked = excluded.last_checked,
|
||||
last_error = excluded.last_error,
|
||||
error_since = coalesce(feeds.error_since, excluded.error_since)",
|
||||
rusqlite::params![feed_id, url, now, msg],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -670,6 +685,10 @@ pub enum Filter {
|
||||
Unread,
|
||||
Downloaded,
|
||||
Flagged,
|
||||
/// Started (a saved playback position past the first few seconds) but not finished
|
||||
/// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished"
|
||||
/// here too). Currently Listening, below Popular, is this filter on every feed at once.
|
||||
InProgress,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
@@ -678,6 +697,7 @@ impl Filter {
|
||||
"unread" => Self::Unread,
|
||||
"downloaded" => Self::Downloaded,
|
||||
"flagged" => Self::Flagged,
|
||||
"in_progress" => Self::InProgress,
|
||||
_ => Self::All,
|
||||
}
|
||||
}
|
||||
@@ -693,6 +713,7 @@ impl Filter {
|
||||
"EXISTS (SELECT 1 FROM enclosures x
|
||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
||||
}
|
||||
Self::InProgress => "coalesce(s.position, 0) > 5 AND coalesce(s.read, 0) = 0",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1641,18 +1662,27 @@ mod tests {
|
||||
"INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
|
||||
('f','a','Alpha dive','notes one',100),
|
||||
('f','b','Beta', 'notes two',200),
|
||||
('f','c','Gamma dive','notes three',300);
|
||||
('f','c','Gamma dive','notes three',300),
|
||||
('f','d','Delta', 'notes four',400),
|
||||
('f','e','Epsilon', 'notes five',500),
|
||||
('f','g','Gimel', 'notes six',600);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||
(1,'f','b','u1','/tmp/b','done');
|
||||
-- Read and starred belong to a person now, so say which one.
|
||||
INSERT INTO users (id, name, is_admin) VALUES (7,'reader',1);
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||
(7,'f','b',1,0),
|
||||
(7,'f','c',1,1);",
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
|
||||
(7,'f','b',1,0,0),
|
||||
(7,'f','c',1,1,0),
|
||||
-- Started and not finished: this is Currently Listening.
|
||||
(7,'f','d',0,0,42),
|
||||
-- Already finished: not Currently Listening, however far it got.
|
||||
(7,'f','e',1,0,42),
|
||||
-- Barely touched (opened, closed within seconds): not Currently Listening.
|
||||
(7,'f','g',0,0,3);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] {
|
||||
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
|
||||
// Both paths must run without erroring, and agree with each other.
|
||||
let order = order_sql("published", "desc");
|
||||
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap();
|
||||
@@ -1664,13 +1694,17 @@ mod tests {
|
||||
assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees");
|
||||
}
|
||||
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 3);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 6);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 3);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).unwrap(), 2);
|
||||
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
|
||||
"search is case-insensitive and covers the description");
|
||||
|
||||
// Currently Listening: started, not finished, and not just an accidental tap.
|
||||
let listening = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order_sql("published", "desc")).unwrap();
|
||||
assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["d"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1766,6 +1800,26 @@ mod tests {
|
||||
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_since_marks_the_start_of_a_run_of_failures_and_clears_on_success() {
|
||||
let db = Db::memory().unwrap();
|
||||
db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
|
||||
// Backdate it, as if this feed had already been failing a while, so a second
|
||||
// failure landing "now" is distinguishable from the first.
|
||||
db.exec_for_test("UPDATE feeds SET error_since = error_since - 3600 WHERE id = 'f'").unwrap();
|
||||
let first = db.feed_summary("f").unwrap().error_since.unwrap();
|
||||
|
||||
// macmanx: failed once, read fine an hour later. A second failure must not push
|
||||
// error_since forward -- the UI decides "failing for a day" from the first one.
|
||||
db.set_feed_error("f", "http://x", "HTTP 404").unwrap();
|
||||
assert_eq!(db.feed_summary("f").unwrap().error_since, Some(first));
|
||||
|
||||
db.touch_feed("f", "http://x").unwrap();
|
||||
let after = db.feed_summary("f").unwrap();
|
||||
assert_eq!(after.last_error, None);
|
||||
assert_eq!(after.error_since, None, "a clean check ends the run of failures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
206
src/feed.rs
206
src/feed.rs
@@ -87,6 +87,50 @@ pub async fn fetch(
|
||||
Ok(Fetched::Body { bytes, etag, last_modified })
|
||||
}
|
||||
|
||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||
/// it is, and whether there is a new address to switch to.
|
||||
pub struct Failure {
|
||||
pub reason: &'static str,
|
||||
pub new_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
||||
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
||||
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
||||
/// comes back `None`: transient by nature, or with nothing more useful to say than the raw
|
||||
/// text already shown once a feed is open.
|
||||
///
|
||||
/// ponytail: matches on the fixed strings this crate itself produces (`anyhow!("HTTP
|
||||
/// {status}")`, and the "got a web page" message above) plus the substrings a DNS failure
|
||||
/// reliably contains. Fragile if reqwest's own wording changes; the fallback is just showing
|
||||
/// nothing extra, so a miss costs a clearer message, not a wrong one.
|
||||
pub fn explain_failure(msg: &str) -> Option<Failure> {
|
||||
if let Some(rest) = msg.strip_prefix("got a web page, not a feed") {
|
||||
let new_url = rest
|
||||
.strip_prefix("; it links ")
|
||||
.and_then(|r| r.strip_suffix(" as its feed"))
|
||||
.map(str::to_owned);
|
||||
return Some(Failure { reason: "The feed moved; this address now shows a web page.", new_url });
|
||||
}
|
||||
let low = msg.to_ascii_lowercase();
|
||||
if low.contains("http 404") {
|
||||
return Some(Failure { reason: "The publisher took this feed down, or moved it.", new_url: None });
|
||||
}
|
||||
if low.contains("http 401") || low.contains("http 403") {
|
||||
return Some(Failure { reason: "The site refuses ipx's requests.", new_url: None });
|
||||
}
|
||||
if low.contains("http 402") {
|
||||
return Some(Failure { reason: "The feed now needs a paid plan.", new_url: None });
|
||||
}
|
||||
if low.contains("dns error")
|
||||
|| low.contains("failed to lookup address")
|
||||
|| low.contains("no address associated")
|
||||
{
|
||||
return Some(Failure { reason: "This address no longer resolves; the site is gone.", new_url: None });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True when a body is an OPML document rather than a feed.
|
||||
///
|
||||
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
|
||||
@@ -220,11 +264,110 @@ pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||
Ok(ch) => Ok(from_rss(ch, bytes)),
|
||||
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
|
||||
Ok(feed) => Ok(from_atom(feed)),
|
||||
Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")),
|
||||
Err(atom_err) => {
|
||||
// Some publishers (kcpw, feedland) write a bare "&" in a URL instead of
|
||||
// "&". Strict XML parsers refuse it; browsers don't. Retry once with
|
||||
// every offending "&" escaped rather than fail outright.
|
||||
let escaped = escape_bare_ampersands(bytes);
|
||||
if escaped != bytes {
|
||||
if let Ok(ch) = rss::Channel::read_from(escaped.as_slice()) {
|
||||
return Ok(from_rss(ch, &escaped));
|
||||
}
|
||||
if let Ok(feed) = atom_syndication::Feed::read_from(escaped.as_slice()) {
|
||||
return Ok(from_atom(feed));
|
||||
}
|
||||
}
|
||||
Err(match alternate_feed_link(bytes) {
|
||||
Some(href) if looks_like_html(bytes) => {
|
||||
anyhow!("got a web page, not a feed; it links {href} as its feed")
|
||||
}
|
||||
None if looks_like_html(bytes) => anyhow!("got a web page, not a feed"),
|
||||
_ => anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})"),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a body is a web page rather than a feed: most of the errors traced back to a feed
|
||||
/// that moved or a domain that lapsed, with the old URL now serving the site instead (or a
|
||||
/// redirect to it). `is_opml` already sniffs the other "not actually a feed" case.
|
||||
fn looks_like_html(bytes: &[u8]) -> bool {
|
||||
let head = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]).to_lowercase();
|
||||
head.contains("<!doctype html") || head.contains("<html")
|
||||
}
|
||||
|
||||
/// The feed a web page names as its own via `<link rel="alternate" type="application/rss+xml"
|
||||
/// href="...">` (or the Atom equivalent) -- how the new address was found for om.co, ms.now,
|
||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let lower = text.to_lowercase();
|
||||
let mut pos = 0;
|
||||
while let Some(rel) = lower[pos..].find("<link") {
|
||||
let start = pos + rel;
|
||||
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||
pos = end + 1;
|
||||
let tag = &text[start..end];
|
||||
let tag_lower = &lower[start..end];
|
||||
let is_alternate = tag_lower.contains("rel=\"alternate\"") || tag_lower.contains("rel='alternate'");
|
||||
let is_feed_type = tag_lower.contains("rss+xml") || tag_lower.contains("atom+xml");
|
||||
if is_alternate && is_feed_type
|
||||
&& let Some(href) = tag_attr(tag, "href")
|
||||
{
|
||||
return Some(href);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let key = format!("{name}=");
|
||||
let idx = tag.to_lowercase().find(&key)?;
|
||||
let after = &tag[idx + key.len()..];
|
||||
let quote = after.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = &after[1..];
|
||||
let close = rest.find(quote)?;
|
||||
Some(rest[..close].trim().to_owned())
|
||||
}
|
||||
|
||||
/// Escapes every `&` that does not already start a recognized XML entity
|
||||
/// (`&`, `<`, `>`, `"`, `'`, or a numeric reference like `'`).
|
||||
fn escape_bare_ampersands(bytes: &[u8]) -> Vec<u8> {
|
||||
fn is_entity_start(rest: &[u8]) -> bool {
|
||||
for named in [&b"amp;"[..], b"lt;", b"gt;", b"quot;", b"apos;"] {
|
||||
if rest.starts_with(named) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let digits = if rest.starts_with(b"#x") || rest.starts_with(b"#X") {
|
||||
&rest[2..]
|
||||
} else if rest.starts_with(b"#") {
|
||||
&rest[1..]
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let len = digits.iter().take_while(|b| b.is_ascii_alphanumeric()).count();
|
||||
len > 0 && digits.get(len) == Some(&b';')
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'&' && !is_entity_start(&bytes[i + 1..]) {
|
||||
out.extend_from_slice(b"&");
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every `<enclosure>` of every `<item>`, in document order.
|
||||
///
|
||||
/// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0
|
||||
@@ -591,6 +734,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
||||
assert_eq!(
|
||||
explain_failure("HTTP 404 Not Found").unwrap().reason,
|
||||
"The publisher took this feed down, or moved it."
|
||||
);
|
||||
assert_eq!(explain_failure("HTTP 401 Unauthorized").unwrap().reason, "The site refuses ipx's requests.");
|
||||
assert_eq!(explain_failure("HTTP 403 Forbidden").unwrap().reason, "The site refuses ipx's requests.");
|
||||
assert_eq!(explain_failure("HTTP 402 Payment Required").unwrap().reason, "The feed now needs a paid plan.");
|
||||
let dns = explain_failure("connecting: dns error: failed to lookup address information").unwrap();
|
||||
assert_eq!(dns.reason, "This address no longer resolves; the site is gone.");
|
||||
let moved = explain_failure("got a web page, not a feed; it links https://x/feed as its feed").unwrap();
|
||||
assert_eq!(moved.new_url.as_deref(), Some("https://x/feed"));
|
||||
assert!(explain_failure("got a web page, not a feed").unwrap().new_url.is_none());
|
||||
for transient in ["HTTP 500 Internal Server Error", "HTTP 429 Too Many Requests", "operation timed out"] {
|
||||
assert!(explain_failure(transient).is_none(), "{transient} must not be flagged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_says_so_and_names_the_feed_it_links() {
|
||||
let html = br#"<!doctype html><html><head>
|
||||
<link rel="alternate" type="application/rss+xml" href="https://x.example/feed">
|
||||
</head><body>not a feed</body></html>"#;
|
||||
let err = parse(html).unwrap_err().to_string();
|
||||
assert_eq!(err, "got a web page, not a feed; it links https://x.example/feed as its feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_with_no_feed_link_still_says_so() {
|
||||
let html = b"<!doctype html><html><body>moved</body></html>";
|
||||
assert_eq!(parse(html).unwrap_err().to_string(), "got a web page, not a feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_that_is_not_html_gets_the_original_parser_errors() {
|
||||
let err = parse(b"not xml at all").unwrap_err().to_string();
|
||||
assert!(err.starts_with("not RSS ("), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_atom_enclosure_links() {
|
||||
let bytes = include_bytes!("../tests/data/atom.xml");
|
||||
@@ -613,6 +796,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_ampersand_in_a_link_is_repaired_and_parsed() {
|
||||
// kcpw.org: <link>https://kcpw.org/?post_type=post&p=125715</link> -- a bare "&"
|
||||
// that strict XML rejects but browsers accept.
|
||||
let xml = br#"<?xml version="1.0"?>
|
||||
<rss version="2.0"><channel><title>X</title><link>https://x</link><description>d</description>
|
||||
<item><title>a</title><guid>g1</guid>
|
||||
<link>https://kcpw.org/?post_type=post&p=125715</link>
|
||||
<enclosure url="https://x/a.mp3?a=1&b=2" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>"#;
|
||||
let feed = parse(xml).unwrap();
|
||||
assert_eq!(feed.entries[0].link.as_deref(), Some("https://kcpw.org/?post_type=post&p=125715"));
|
||||
assert_eq!(feed.entries[0].enclosures[0].url, "https://x/a.mp3?a=1&b=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_bare_ampersands_leaves_real_entities_alone() {
|
||||
let out = escape_bare_ampersands(b"a&b <x> ' / c&d");
|
||||
assert_eq!(out, b"a&b <x> ' / c&d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() {
|
||||
// Some feeds set a different itunes:title. The displayed title is always the RSS
|
||||
|
||||
77
src/main.rs
77
src/main.rs
@@ -637,6 +637,7 @@ fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
|
||||
cfg.save(config_path)?;
|
||||
// State and files stay: re-adding the feed should not re-download its back catalogue.
|
||||
println!("removed {feed}; downloads and history kept");
|
||||
retire_group(ctx, feed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -842,6 +843,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
|
||||
feed: id.clone(),
|
||||
reason: "not modified".into(),
|
||||
}),
|
||||
Ok(Outcome::Empty) => ctx.out.emit(Event::FeedSkip {
|
||||
feed: id.clone(),
|
||||
reason: "nothing yet".into(),
|
||||
}),
|
||||
Ok(Outcome::Opml { added, removed, kept, total }) => {
|
||||
ctx.out.emit(Event::FeedSkip {
|
||||
feed: id.clone(),
|
||||
@@ -919,6 +924,12 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
continue; // promoted to config at some point; that entry wins
|
||||
}
|
||||
let parent = cfg.feeds.get(&m.group_id);
|
||||
if parent.is_none() {
|
||||
// The OPML or Patreon feed this was derived from is no longer in config --
|
||||
// removing it should have retired these rows too (see `retire_group`), but
|
||||
// skip them here regardless so a row that slips through is never scanned.
|
||||
continue;
|
||||
}
|
||||
let base = parent
|
||||
.and_then(|p| p.folder.clone())
|
||||
.or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title))
|
||||
@@ -947,6 +958,22 @@ pub fn subscriptions(ctx: &Ctx) -> Result<Vec<Sub>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Retires every feed derived from `parent_id`, now that nothing subscribes to the OPML or
|
||||
/// Patreon feed that listed them: the same rule `sync_group` applies to one the list drops --
|
||||
/// removed if nothing was downloaded, orphaned and kept otherwise. Called once the parent
|
||||
/// itself is removed, since `subscriptions()` would otherwise keep scanning them under a
|
||||
/// fallback policy meant for a feed with no parent at all.
|
||||
pub fn retire_group(ctx: &Ctx, parent_id: &str) -> Result<()> {
|
||||
for m in ctx.db.managed_feeds()?.into_iter().filter(|m| m.group_id == parent_id) {
|
||||
if ctx.db.downloaded_count(&m.id).unwrap_or(1) > 0 {
|
||||
ctx.db.set_orphaned(&m.id, true)?;
|
||||
} else {
|
||||
ctx.db.drop_managed(&m.id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seconds to wait before re-checking a feed.
|
||||
///
|
||||
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the
|
||||
@@ -971,6 +998,9 @@ struct Scan {
|
||||
/// What a scan of one feed turned out to be.
|
||||
enum Outcome {
|
||||
NotModified,
|
||||
/// A response with nothing in it -- the British Antarctic Survey answers a 202 with an
|
||||
/// empty body when it has nothing new to publish. Not a parse failure; try again later.
|
||||
Empty,
|
||||
Feed(Scan),
|
||||
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows.
|
||||
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
|
||||
@@ -1033,6 +1063,11 @@ async fn scan_one(
|
||||
feed::Fetched::Body { bytes, etag, last_modified } => (bytes, etag, last_modified),
|
||||
};
|
||||
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
ctx.db.touch_feed(id, &feed_cfg.url)?;
|
||||
return Ok(Outcome::Empty);
|
||||
}
|
||||
|
||||
// A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml"
|
||||
// URL; sniffing the body also catches one served from a URL without that extension.
|
||||
if feed::is_opml(&bytes) {
|
||||
@@ -1635,4 +1670,46 @@ mod tests {
|
||||
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(true), None)], &feed(), 3);
|
||||
assert!(p.auto_download);
|
||||
}
|
||||
|
||||
fn test_ctx(cfg: config::Config) -> Ctx {
|
||||
Ctx {
|
||||
cfg: std::sync::RwLock::new(Arc::new(cfg)),
|
||||
db: db::Db::memory().unwrap(),
|
||||
client: reqwest::Client::new(),
|
||||
out: Emitter::terminal(),
|
||||
torrents: tokio::sync::OnceCell::new(),
|
||||
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
|
||||
config_path: PathBuf::new(),
|
||||
detach_torrents: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_derived_feed_is_not_scanned_once_its_opml_leaves_config() {
|
||||
// davewiner: the OPML subscription left config.toml, but its 922 derived rows
|
||||
// stayed in the database and kept being scanned under the no-parent fallback.
|
||||
let ctx = test_ctx(config::Config::default());
|
||||
ctx.db.upsert_managed("child", "http://x/child.xml", "Child", "gone-opml").unwrap();
|
||||
assert!(
|
||||
subscriptions(&ctx).unwrap().iter().all(|s| s.id != "child"),
|
||||
"a derived feed whose parent is gone from config must not be scanned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retiring_a_group_drops_what_was_never_downloaded_and_orphans_the_rest() {
|
||||
let ctx = test_ctx(config::Config::default());
|
||||
ctx.db.upsert_managed("empty", "http://x/empty.xml", "Empty", "parent").unwrap();
|
||||
ctx.db.upsert_managed("has-file", "http://x/has-file.xml", "Has File", "parent").unwrap();
|
||||
let enc = feed::Enclosure { url: "http://x/ep.mp3".into(), mime: None, length: None };
|
||||
ctx.db.record_enclosure("has-file", "g1", &enc).unwrap();
|
||||
ctx.db.mark_downloaded(&enc.url, std::path::Path::new("/downloads/ep.mp3"), 1).unwrap();
|
||||
|
||||
retire_group(&ctx, "parent").unwrap();
|
||||
|
||||
let managed = ctx.db.managed_feeds().unwrap();
|
||||
assert!(!managed.iter().any(|m| m.id == "empty"), "nothing downloaded, so it is forgotten");
|
||||
assert!(managed.iter().any(|m| m.id == "has-file"), "has a file on disk, so it is kept");
|
||||
assert!(ctx.db.feed_summary("has-file").unwrap().orphaned, "and flagged as orphaned");
|
||||
}
|
||||
}
|
||||
|
||||
25
src/web.rs
25
src/web.rs
@@ -488,6 +488,9 @@ struct FeedRow {
|
||||
last_checked: Option<i64>,
|
||||
next_check: Option<i64>,
|
||||
last_error: Option<String>,
|
||||
/// Set once `last_error` is a kind worth telling someone about and it has held for a
|
||||
/// day -- a feed that fails once and reads fine an hour later (macmanx) never gets here.
|
||||
failing: Option<FailingRow>,
|
||||
entries: i64,
|
||||
downloaded: i64,
|
||||
unread: i64,
|
||||
@@ -495,6 +498,15 @@ struct FeedRow {
|
||||
subscribers: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FailingRow {
|
||||
reason: &'static str,
|
||||
new_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A day, in seconds: how long an error has to hold before the UI mentions it.
|
||||
const FLAG_AFTER_SECS: i64 = 86_400;
|
||||
|
||||
async fn feeds(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
@@ -558,6 +570,12 @@ async fn feeds(
|
||||
next_check: s
|
||||
.last_checked
|
||||
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64),
|
||||
failing: s
|
||||
.error_since
|
||||
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
||||
.and_then(|_| s.last_error.as_deref())
|
||||
.and_then(crate::feed::explain_failure)
|
||||
.map(|f| FailingRow { reason: f.reason, new_url: f.new_url }),
|
||||
last_error: s.last_error,
|
||||
entries: s.entries,
|
||||
downloaded: s.downloaded,
|
||||
@@ -917,9 +935,13 @@ fn entry_page(
|
||||
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.
|
||||
// Every link opens in a new tab -- ammonia's default rel="noopener noreferrer" already
|
||||
// keeps that safe -- so following one in show notes never navigates away from ipx.
|
||||
let mut sanitizer = ammonia::Builder::new();
|
||||
sanitizer.add_tag_attributes("a", &["target"]).set_tag_attribute_value("a", "target", "_blank");
|
||||
for row in &mut rows {
|
||||
if let Some(d) = &row.description {
|
||||
row.description = Some(ammonia::clean(d));
|
||||
row.description = Some(sanitizer.clean(d).to_string());
|
||||
}
|
||||
}
|
||||
let total = db.count_in(user_id, feed, filter, search)?;
|
||||
@@ -1147,6 +1169,7 @@ async fn remove_feed(
|
||||
}
|
||||
cfg.save(&state.config_path)?;
|
||||
state.ctx.reload_cfg(&state.config_path)?;
|
||||
crate::retire_group(&state.ctx, &id)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
||||
await expect(page.getByText('Test Show')).toBeVisible();
|
||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.reload();
|
||||
@@ -33,7 +33,7 @@ test('the theme button steps through dark, light and classic, and remembers', as
|
||||
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 expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
|
||||
|
||||
await page.reload();
|
||||
await expect.poll(theme).toBe('classic');
|
||||
@@ -41,6 +41,28 @@ test('the theme button steps through dark, light and classic, and remembers', as
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||
});
|
||||
|
||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
||||
|
||||
await page.locator('#stheme').selectOption('auto');
|
||||
await expect.poll(theme).toBe('auto');
|
||||
// Auto follows the system; emulating a light system must show the light palette live,
|
||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
||||
|
||||
// The header button and the dropdown are the same one setting, not two.
|
||||
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
|
||||
await page.locator('#theme').click();
|
||||
expect(await theme()).toBe('dark');
|
||||
});
|
||||
|
||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#modal.on')).toBeVisible();
|
||||
@@ -57,7 +79,7 @@ test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('First Episode')).toBeVisible();
|
||||
// Newest first, so target the episode by name rather than by position.
|
||||
@@ -74,7 +96,7 @@ test('episodes show with their metadata, and the text opens below', async ({ pag
|
||||
});
|
||||
|
||||
test('the three panes are there and the item text lands in the bottom one', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await expect(page.locator('#list')).toBeVisible();
|
||||
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
|
||||
await expect(page.locator('#detail')).toContainText('Pick an item');
|
||||
@@ -133,8 +155,34 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
||||
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
|
||||
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
|
||||
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
|
||||
// test may have opened -- and so read -- this one already; reset it before relying on it.
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||
|
||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
await expect(row).toContainText('0:42 of 15:00');
|
||||
|
||||
await row.click();
|
||||
await expect(page.locator('#player')).toBeVisible();
|
||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
||||
await page.locator('#pclose').click();
|
||||
|
||||
// Finished (read) drops it from the list, however far it got.
|
||||
await page.evaluate(() =>
|
||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
||||
});
|
||||
|
||||
test('the filter tabs change what is listed', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
const all = await page.locator('.ep').count(); // All is the default tab
|
||||
await expect(page.locator('#count')).toContainText('item');
|
||||
@@ -147,7 +195,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('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
||||
await expect(page.locator('#scopy')).toBeVisible();
|
||||
@@ -288,7 +336,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
@@ -307,7 +355,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag
|
||||
});
|
||||
|
||||
test('the toolbar acts on the selected item', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
// Nothing selected, nothing to act on.
|
||||
@@ -359,7 +407,15 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
|
||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||
await expect(page.locator('#prefs')).toBeHidden(); // not an admin
|
||||
// Settings stays: Sam has their own subscriptions to export and import, and the
|
||||
// schedule and quota are worth seeing even without a say in them. Only the log and the
|
||||
// users screen -- and the server -- are an admin's alone.
|
||||
await expect(page.locator('#prefs')).toBeVisible();
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||
await expect(page.locator('#gsave')).toBeHidden();
|
||||
await expect(page.locator('#gusers')).toBeHidden();
|
||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
||||
// Hiding the button is not the guard; the server is.
|
||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||
await expect(page.locator('#logs')).toBeHidden();
|
||||
@@ -387,7 +443,7 @@ test('a second person has their own feeds and their own read state', async ({ br
|
||||
|
||||
test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => {
|
||||
// Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file.
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
const row = page.locator('.ep').first();
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
@@ -411,7 +467,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
||||
expect(seen[1]).toContain('one copy of this file');
|
||||
|
||||
await page.reload();
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||
await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
|
||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
@@ -783,6 +839,22 @@ test('the item table sorts by any column, both ways, and remembers', async ({ pa
|
||||
await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the selected feed and tab are remembered across a reload', async ({ page }) => {
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('#content h2')).toHaveText('Test Show');
|
||||
await expect(page.locator('.tabs button.on')).toHaveText('Unread');
|
||||
|
||||
// A feed that is gone -- unsubscribed, or never visited on this browser -- lands on All
|
||||
// Subscriptions, not the first feed alphabetically.
|
||||
await page.evaluate(() => localStorage.setItem('ipx.feed', 'no-such-feed'));
|
||||
await page.reload();
|
||||
await expect(page.locator('#feedlist .place.sel')).toContainText('All Subscriptions');
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -790,7 +862,9 @@ test('play in the Files pane plays once, in the player bar', async ({ page }) =>
|
||||
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
|
||||
// The player bar's element doubles as a <video> so a video file has somewhere to show its
|
||||
// picture (see #audio's own comment), but there is still exactly one of it, and nothing else.
|
||||
await expect(page.locator('#audio')).toHaveCount(1);
|
||||
await page.locator('#pclose').click();
|
||||
});
|
||||
|
||||
|
||||
205
web/index.html
205
web/index.html
@@ -43,6 +43,29 @@
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
/* Auto: the same palette as Light, but only while the system is set to light -- the default
|
||||
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
|
||||
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
|
||||
CSS custom properties have no way to say "these vars, but only under this media query". */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root[data-theme="auto"] {
|
||||
--bg:#f2f4f7;
|
||||
--panel:#ffffff;
|
||||
--panel2:#e9edf3;
|
||||
--raise:#dde3ec;
|
||||
--line:#d6d6d6;
|
||||
--fg:#1a1a1a;
|
||||
--dim:#606060;
|
||||
--faint:#767676;
|
||||
--accent:#2d5391;
|
||||
--accent2:#9a5f0a;
|
||||
--ink:#ffffff;
|
||||
--good:#2f7d4f;
|
||||
--warn:#b06f10;
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
}
|
||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme:light;
|
||||
@@ -69,7 +92,14 @@ html,body{height:100%}
|
||||
body{
|
||||
margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden;
|
||||
display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
|
||||
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
|
||||
measured against whichever state happened to be current -- sized too tall while the bar
|
||||
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
|
||||
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
|
||||
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
|
||||
the fallback for a browser that does not know dvh. */
|
||||
height:100vh;height:100dvh;
|
||||
}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
a{color:var(--accent)}
|
||||
@@ -252,6 +282,8 @@ a.btn{text-decoration:none;color:inherit}
|
||||
.btn.danger:hover{border-color:var(--bad);color:var(--bad)}
|
||||
/* An icon in place of a word; the word is in its tooltip. */
|
||||
.btn.ico{padding:4px 9px;min-width:32px;font-size:14px;line-height:1.25;text-align:center}
|
||||
/* Inline in a sentence, next to a plain-word error explanation. */
|
||||
.btn.tiny{padding:2px 7px;font-size:11.5px;border-radius:6px;margin-left:2px}
|
||||
/* An icon (Font Awesome, embedded as SVG) in the button's own colour. */
|
||||
.i{display:inline-block;width:16px;height:16px;vertical-align:-3px;flex:none;fill:currentColor}
|
||||
/* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
|
||||
@@ -337,6 +369,15 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
|
||||
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
|
||||
}
|
||||
#player.on{display:grid}
|
||||
/* #audio is a <video> playing double duty as the audio element (see its tag). Only a video
|
||||
file needs to be seen, floated above the bar rather than laid into it, so an audio episode's
|
||||
layout is unchanged. */
|
||||
#audio{display:none}
|
||||
body.has-video #audio{
|
||||
display:block;position:fixed;z-index:45;right:16px;bottom:120px;
|
||||
width:360px;max-width:calc(100vw - 32px);aspect-ratio:16/9;background:#000;
|
||||
border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.5);
|
||||
}
|
||||
#pnow{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
|
||||
#pnow .art{width:44px;height:44px;font-size:13px}
|
||||
#pnow .txt{min-width:0}
|
||||
@@ -546,7 +587,7 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
A phone hides the item group, which it has no table for. -->
|
||||
<div class="tgroup">
|
||||
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button>
|
||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="minus" disabled></button>
|
||||
<button id="tbRemove" title="Unsubscribe from this feed" aria-label="Unsubscribe from this feed" data-icon="circleMinus" disabled></button>
|
||||
<button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
|
||||
</div>
|
||||
<div class="tgroup item">
|
||||
@@ -610,7 +651,10 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
|
||||
<div id="modal"><div class="card" id="modalCard"></div></div>
|
||||
<div id="toasts"></div>
|
||||
<audio id="audio" preload="metadata"></audio>
|
||||
<!-- One element for both: a <video> plays an audio-only file exactly like <audio> does (same
|
||||
HTMLMediaElement API), and it is the only tag that can also show a picture. Hidden unless
|
||||
the current file is video -- see body.has-video below. -->
|
||||
<video id="audio" preload="metadata" playsinline></video>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
@@ -625,6 +669,7 @@ const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}
|
||||
const ICON={
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
minus:fa('0 0 448 512','<path fill="currentColor" d="M0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32z"/>'), // solid/minus
|
||||
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
|
||||
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
|
||||
@@ -753,8 +798,13 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
const S = {
|
||||
feeds:[], feed:null, entries:[], total:0, offset:0,
|
||||
filter:'all', q:'', sel:null, me:null,
|
||||
feeds:[],
|
||||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||
// you were instead of jumping to the first feed alphabetically.
|
||||
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||
entries:[], total:0, offset:0,
|
||||
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||
q:'', sel:null, me:null,
|
||||
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||
||{col:'published',dir:'desc'},
|
||||
@@ -766,7 +816,13 @@ async function loadFeeds(keepSel){
|
||||
S.feeds = await api('/api/feeds');
|
||||
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||
renderFeeds();
|
||||
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id);
|
||||
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||
if(!keepSel && S.feeds.length){
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||
selectFeed(known ? S.feed : ':all');
|
||||
}
|
||||
}
|
||||
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||
// server's alphabetical order still holds within each half.
|
||||
@@ -828,6 +884,9 @@ function renderFeeds(){
|
||||
const [unread,eps,saved]=mine.length
|
||||
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||
: [f.unread,f.entries,f.downloaded];
|
||||
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||
// reads fine; it is failing when any feed inside it is.
|
||||
const failing=mine.length ? mine.find(c=>c.failing)?.failing : f.failing;
|
||||
const el=document.createElement('div');
|
||||
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
|
||||
el.tabIndex=0; el.dataset.id=f.id;
|
||||
@@ -839,6 +898,7 @@ function renderFeeds(){
|
||||
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
|
||||
`</small></div>`+
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
(failing?`<span class="tag" style="color:var(--bad)" title="${esc(failing.reason)}">Error</span>`:'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
@@ -847,10 +907,26 @@ function renderFeeds(){
|
||||
done();
|
||||
}
|
||||
function selectFeed(id){
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||
renderFeeds(); renderFeed(); loadEntries();
|
||||
}
|
||||
|
||||
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||
function failBannerHTML(f){
|
||||
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||
return '';
|
||||
}
|
||||
function wireFailBanner(box,f){
|
||||
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||
}
|
||||
|
||||
/* ---------------- feed page ---------------- */
|
||||
function renderFeed(){
|
||||
const box=$('#content');
|
||||
@@ -874,7 +950,7 @@ function renderFeed(){
|
||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||||
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
||||
${failBannerHTML(f)}
|
||||
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||
@@ -884,7 +960,7 @@ function renderFeed(){
|
||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="fhead slim">
|
||||
@@ -918,7 +994,12 @@ function renderFeed(){
|
||||
showDetail(null);
|
||||
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()});
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||
S.filter=b.dataset.f; S.offset=0;
|
||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||
renderFeed(); loadEntries();
|
||||
});
|
||||
if(f) wireFailBanner(box,f);
|
||||
}
|
||||
|
||||
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||||
@@ -957,7 +1038,7 @@ function renderGroup(f,kids){
|
||||
<h2>${esc(f.title||f.id)}</h2>
|
||||
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
|
||||
${failBannerHTML(f)}
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||
</div>
|
||||
@@ -965,7 +1046,7 @@ function renderGroup(f,kids){
|
||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
@@ -974,6 +1055,7 @@ function renderGroup(f,kids){
|
||||
</div>
|
||||
<div class="childlist" id="kidlist"></div>`;
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||
wireFailBanner($('#content'),f);
|
||||
const draw=()=>{
|
||||
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||
const box=$('#kidlist'); box.innerHTML='';
|
||||
@@ -985,8 +1067,10 @@ function renderGroup(f,kids){
|
||||
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||
el.onclick=()=>selectFeed(c.id);
|
||||
box.appendChild(el);
|
||||
@@ -1027,7 +1111,15 @@ async function loadEntries(append){
|
||||
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||
S.total=r.total;
|
||||
S.entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||
if(!append && S.sel && !entries.some(e=>e.guid===S.sel)){
|
||||
const open=S.entries.find(e=>e.guid===S.sel);
|
||||
if(open) entries=[open,...entries];
|
||||
}
|
||||
S.entries = entries;
|
||||
renderEntries();
|
||||
}
|
||||
function renderEntries(){
|
||||
@@ -1348,6 +1440,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
|
||||
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||
if(!resuming){
|
||||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id;
|
||||
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||
audio.src=`/media/${enc.id}`;
|
||||
audio.currentTime=0;
|
||||
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||
@@ -1402,7 +1495,7 @@ $('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');renderEntries()};
|
||||
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries()};
|
||||
(function restore(){
|
||||
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||
@@ -1566,7 +1659,7 @@ async function listFeeds(url){
|
||||
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.subbed}</button>`);
|
||||
// Yours already: the row opens it instead.
|
||||
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
|
||||
$('[data-a="sub"]',el).onclick=async()=>{
|
||||
@@ -1588,16 +1681,42 @@ async function renderListed(v){
|
||||
$('#tbRemove').disabled=true;
|
||||
syncTools(null);
|
||||
$('#epSearch').placeholder='Search items…';
|
||||
const listening=v===VIEWS[':popular'];
|
||||
box.innerHTML=`
|
||||
<div class="fhead slim">
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta"><h2>${v.title}</h2>
|
||||
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`;
|
||||
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>
|
||||
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
|
||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
|
||||
$('#count').textContent=v.title;
|
||||
const n=await listFeeds(v.url);
|
||||
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
|
||||
if(listening) renderListening();
|
||||
}
|
||||
|
||||
/// Below Popular: episodes you started and have not finished, across every feed you
|
||||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||
/// where you left off, not another way to browse.
|
||||
async function renderListening(){
|
||||
const box=$('#listening');
|
||||
let rows=[];
|
||||
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
|
||||
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||
for(const e of rows){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
|
||||
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
|
||||
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
|
||||
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
|
||||
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
|
||||
el.onclick=()=>play(e);
|
||||
box.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||||
@@ -1645,18 +1764,31 @@ function due(ts){
|
||||
return 'in '+Math.round(d/86400)+'d';
|
||||
}
|
||||
|
||||
/// Global settings. GET /api/settings is open to anyone signed in; only the PATCH, and the
|
||||
/// Users screen behind it, are the operator's alone (the server refuses both from anyone
|
||||
/// else). A non-admin gets the same modal minus those two parts, not no settings at all --
|
||||
/// Export/Import are theirs regardless, and seeing the schedule and quota explains why a
|
||||
/// feed is polled when it is.
|
||||
async function prefsModal(){
|
||||
const g = await api('/api/settings');
|
||||
const gs = splitEvery(g.every_mins);
|
||||
const admin = !!(S.me&&S.me.admin);
|
||||
openModal(`<h3>Settings</h3>
|
||||
<div class="field"><label>Theme</label>
|
||||
<select id="stheme">${THEME_ORDER.map(t=>
|
||||
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
|
||||
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
|
||||
the header, one click at a time; this jumps straight to the one you want.</span></div>
|
||||
<div class="field"><label>Check feeds every</label>
|
||||
<div class="inline">
|
||||
${admin?`<div class="inline">
|
||||
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||
</div>
|
||||
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||
<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
|
||||
:`<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its
|
||||
own. Only an admin changes this.</span>`}</div>
|
||||
${admin?`<div class="field"><label>Max new downloads per scan, per feed</label>
|
||||
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||
inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
|
||||
@@ -1671,7 +1803,7 @@ async function prefsModal(){
|
||||
<span class="hint">Over this, the oldest played items are deleted first. Kept
|
||||
items are never touched.</span></div>
|
||||
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>`:''}
|
||||
<div class="field"><label>Download folder</label>
|
||||
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||
<div class="field"><label>Subscriptions</label>
|
||||
@@ -1682,12 +1814,14 @@ async function prefsModal(){
|
||||
</div>
|
||||
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||
subscribes you to every feed in one.</span></div>
|
||||
<div class="field"><label>Users</label>
|
||||
${admin?`<div class="field"><label>Users</label>
|
||||
<div class="inline"><button class="btn ico" id="gusers" title="Manage users…" aria-label="Manage users">${ICON.users}</button></div>
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
|
||||
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
|
||||
$('#stheme').onchange=e=>setTheme(e.target.value);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
if(!admin) return;
|
||||
$('#gusers').onclick=usersModal;
|
||||
$('#gsave').onclick=async()=>{
|
||||
try{
|
||||
@@ -1751,7 +1885,7 @@ async function usersModal(){
|
||||
};
|
||||
}
|
||||
|
||||
function settingsModal(f){
|
||||
function settingsModal(f,newUrl){
|
||||
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
||||
@@ -1772,7 +1906,7 @@ function settingsModal(f){
|
||||
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||
<div class="field"><label>Feed URL</label>
|
||||
<div class="inline">
|
||||
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||
</div>
|
||||
<span class="hint">${S.me&&S.me.admin
|
||||
@@ -1829,7 +1963,7 @@ function removeFeed(f){
|
||||
reading it keeps it, along with their own read state.
|
||||
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.minus}</button></div>`);
|
||||
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||
$('#rgo').onclick=async()=>{
|
||||
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||
@@ -1875,9 +2009,10 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
|
||||
api('/api/me').then(u=>{
|
||||
S.me=u;
|
||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||
// Scanning, quotas, accounts and the log are the operator's business. The server refuses
|
||||
// them too; hiding the buttons just stops offering what would fail.
|
||||
if(!u.admin) $('#admintools').hidden=true;
|
||||
// The log names every account and every failed sign-in; that alone is the operator's
|
||||
// business, and the server refuses it from anyone else. Settings stays -- it opens the
|
||||
// same modal for everyone, just without the admin-only fields (see prefsModal).
|
||||
if(!u.admin) $('#logs').hidden=true;
|
||||
}).catch(()=>{});
|
||||
$('#logs').onclick=logsModal;
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
@@ -1894,14 +2029,18 @@ $('#feedlist').onkeydown=ev=>{
|
||||
};
|
||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
$('#scrim').onclick=()=>nav(false);
|
||||
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
||||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
|
||||
// Dark, light, the 2004 Mac app, and following the system. The header button steps through
|
||||
// them and Settings offers the same four as a dropdown; either one sets ipx.theme and both
|
||||
// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in
|
||||
// "steps through dark, light and classic" reaching classic exactly where that test expects.
|
||||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
|
||||
const THEME_ORDER=Object.keys(THEMES);
|
||||
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
|
||||
function setTheme(t){
|
||||
if(!THEMES[t]) t='dark';
|
||||
document.documentElement.dataset.theme=t;
|
||||
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
|
||||
const sel=$('#stheme'); if(sel) sel.value=t;
|
||||
try{ localStorage.setItem('ipx.theme',t); }catch{}
|
||||
}
|
||||
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));
|
||||
|
||||
Reference in New Issue
Block a user