Release 0.5.3: OPML orphan scan, feed error UI, small UI fixes

- Stop scanning an OPML/Patreon feed's derived rows once nobody subscribes
  to it; retire them (drop or orphan) the way sync_group already does when
  the list itself drops one. This is what let 922 defunct davewiner feeds
  keep scanning hourly after the OPML left config.
- Repair feed XML with a bare `&`, and give a plain reason (moved web page
  with its new address when linked, or nothing yet for an empty body)
  instead of a raw parser error.
- Show a failing feed's plain-English reason and next step (Unsubscribe /
  Use the new address) in the sidebar and on its own page, once it has
  been down a day.
- Fix four small UI bugs: show-note links open in a new tab, video files
  play as video, an opened item no longer disappears from the Unread tab,
  and Subscribe/Unsubscribe get their own icons.
- Fix Settings disappearing for non-admin accounts: it was hiding the
  whole modal instead of just the admin-only parts (Users, the editable
  schedule/quota, Save), which are the only parts the server actually
  refuses them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
2026-09-14 14:53:45 +00:00
parent 51ce0bf9eb
commit 6e24f2d77a
11 changed files with 520 additions and 46 deletions

View File

@@ -10,6 +10,43 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased] ## [Unreleased]
## [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 `&amp;` (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 ## [0.5.2] - 2026-09-12
### Added ### Added
@@ -295,7 +332,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. - 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/`. - `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.3...main
[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.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.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 [0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.5.2" version = "0.5.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

26
TODO.md
View File

@@ -6,7 +6,7 @@ From the production log and the feeds' stored errors on 2026-09-13. The Docker l
to 12:32 UTC, so the list comes from `feeds.last_error`: 57 of 1,059 feeds. None of it is ipx's 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. User-Agent; a browser gets the same answers.
- [ ] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from - [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 `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 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 on: `subscriptions()` adds every derived feed, and with no parent to copy from,
@@ -15,19 +15,19 @@ User-Agent; a browser gets the same answers.
when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list 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` 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`) both leave them behind today. (`src/main.rs`, `src/web.rs`)
- [ ] **Read feeds with a bare `&`.** kcpw has `<link>https://kcpw.org/?post_type=post&p=125715</link>`, - [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 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 not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
as `&amp;`. Nobody subscribes to these three now, but the next feed like them will fail the same as `&amp;`. Nobody subscribes to these three now, but the next feed like them will fail the same
way. (`src/feed.rs`) way. (`src/feed.rs`)
- [ ] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not - [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 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 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 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 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 Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
as a parse failure. (`src/feed.rs`) as a parse failure. (`src/feed.rs`)
- [ ] **Show a publisher's error in the UI.** Today a failing feed shows its raw error in red only - [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" 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 (`: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 problem it is and what to do, in plain words: a 404 means the publisher took the feed down or moved
@@ -41,9 +41,17 @@ User-Agent; a browser gets the same answers.
gives `joanwestenberg.com/rss`, which is a 404; the feed is now `joanwestenberg.com/feed`. 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. Nothing for ipx to fix; subscribe to the new URL directly.
The rest are the publishers' doing: 404 (24 feeds), 403 from sites that refuse anything but a ## Other Fixes and Features
browser (5), no longer resolving or connecting (7), and one each of 400 (an invite-only Substack),
401 (a site gone private) and 402 (an expired rss.app trial). Once the first item lands, all of them
but Westenberg stop being scanned.
After these: `cargo test`, `node tests/page-smoke.js`, `npx playwright test`. - [ ] 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
- [ ] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh
- [x] video files play as audio files, they should play as video.
- [ ] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes
- [ ] 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.
## Directory Overhaul
- [ ] Directory needs to be more functional, with categories and a more interesting layout. use /frontend-design to help

View File

@@ -6,6 +6,28 @@ reasoning lives. New write-ups go at the top.
See [README.md](../README.md) for what the thing is. See [README.md](../README.md) for what the thing is.
## 2026-09-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 ## 2026-09-12 — Healthy while busy
After a deploy the container sat at "starting" for a minute, and Docker's health log showed two After a deploy the container sat at "starting" for a minute, and Docker's health log showed two

View File

@@ -22,6 +22,10 @@ CREATE TABLE IF NOT EXISTS feeds (
last_checked INTEGER, last_checked INTEGER,
ttl_mins INTEGER, ttl_mins INTEGER,
last_error TEXT, 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. -- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
orphaned INTEGER NOT NULL DEFAULT 0, orphaned INTEGER NOT NULL DEFAULT 0,
-- The OPML subscription this feed came from. -- 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. // and it came back the same day with `last_login` beside it.
("users", "created", "INTEGER"), ("users", "created", "INTEGER"),
("users", "last_login", "INTEGER"), ("users", "last_login", "INTEGER"),
("feeds", "error_since", "INTEGER"),
]; ];
let retired: &[(&str, &str)] = &[ let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from // 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 orphaned: bool,
pub last_checked: Option<i64>, pub last_checked: Option<i64>,
pub last_error: Option<String>, 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 entries: i64,
pub downloaded: i64, pub downloaded: i64,
} }
@@ -249,7 +256,7 @@ impl Db {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn let mut sum: FeedSummary = conn
.query_row( .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", FROM feeds WHERE id = ?1",
[feed_id], [feed_id],
|r| { |r| {
@@ -259,6 +266,7 @@ impl Db {
last_checked: r.get(2)?, last_checked: r.get(2)?,
last_error: r.get(3)?, last_error: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0, orphaned: r.get::<_, i64>(4)? != 0,
error_since: r.get(5)?,
..Default::default() ..Default::default()
}) })
}, },
@@ -333,7 +341,8 @@ impl Db {
last_checked = excluded.last_checked, last_checked = excluded.last_checked,
ttl_mins = excluded.ttl_mins, ttl_mins = excluded.ttl_mins,
image = coalesce(excluded.image, feeds.image), 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], rusqlite::params![feed_id, url, title, etag, last_modified, now(), ttl_mins.map(|t| t as i64), image],
)?; )?;
Ok(()) Ok(())
@@ -344,7 +353,8 @@ impl Db {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO feeds (id, url, last_checked) VALUES (?1, ?2, ?3) "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()], rusqlite::params![feed_id, url, now()],
)?; )?;
Ok(()) Ok(())
@@ -363,10 +373,15 @@ impl Db {
pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> { pub fn set_feed_error(&self, feed_id: &str, url: &str, msg: &str) -> Result<()> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let now = now();
conn.execute( conn.execute(
"INSERT INTO feeds (id, url, last_checked, last_error) VALUES (?1, ?2, ?3, ?4) "INSERT INTO feeds (id, url, last_checked, last_error, error_since)
ON CONFLICT(id) DO UPDATE SET last_checked = excluded.last_checked, last_error = excluded.last_error", VALUES (?1, ?2, ?3, ?4, ?3)
rusqlite::params![feed_id, url, now(), msg], 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(()) Ok(())
} }
@@ -1766,6 +1781,26 @@ mod tests {
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited"); 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] #[test]
fn enclosure_url_is_the_dedupe_key() { fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();

View File

@@ -87,6 +87,50 @@ pub async fn fetch(
Ok(Fetched::Body { bytes, etag, last_modified }) 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. /// 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 /// 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)), Ok(ch) => Ok(from_rss(ch, bytes)),
Err(rss_err) => match atom_syndication::Feed::read_from(bytes) { Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
Ok(feed) => Ok(from_atom(feed)), 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
// "&amp;". 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
/// (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, or a numeric reference like `&#39;`).
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"&amp;");
} else {
out.push(bytes[i]);
}
i += 1;
}
out
}
/// Every `<enclosure>` of every `<item>`, in document order. /// 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 /// 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] #[test]
fn parses_atom_enclosure_links() { fn parses_atom_enclosure_links() {
let bytes = include_bytes!("../tests/data/atom.xml"); 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&amp;b &lt;x&gt; &#39; &#x2F; c&d");
assert_eq!(out, b"a&amp;b &lt;x&gt; &#39; &#x2F; c&amp;d");
}
#[test] #[test]
fn the_rss_title_always_wins_and_episode_numbers_stay_metadata() { 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 // Some feeds set a different itunes:title. The displayed title is always the RSS

View File

@@ -637,6 +637,7 @@ fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
cfg.save(config_path)?; cfg.save(config_path)?;
// State and files stay: re-adding the feed should not re-download its back catalogue. // State and files stay: re-adding the feed should not re-download its back catalogue.
println!("removed {feed}; downloads and history kept"); println!("removed {feed}; downloads and history kept");
retire_group(ctx, feed)?;
Ok(()) Ok(())
} }
@@ -842,6 +843,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
feed: id.clone(), feed: id.clone(),
reason: "not modified".into(), 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 }) => { Ok(Outcome::Opml { added, removed, kept, total }) => {
ctx.out.emit(Event::FeedSkip { ctx.out.emit(Event::FeedSkip {
feed: id.clone(), 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 continue; // promoted to config at some point; that entry wins
} }
let parent = cfg.feeds.get(&m.group_id); 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 let base = parent
.and_then(|p| p.folder.clone()) .and_then(|p| p.folder.clone())
.or_else(|| ctx.db.feed_summary(&m.group_id).ok().and_then(|s| s.title)) .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) 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. /// Seconds to wait before re-checking a feed.
/// ///
/// A per-feed schedule is an explicit instruction and wins outright. Without one, the /// 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. /// What a scan of one feed turned out to be.
enum Outcome { enum Outcome {
NotModified, 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), Feed(Scan),
/// The URL is a list of feeds rather than a feed: an OPML, or a Patreon creator's shows. /// 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 }, 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), 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" // 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. // URL; sniffing the body also catches one served from a URL without that extension.
if feed::is_opml(&bytes) { 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); let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(true), None)], &feed(), 3);
assert!(p.auto_download); 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");
}
} }

View File

@@ -488,6 +488,9 @@ struct FeedRow {
last_checked: Option<i64>, last_checked: Option<i64>,
next_check: Option<i64>, next_check: Option<i64>,
last_error: Option<String>, 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, entries: i64,
downloaded: i64, downloaded: i64,
unread: i64, unread: i64,
@@ -495,6 +498,15 @@ struct FeedRow {
subscribers: i64, 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( async fn feeds(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
@@ -558,6 +570,12 @@ async fn feeds(
next_check: s next_check: s
.last_checked .last_checked
.map(|t| t + crate::due_after(&cfg, feed, st.ttl_mins) as i64), .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, last_error: s.last_error,
entries: s.entries, entries: s.entries,
downloaded: s.downloaded, downloaded: s.downloaded,
@@ -917,9 +935,13 @@ fn entry_page(
let mut rows = let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)?; 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. // 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 { for row in &mut rows {
if let Some(d) = &row.description { if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d)); row.description = Some(sanitizer.clean(d).to_string());
} }
} }
let total = db.count_in(user_id, feed, filter, search)?; let total = db.count_in(user_id, feed, filter, search)?;
@@ -1147,6 +1169,7 @@ async fn remove_feed(
} }
cfg.save(&state.config_path)?; cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?; state.ctx.reload_cfg(&state.config_path)?;
crate::retire_group(&state.ctx, &id)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -359,7 +359,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. // 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('#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. // Hiding the button is not the guard; the server is.
expect((await page.request.get('/api/users')).status()).toBe(403); expect((await page.request.get('/api/users')).status()).toBe(403);
await expect(page.locator('#logs')).toBeHidden(); await expect(page.locator('#logs')).toBeHidden();
@@ -790,7 +798,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('.ep', { has: page.locator('.kind.here') }).first().click();
await page.locator('#files [data-a="play"]').click(); await page.locator('#files [data-a="play"]').click();
await expect(page.locator('#player')).toBeVisible(); 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(); await page.locator('#pclose').click();
}); });

View File

@@ -252,6 +252,8 @@ a.btn{text-decoration:none;color:inherit}
.btn.danger:hover{border-color:var(--bad);color:var(--bad)} .btn.danger:hover{border-color:var(--bad);color:var(--bad)}
/* An icon in place of a word; the word is in its tooltip. */ /* 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} .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. */ /* 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} .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 /* A file's type as an icon, in place of the old DOWNLOADED / PENDING / audio chips: green once
@@ -337,6 +339,15 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4); padding:9px 16px;box-shadow:0 -6px 24px rgba(6,10,16,.4);
} }
#player.on{display:grid} #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{display:flex;gap:11px;align-items:center;min-width:0;width:250px}
#pnow .art{width:44px;height:44px;font-size:13px} #pnow .art{width:44px;height:44px;font-size:13px}
#pnow .txt{min-width:0} #pnow .txt{min-width:0}
@@ -546,7 +557,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. --> A phone hides the item group, which it has no table for. -->
<div class="tgroup"> <div class="tgroup">
<button id="addFeed" title="Add a feed" aria-label="Add a feed" data-icon="plus"></button> <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> <button id="scanAll" title="Check every feed for new items" aria-label="Check every feed for new items" data-icon="scan"></button>
</div> </div>
<div class="tgroup item"> <div class="tgroup item">
@@ -610,7 +621,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="modal"><div class="card" id="modalCard"></div></div>
<div id="toasts"></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> <script>
'use strict'; 'use strict';
@@ -625,6 +639,7 @@ const fa=(box,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}
const ICON={ 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 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 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 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 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 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
@@ -828,6 +843,9 @@ function renderFeeds(){
const [unread,eps,saved]=mine.length const [unread,eps,saved]=mine.length
? [sum('unread'),sum('entries'),sum('downloaded')] ? [sum('unread'),sum('entries'),sum('downloaded')]
: [f.unread,f.entries,f.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'); const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':''); el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
el.tabIndex=0; el.dataset.id=f.id; el.tabIndex=0; el.dataset.id=f.id;
@@ -839,6 +857,7 @@ function renderFeeds(){
`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+ `${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`+
`</small></div>`+ `</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+ (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>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); nav(false); }; el.onclick=()=>{ selectFeed(f.id); nav(false); };
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
@@ -851,6 +870,21 @@ function selectFeed(id){
renderFeeds(); renderFeed(); loadEntries(); 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 ---------------- */ /* ---------------- feed page ---------------- */
function renderFeed(){ function renderFeed(){
const box=$('#content'); const box=$('#content');
@@ -874,7 +908,7 @@ function renderFeed(){
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${ <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)}${ 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.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 ${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>`:''} 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>`:''} ${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
@@ -884,7 +918,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="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="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" 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>` : ` </div>` : `
<div class="fhead slim"> <div class="fhead slim">
@@ -919,6 +953,7 @@ function renderFeed(){
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); $$('#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;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 /// The item table's headings, each a button that sorts by its column. The first click goes the
@@ -957,7 +992,7 @@ function renderGroup(f,kids){
<h2>${esc(f.title||f.id)}</h2> <h2>${esc(f.title||f.id)}</h2>
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'} <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> · ${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 ${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>`:''} listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
</div> </div>
@@ -965,7 +1000,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 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="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" 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> </div>
<div class="toolbar"> <div class="toolbar">
@@ -974,6 +1009,7 @@ function renderGroup(f,kids){
</div> </div>
<div class="childlist" id="kidlist"></div>`; <div class="childlist" id="kidlist"></div>`;
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
wireFailBanner($('#content'),f);
const draw=()=>{ const draw=()=>{
const q=($('#kidSearch').value||'').trim().toLowerCase(); const q=($('#kidSearch').value||'').trim().toLowerCase();
const box=$('#kidlist'); box.innerHTML=''; const box=$('#kidlist'); box.innerHTML='';
@@ -985,8 +1021,10 @@ function renderGroup(f,kids){
el.innerHTML = artHTML(c.image,c.title||c.id)+ el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+ `<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+ `<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.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>`; `<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
el.onclick=()=>selectFeed(c.id); el.onclick=()=>selectFeed(c.id);
box.appendChild(el); box.appendChild(el);
@@ -1027,7 +1065,15 @@ async function loadEntries(append){
const r=await api(S.feed===':all' ? `/api/entries?${p}` const r=await api(S.feed===':all' ? `/api/entries?${p}`
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`); : `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
S.total=r.total; 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(); renderEntries();
} }
function renderEntries(){ function renderEntries(){
@@ -1348,6 +1394,7 @@ function play(e,enc=e.enclosures.find(isPlayable)){
const resuming = player.guid===e.guid && player.enc===enc.id; const resuming = player.guid===e.guid && player.enc===enc.id;
if(!resuming){ if(!resuming){
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; 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.src=`/media/${enc.id}`;
audio.currentTime=0; audio.currentTime=0;
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
@@ -1402,7 +1449,7 @@ $('#pfwd').onclick=()=>audio.currentTime+=30;
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $('#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)}; $('#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)}; $('#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(){ (function restore(){
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol'); const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
if(r){$('#rate').value=r;audio.playbackRate=+r} if(r){$('#rate').value=r;audio.playbackRate=+r}
@@ -1566,7 +1613,7 @@ async function listFeeds(url){
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+ `<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. // 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>` (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. // Yours already: the row opens it instead.
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; } if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; box.appendChild(el); continue; }
$('[data-a="sub"]',el).onclick=async()=>{ $('[data-a="sub"]',el).onclick=async()=>{
@@ -1645,18 +1692,26 @@ function due(ts){
return 'in '+Math.round(d/86400)+'d'; 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(){ async function prefsModal(){
const g = await api('/api/settings'); const g = await api('/api/settings');
const gs = splitEvery(g.every_mins); const gs = splitEvery(g.every_mins);
const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3> openModal(`<h3>Settings</h3>
<div class="field"><label>Check feeds every</label> <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}"> <input type="number" id="gnum" min="1" max="999" value="${gs.n}">
<select id="gunit">${unitOptions(gs.u)}</select> <select id="gunit">${unitOptions(gs.u)}</select>
</div> </div>
<span class="hint">Applies to every feed that does not set its own. A feed's suggested <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> interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span>`
<div class="field"><label>Max new downloads per scan, per feed</label> :`<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}"> <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 <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 inside an OPML subscription. <b>0 means unlimited</b>, which will pull a whole back
@@ -1671,7 +1726,7 @@ async function prefsModal(){
<span class="hint">Over this, the oldest played items are deleted first. Kept <span class="hint">Over this, the oldest played items are deleted first. Kept
items are never touched.</span></div> items are never touched.</span></div>
<div class="field"><label>Delete items older than (days, 0 = keep)</label> <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> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
<div class="field"><label>Subscriptions</label> <div class="field"><label>Subscriptions</label>
@@ -1682,12 +1737,13 @@ async function prefsModal(){
</div> </div>
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import <span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
subscribes you to every feed in one.</span></div> 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> <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> <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> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button></div>`); ${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
if(!admin) return;
$('#gusers').onclick=usersModal; $('#gusers').onclick=usersModal;
$('#gsave').onclick=async()=>{ $('#gsave').onclick=async()=>{
try{ try{
@@ -1751,7 +1807,7 @@ async function usersModal(){
}; };
} }
function settingsModal(f){ function settingsModal(f,newUrl){
const isGroup = S.feeds.some(c=>c.group===f.id); const isGroup = S.feeds.some(c=>c.group===f.id);
openModal(`<h3>${esc(f.title||f.id)}</h3> 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 ${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
@@ -1772,7 +1828,7 @@ function settingsModal(f){
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label> <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="field"><label>Feed URL</label>
<div class="inline"> <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> <button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
</div> </div>
<span class="hint">${S.me&&S.me.admin <span class="hint">${S.me&&S.me.admin
@@ -1829,7 +1885,7 @@ function removeFeed(f){
reading it keeps it, along with their own read state. 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> 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> <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()=>{ $('#rgo').onclick=async()=>{
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'}); await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
closeModal(); toast('Unsubscribed'); S.feed=null; closeModal(); toast('Unsubscribed'); S.feed=null;
@@ -1875,9 +1931,10 @@ $('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); locat
api('/api/me').then(u=>{ api('/api/me').then(u=>{
S.me=u; S.me=u;
$('#who').textContent=u.name+(u.admin?' · admin':''); $('#who').textContent=u.name+(u.admin?' · admin':'');
// Scanning, quotas, accounts and the log are the operator's business. The server refuses // The log names every account and every failed sign-in; that alone is the operator's
// them too; hiding the buttons just stops offering what would fail. // business, and the server refuses it from anyone else. Settings stays -- it opens the
if(!u.admin) $('#admintools').hidden=true; // same modal for everyone, just without the admin-only fields (see prefsModal).
if(!u.admin) $('#logs').hidden=true;
}).catch(()=>{}); }).catch(()=>{});
$('#logs').onclick=logsModal; $('#logs').onclick=logsModal;
$('#feedFilter').oninput=renderFeeds; $('#feedFilter').oninput=renderFeeds;