13 Commits

Author SHA1 Message Date
b5ff57ac0f Release 0.8.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:13:06 +00:00
35b57fa997 Settings and the shortcuts list close from the corner
Both have nothing to confirm, so their only button was a lone X at the
foot of the card, below the fold of a long Settings card. A dialog with a
confirm keeps Cancel beside it at the bottom, where the pair belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:45:02 +00:00
43ffafe7ac A UI pass: contrast in every theme, and a sign-in page that fits
Measured every theme's palette against WCAG AA. The unread badge's count
on its --accent2 fill fell as low as 2.6:1 (Flat Remix light), and the
unread dot with it; each theme's --accent2 is taken down until white on
it clears 4.5. Tags were --warn or --bad on --raise, short of AA in most
light themes, so they are outlined on the row's own ground instead.

Nordic dark had --line equal to --panel2, so bordered buttons on a
panel2 ground drew no edge at all. Classic's selected row left the
row's icon buttons grey on the blue. A zero badge on a selected row was
--raise on --raise and vanished.

login.html never had a doctype or viewport meta, so it rendered in
quirks mode and at desktop width on a phone, and its light palette sat
under data-theme="light", which nothing ever set. It follows
prefers-color-scheme now, signed out having no account to ask.

Dropped the unused log and users icons. The Classic theme's label is
now just "Classic".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:37:21 +00:00
45cbd3a239 The scan spinner takes the unread count's place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:21:49 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
5f6bdbfbcb Release 0.8.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:31:56 +00:00
8849ba6bef Merge config-db: the feed catalogue and server settings in the database (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:29:16 +00:00
1cafd8d6e3 Keep the feed catalogue and server settings in the database
Phase 3 of #18. Two tables: catalogue (each feed's config::Feed as JSON, so a
new feed setting needs no column) and settings (general: the five server
settings the admin page edits). config.toml keeps what is needed before the
database is reached, or decides who gets in: paths, [torrent], [web].

ipx still runs from one in-memory Config, assembled at start from both
(assemble_config). The eight places that saved config.toml and re-read it now
call Ctx::store_cfg, which writes the database and swaps the copy in memory; the
first-run web token, which is config.toml's, is written there.

The first start on a database with no catalogue imports config.toml's feeds and
settings in one transaction whose first insert is the settings row, so two ipx
starting at once cannot both import; it then trims config.toml, keeping the
original as config.toml.pre-database. After that, feeds written into the file are
ignored with a warning. copy-db skips it, and copies both tables.

Rehearsed on a clone of production's database with production's config: all 130
feeds imported, the file trimmed, and the feed list, settings and directory
identical to the live server's.

Postgres connections now ask for no notices. Every CREATE ... IF NOT EXISTS on an
existing table sends one, eleven per open; sqlx logs them, and
tracing-subscriber 0.3.23's per-layer filters then dropped the next line ipx
logged -- the import's own message went missing that way. Proved by toggling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:28:49 +00:00
f09bb4a11c Revert pinned items rising to the top of their list
Sorting by the pin column, or the Pinned tab, was enough. order_sql loses its
pinned_first option, pinning no longer reloads the list, and the tests and
changelog line for #35 go. The NULLS FIRST/LAST ordering from the Postgres work
stays.

Closes #36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:08:37 +00:00
973ebdd33a The database URL's env file lives beside the compose file
Arcane runs compose in its own container, where /mnt/fast/appdata does not
exist, so an absolute env_file path there failed its update with 'env file not
found'. The file is now ipodderx.env in the content project, referred to
relatively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:56:17 +00:00
bc7491a377 docker-compose.yml: the database URL, as production has it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:45:51 +00:00
c8df148546 Merge seaorm: the database through SeaORM, on SQLite or Postgres (#18)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:42:27 +00:00
c1c06229c8 Docs: Postgres in production
Where the database now is and how to reach it, IPX_DATABASE_URL and
IPX_TEST_DATABASE_URL, copy-db, backups, and the title sort on Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:42:27 +00:00
24 changed files with 636 additions and 168 deletions

View File

@@ -7,14 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.8.1] - 2026-09-19
### Changed ### Changed
- The database is reached through SeaORM, on the way to Postgres (issue #18); it is still the - A feed being checked shows a small spinner in place of its unread count (and its folder's),
same SQLite file, and nothing you see changes. A database from before 0.7 has to be opened by a instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
0.7 release first, which brings its tables up to date. "Downloaded" toast is only for a file on your screen.
- A pinned item sits at the top of its list, above everything else in whatever order you sort - "Check every feed" checks every feed you subscribe to, not every feed on the server.
by, and moves there the moment you pin it. Sorting by the pin column itself still goes both - The Classic theme is listed in Settings as just "Classic".
ways, and Currently Listening keeps its own order. - Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
### Fixed
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
drawing at desktop width.
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
- In Nordic's dark half, button and toolbar borders show.
- A zero unread count on the selected feed no longer disappears into the selection.
## [0.8.0] - 2026-09-18
### Added
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
across, everything in one go.
- The catalogue of feeds and the server settings the admin page edits are kept in the database
rather than config.toml, which keeps where things are, the torrent settings and who may sign
in. The first start takes them from config.toml and trims it, keeping the original as
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
### Changed
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
be opened by a 0.7 release first, which brings its tables up to date.
## [0.7.0] - 2026-09-18 ## [0.7.0] - 2026-09-18
@@ -493,6 +524,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `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.6.1...main [unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
[0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0 [0.7.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...v0.7.0
[0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1 [0.6.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.0...v0.6.1
[0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0 [0.6.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...v0.6.0

View File

@@ -13,8 +13,9 @@ Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That
| | Host | In the container | | | Host | In the container |
|---|---|---| |---|---|---|
| Image | `192.168.1.130:5000/ipodderx:latest` | | | Image | `192.168.1.130:5000/ipodderx:latest` | |
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` | | Config | `/mnt/fast/appdata/ipodderx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` | | Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipodderx.env` beside the compose file (`/mnt/fast/arcane/projects/content/ipodderx.env`, mode 600), passed to the container as `IPX_DATABASE_URL`. A relative `env_file`: Arcane runs compose in its own container, where `/mnt/fast/appdata` does not exist | |
| Old database | `/mnt/user/ipodderx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` | | Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` | | Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway | | Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
@@ -126,9 +127,17 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged` * **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
and `position` columns from before accounts; two bugs came from queries still reading them and `position` columns from before accounts; two bugs came from queries still reading them
(retention, and the entry pruner), and they were dropped in 0.5. (retention, and the entry pruner), and they were dropped in 0.5.
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once; * **The catalogue and the server settings are in the database, not config.toml** (issue #18):
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
and who gets in, the database for the rest (`assemble_config`); a change goes through
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are `subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
derived and never written to config. derived and never in the catalogue.
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`). * **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
Anyone wanting an item is enough to fetch it. Anyone wanting an item is enough to fetch it.
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the * **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the

2
Cargo.lock generated
View File

@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.7.0" version = "0.8.1"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

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

View File

@@ -8,6 +8,9 @@ services:
PGID: "100" PGID: "100"
TZ: "America/Toronto" TZ: "America/Toronto"
IPX_LOG: "ipx=info" IPX_LOG: "ipx=info"
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
env_file:
- ipodderx.env # relative: Arcane resolves it inside its own container
ports: ports:
- "8099:8099" # web UI - "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers - "6881:6881/tcp" # BitTorrent peers

View File

@@ -1,7 +1,19 @@
# Configuration # Configuration
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml` who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
`media_types`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
take effect without a restart.
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
ignored, with a warning in the log saying so. The sections below describe them as they were
written in config.toml, which is still how a fresh install begins.
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`. (`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
| What | Where | Override | | What | Where | Override |
@@ -11,8 +23,10 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` | | Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
| Downloads | `[general] download_dir` | — | | Downloads | `[general] download_dir` | — |
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db` `~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
while the daemon is stopped, or with `sqlite3 state.db .backup`. Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`. `ipx copy-db <state.db>` copies a
SQLite database into the empty Postgres one `IPX_DATABASE_URL` names.
## `[general]` ## `[general]`
@@ -28,6 +42,9 @@ max_new_per_check = 3 # per feed, per scan. 0 = unlimited
media_types = ["audio", "video"] media_types = ["audio", "video"]
``` ```
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check` and `media_types` move into the
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to * **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI. be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`. * **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
@@ -88,8 +105,9 @@ itself carry a credential. Put TLS in front of it if that matters.
## `[feeds.<id>]` ## `[feeds.<id>]`
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add` Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
derives it from the feed title. feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
```toml ```toml
[feeds.atp] [feeds.atp]
@@ -107,8 +125,8 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
and are the same for everyone. See [users.md](users.md). and are the same for everyone. See [users.md](users.md).
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry. and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
## Environment ## Environment
@@ -116,6 +134,8 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|---|---| |---|---|
| `IPX_CONFIG` | Config file path | | `IPX_CONFIG` | Config file path |
| `IPX_DATA_DIR` | Directory holding `state.db` | | `IPX_DATA_DIR` | Directory holding `state.db` |
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) | | `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view | | `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches | | `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |

View File

@@ -261,21 +261,86 @@ impl Config {
Ok(cfg) Ok(cfg)
} }
pub fn save(&self, path: &Path) -> Result<()> { }
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir) /// What the database keeps of the configuration (issue #18): the server settings the admin page
.with_context(|| format!("creating {}", dir.display()))?; /// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
/// or decides who gets in, and stays in config.toml.
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub struct Stored {
pub schedule: String,
pub max_total_gb: f64,
pub max_age_days: u64,
pub max_new_per_check: usize,
pub media_types: Vec<String>,
}
/// `[general]` keys that live in the database once it holds the configuration.
const STORED_KEYS: [&str; 5] = ["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types"];
impl Stored {
pub fn of(cfg: &Config) -> Self {
let g = &cfg.general;
Self {
schedule: g.schedule.clone(),
max_total_gb: g.max_total_gb,
max_age_days: g.max_age_days,
max_new_per_check: g.max_new_per_check,
media_types: g.media_types.clone(),
} }
let text = toml::to_string_pretty(self)?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
// Passwords may live in here.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
} }
pub fn apply(self, cfg: &mut Config) {
let g = &mut cfg.general;
g.schedule = self.schedule;
g.max_total_gb = self.max_total_gb;
g.max_age_days = self.max_age_days;
g.max_new_per_check = self.max_new_per_check;
g.media_types = self.media_types;
}
}
impl Config {
/// config.toml as it is kept once the database holds the feeds and server settings: the same
/// file without `[feeds]` or the `[general]` keys in `Stored`.
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
let mut v = toml::Value::try_from(self)?;
if let Some(t) = v.as_table_mut() {
t.remove("feeds");
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
for k in STORED_KEYS {
g.remove(k);
}
}
}
write_private(path, &toml::to_string_pretty(&v)?)
}
/// Whether config.toml still lists feeds or server settings, which the database now holds:
/// an edit there would otherwise go unnoticed.
pub fn file_holds_stored(path: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(path) else { return false };
let Ok(v) = text.parse::<toml::Table>() else { return false };
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|| v.get("general")
.and_then(|g| g.as_table())
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
}
}
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
fn write_private(path: &Path, text: &str) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
} }
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`. /// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
@@ -370,6 +435,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
let cfg: Config = toml::from_str(
r#"
[general]
download_dir = "/downloads"
schedule = "every 2h"
max_new_per_check = 7
media_types = ["audio"]
[web]
bind = "0.0.0.0:8099"
token = "t"
[feeds.show]
url = "http://x/show.xml"
"#,
)
.unwrap();
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
cfg.save_bootstrap(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(!Config::file_holds_stored(&path), "{text}");
let back: Config = toml::from_str(&text).unwrap();
assert!(back.feeds.is_empty());
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
std::fs::remove_file(&path).unwrap();
}
#[test] #[test]
fn parses_a_config_and_applies_defaults() { fn parses_a_config_and_applies_defaults() {
let cfg: Config = toml::from_str( let cfg: Config = toml::from_str(

183
src/db.rs
View File

@@ -2,7 +2,7 @@
//! per-feed .ipxd plists, history.dat and qmcache.dat. //! per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use crate::entity::{enclosures, entries, feeds, sessions, subscriptions, users}; use crate::entity::{catalogue, enclosures, entries, feeds, sessions, settings, subscriptions, users};
use sea_orm::sea_query::{Expr, Func}; use sea_orm::sea_query::{Expr, Func};
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
@@ -55,6 +55,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
schema.create_table_from_entity(subscriptions::Entity), schema.create_table_from_entity(subscriptions::Entity),
schema.create_table_from_entity(entry_state::Entity), schema.create_table_from_entity(entry_state::Entity),
schema.create_table_from_entity(sessions::Entity), schema.create_table_from_entity(sessions::Entity),
schema.create_table_from_entity(catalogue::Entity),
schema.create_table_from_entity(settings::Entity),
] { ] {
orm.execute(table.if_not_exists()).await.context("creating the schema")?; orm.execute(table.if_not_exists()).await.context("creating the schema")?;
} }
@@ -100,6 +102,9 @@ pub fn location() -> String {
.unwrap_or_else(|| crate::config::data_dir().join("state.db").display().to_string()) .unwrap_or_else(|| crate::config::data_dir().join("state.db").display().to_string())
} }
/// The Postgres connection option that keeps notices from being sent at all; see `connect`.
const QUIET: &str = "options=-c%20client_min_messages%3Dwarning";
/// A URL fit for a log or an error: the password taken out. /// A URL fit for a log or an error: the password taken out.
fn redact(url: &str) -> String { fn redact(url: &str) -> String {
match (url.find("://"), url.rfind('@')) { match (url.find("://"), url.rfind('@')) {
@@ -118,15 +123,30 @@ fn is_postgres(location: &str) -> bool {
/// sqlx's defaults for SQLite are what ipx wants: foreign keys on, and a five-second wait for a /// sqlx's defaults for SQLite are what ipx wants: foreign keys on, and a five-second wait for a
/// lock, which is what rusqlite was set to. /// lock, which is what rusqlite was set to.
async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> { async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
let url = let mut opts = sea_orm::ConnectOptions::new(url_for(location));
if is_postgres(location) { location.to_owned() } else { format!("sqlite://{location}?mode=rwc") };
let mut opts = sea_orm::ConnectOptions::new(url);
opts.sqlx_logging(false); opts.sqlx_logging(false);
sea_orm::Database::connect(opts) sea_orm::Database::connect(opts)
.await .await
.with_context(|| format!("opening {}", redact(location))) .with_context(|| format!("opening {}", redact(location)))
} }
/// The URL `connect` hands sqlx for a location: a SQLite file, created if missing, or a Postgres
/// URL with notices turned off.
fn url_for(location: &str) -> String {
if !is_postgres(location) {
format!("sqlite://{location}?mode=rwc")
} else if location.contains("options=") {
location.to_owned() // someone chose their own; theirs stands
} else {
// Warnings and up only. Postgres answers every CREATE ... IF NOT EXISTS on an existing
// table with a notice, eleven on each open; sqlx logs each, no filter here wants them,
// and tracing-subscriber's per-layer filters then swallowed the next line ipx logged
// (0.3.23: "moved the feeds ... into the database" went missing that way).
let sep = if location.contains('?') { '&' } else { '?' };
format!("{location}{sep}{QUIET}")
}
}
/// One person's wants for one feed. `None` in a field means the feed's own setting stands. /// One person's wants for one feed. `None` in a field means the feed's own setting stands.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -280,7 +300,9 @@ impl Db {
admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?; admin.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await?;
// Every connection in the pool starts in it, so each test sees only its own tables. // Every connection in the pool starts in it, so each test sees only its own tables.
let sep = if url.contains('?') { '&' } else { '?' }; let sep = if url.contains('?') { '&' } else { '?' };
let orm = connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}")).await?; let orm =
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"))
.await?;
create_missing(&orm).await?; create_missing(&orm).await?;
return Ok(Self { orm, tmp: None }); return Ok(Self { orm, tmp: None });
} }
@@ -765,10 +787,10 @@ fn entries_from(a: &mut Args, user_id: i64, feed_id: Option<&str>, filter: Filte
/// ///
/// ponytail: file type and size look at the item's first and largest file. The row shows the file /// ponytail: file type and size look at the item's first and largest file. The row shows the file
/// it summarises, which is almost always that one; sort by that one if they ever disagree. /// it summarises, which is almost always that one; sort by that one if they ever disagree.
/// `pinned_first` puts your pinned items above the rest, each part in the order asked for, so a ///
/// pin keeps something at the top of its list (issue #35). Not when sorting by the pin itself, /// Pinned items are not lifted above the rest: sorting by the pin column, or the Pinned tab, does
/// where the direction chosen is the point. /// that when it is wanted, and lifting them always was undone (issues #35 and #36).
pub fn order_sql(col: &str, dir: &str, pinned_first: bool) -> String { pub fn order_sql(col: &str, dir: &str) -> String {
let expr = match col { let expr = match col {
"kept" => "coalesce(s.flagged, false)", "kept" => "coalesce(s.flagged, false)",
"title" => "lower(coalesce(e.title, ''))", "title" => "lower(coalesce(e.title, ''))",
@@ -781,9 +803,8 @@ pub fn order_sql(col: &str, dir: &str, pinned_first: bool) -> String {
// and Postgres as the largest, so "largest first" on Postgres opened with every item that has // and Postgres as the largest, so "largest first" on Postgres opened with every item that has
// no file. NULLS FIRST going up and LAST going down keeps what SQLite did. // no file. NULLS FIRST going up and LAST going down keeps what SQLite did.
let dir = if dir == "asc" { "ASC NULLS FIRST" } else { "DESC NULLS LAST" }; let dir = if dir == "asc" { "ASC NULLS FIRST" } else { "DESC NULLS LAST" };
let pins = if pinned_first && col != "kept" { "coalesce(s.flagged, false) DESC, " } else { "" };
// The guid breaks what ties remain: SQLite's rowid did, and Postgres has none. // The guid breaks what ties remain: SQLite's rowid did, and Postgres has none.
format!("{pins}{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.guid DESC") format!("{expr} {dir}, coalesce(e.published, e.first_seen) DESC, e.guid DESC")
} }
/// Which slice of a feed the UI is asking for. /// Which slice of a feed the UI is asking for.
@@ -964,6 +985,76 @@ impl Db {
Ok(()) Ok(())
} }
// ---- the configuration: the catalogue and the server settings ----
/// The feeds and server settings, once the database holds them; None before, when they are
/// still config.toml's.
pub async fn stored_config(
&self,
) -> Result<Option<(crate::config::Stored, std::collections::BTreeMap<String, crate::config::Feed>)>> {
let Some(general) = settings::Entity::find_by_id("general".to_owned()).one(&self.orm).await? else {
return Ok(None);
};
let stored = serde_json::from_str(&general.value).context("reading the stored server settings")?;
let mut feeds = std::collections::BTreeMap::new();
for row in catalogue::Entity::find().all(&self.orm).await? {
let feed = serde_json::from_str(&row.spec).with_context(|| format!("reading feed {}", row.id))?;
feeds.insert(row.id, feed);
}
Ok(Some((stored, feeds)))
}
/// Takes the feeds and server settings from `cfg` (config.toml, as read) into a database that
/// has none. False when it already had them: another process got there first, and its copy
/// stands. One transaction, and the settings row goes in first, so two processes starting at
/// once cannot both import.
pub async fn import_config(&self, cfg: &crate::config::Config) -> Result<bool> {
use sea_orm::TransactionTrait;
let backend = self.orm.get_database_backend();
let tx = self.orm.begin().await?;
let claimed = tx
.execute_raw(Statement::from_sql_and_values(
backend,
"INSERT INTO settings (name, value) VALUES ('general', $1) ON CONFLICT DO NOTHING",
vec![serde_json::to_string(&crate::config::Stored::of(cfg))?.into()],
))
.await?
.rows_affected();
if claimed == 0 {
return Ok(false); // dropped, so rolled back
}
for (id, feed) in &cfg.feeds {
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
.insert(&tx)
.await?;
}
tx.commit().await?;
Ok(true)
}
/// Writes the feeds and server settings as they now stand: what config.toml's save did.
/// The whole catalogue at once, in one transaction, so a feed removed goes too.
pub async fn store_config(&self, cfg: &crate::config::Config) -> Result<()> {
use sea_orm::TransactionTrait;
let backend = self.orm.get_database_backend();
let tx = self.orm.begin().await?;
tx.execute_raw(Statement::from_sql_and_values(
backend,
"INSERT INTO settings (name, value) VALUES ('general', $1)
ON CONFLICT (name) DO UPDATE SET value = excluded.value",
vec![serde_json::to_string(&crate::config::Stored::of(cfg))?.into()],
))
.await?;
catalogue::Entity::delete_many().exec(&tx).await?;
for (id, feed) in &cfg.feeds {
catalogue::ActiveModel { id: Set(id.clone()), spec: Set(serde_json::to_string(feed)?) }
.insert(&tx)
.await?;
}
tx.commit().await?;
Ok(())
}
// ---- moving to another database ---- // ---- moving to another database ----
/// Copies every row of `from` into this database, which must be empty: the move from the /// Copies every row of `from` into this database, which must be empty: the move from the
@@ -986,6 +1077,8 @@ impl Db {
("subscriptions", copy_table::<subscriptions::Entity>(&from.orm, &tx).await?), ("subscriptions", copy_table::<subscriptions::Entity>(&from.orm, &tx).await?),
("entry_state", copy_table::<entry_state::Entity>(&from.orm, &tx).await?), ("entry_state", copy_table::<entry_state::Entity>(&from.orm, &tx).await?),
("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?), ("sessions", copy_table::<sessions::Entity>(&from.orm, &tx).await?),
("catalogue", copy_table::<catalogue::Entity>(&from.orm, &tx).await?),
("settings", copy_table::<settings::Entity>(&from.orm, &tx).await?),
]; ];
if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres { if self.orm.get_database_backend() == sea_orm::DbBackend::Postgres {
// The copied ids came with the rows; the counters that hand out new ones start past // The copied ids came with the rows; the counters that hand out new ones start past
@@ -1729,7 +1822,7 @@ mod tests {
).await ).await
.unwrap(); .unwrap();
let order = async |col: &str, dir: &str| -> Vec<String> { let order = async |col: &str, dir: &str| -> Vec<String> {
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir, false)).await db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir)).await
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|e| e.guid) .map(|e| e.guid)
@@ -1751,20 +1844,11 @@ mod tests {
assert_eq!(order("published", "desc").await, ["c", "b", "a"]); assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap(); db.set_entry_flag(1, "f", "a", EntryFlag::Flagged, true).await.unwrap();
assert_eq!(order("kept", "desc").await[0], "a"); assert_eq!(order("kept", "desc").await[0], "a");
// Pinned first: the pinned banana tops every sort, the rest in the order asked for. // A pin does not lift an item above the others in any other sort (#36).
let pinned = async |col: &str, dir: &str| -> Vec<String> { assert_eq!(order("published", "desc").await, ["c", "b", "a"]);
db.entries_in(1, None, Filter::All, None, 0, 50, &order_sql(col, dir, true)).await
.unwrap()
.into_iter()
.map(|e| e.guid)
.collect()
};
assert_eq!(pinned("published", "desc").await, ["a", "c", "b"]);
assert_eq!(pinned("title", "desc").await, ["a", "c", "b"]);
assert_eq!(pinned("kept", "asc").await[2], "a", "sorting by the pin itself keeps its direction");
// An unknown column or direction is newest first; the name itself never reaches the SQL. // An unknown column or direction is newest first; the name itself never reaches the SQL.
assert_eq!(order("title; DROP TABLE entries", "sideways").await, ["c", "b", "a"]); assert_eq!(order("title; DROP TABLE entries", "sideways").await, ["c", "b", "a"]);
assert!(!order_sql("x'; --", "asc", false).contains("x'")); assert!(!order_sql("x'; --", "asc").contains("x'"));
} }
#[tokio::test] #[tokio::test]
@@ -1813,7 +1897,7 @@ mod tests {
// Starring and position are just as private. // Starring and position are just as private.
db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).await.unwrap(); db.set_entry_flag(1, "f", "b", EntryFlag::Flagged, true).await.unwrap();
db.set_position(2, "f", "b", 42, Some(600)).await.unwrap(); db.set_position(2, "f", "b", 42, Some(600)).await.unwrap();
let order = order_sql("published", "desc", false); let order = order_sql("published", "desc");
let page = async |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).await.unwrap(); let page = async |user| db.entries_in(user, Some("f"), Filter::All, None, 0, 50, &order).await.unwrap();
let (ray, sam) = (page(1).await, page(2).await); let (ray, sam) = (page(1).await, page(2).await);
let ray_b = ray.iter().find(|e| e.guid == "b").unwrap(); let ray_b = ray.iter().find(|e| e.guid == "b").unwrap();
@@ -1927,7 +2011,7 @@ mod tests {
for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] { for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] {
// Both paths must run without erroring, and agree with each other. // Both paths must run without erroring, and agree with each other.
let order = order_sql("published", "desc", false); let order = order_sql("published", "desc");
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).await.unwrap(); let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).await.unwrap();
let n = db.count_in(7, Some("f"), f, None).await.unwrap(); let n = db.count_in(7, Some("f"), f, None).await.unwrap();
assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page"); assert_eq!(rows.len() as i64, n, "{f:?} count disagrees with the page");
@@ -1946,7 +2030,7 @@ mod tests {
"search is case-insensitive and covers the description"); "search is case-insensitive and covers the description");
// Currently Listening: started, not finished, and not just an accidental tap. // Currently Listening: started, not finished, and not just an accidental tap.
let order = order_sql("published", "desc", false); let order = order_sql("published", "desc");
let listening = async || { let listening = async || {
let rows = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order).await.unwrap(); let rows = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order).await.unwrap();
rows.into_iter().map(|e| e.guid).collect::<Vec<_>>() rows.into_iter().map(|e| e.guid).collect::<Vec<_>>()
@@ -2087,6 +2171,51 @@ mod tests {
assert!(db.pinned_feeds(me).await.unwrap().is_empty()); assert!(db.pinned_feeds(me).await.unwrap().is_empty());
} }
#[test]
fn postgres_is_asked_for_no_notices_and_a_password_never_reaches_a_log() {
assert_eq!(url_for("postgres://u:p@h/d"), format!("postgres://u:p@h/d?{QUIET}"));
assert_eq!(url_for("postgres://u:p@h/d?sslmode=disable"), format!("postgres://u:p@h/d?sslmode=disable&{QUIET}"));
assert_eq!(url_for("postgres://u:p@h/d?options=-c%20x%3Dy"), "postgres://u:p@h/d?options=-c%20x%3Dy", "theirs stands");
assert_eq!(url_for("/data/state.db"), "sqlite:///data/state.db?mode=rwc");
assert_eq!(redact("postgres://ipodderx:s3cret@h:5433/ipodderx"), "postgres://ipodderx:***@h:5433/ipodderx");
}
#[tokio::test]
async fn the_configuration_goes_in_once_and_comes_back_as_it_went() {
let db = Db::memory().await.unwrap();
assert!(db.stored_config().await.unwrap().is_none(), "nothing until it is taken in");
let mut cfg: crate::config::Config = toml::from_str(
r#"
[general]
schedule = "every 2h"
max_total_gb = 1.5
[feeds.a]
url = "http://x/a.xml"
keywords = ["one", "two"]
[feeds.b]
url = "http://x/b.xml"
password = "secret"
"#,
)
.unwrap();
assert!(db.import_config(&cfg).await.unwrap());
assert!(!db.import_config(&cfg).await.unwrap(), "a second import finds the first and stands back");
let (stored, feeds) = db.stored_config().await.unwrap().unwrap();
assert_eq!(stored, crate::config::Stored::of(&cfg));
assert_eq!(feeds.keys().collect::<Vec<_>>(), ["a", "b"]);
assert_eq!(feeds["a"].keywords, ["one", "two"]);
assert_eq!(feeds["b"].password.as_deref(), Some("secret"));
// Storing is the whole catalogue: a feed taken out goes, one put in arrives.
cfg.feeds.remove("a");
cfg.feeds.insert("c".into(), cfg.feeds["b"].clone());
cfg.general.schedule = "every 30m".into();
db.store_config(&cfg).await.unwrap();
let (stored, feeds) = db.stored_config().await.unwrap().unwrap();
assert_eq!(stored.schedule, "every 30m");
assert_eq!(feeds.keys().collect::<Vec<_>>(), ["b", "c"]);
}
#[tokio::test] #[tokio::test]
async fn enclosure_url_is_the_dedupe_key() { async fn enclosure_url_is_the_dedupe_key() {
let db = Db::memory().await.unwrap(); let db = Db::memory().await.unwrap();

View File

@@ -242,3 +242,44 @@ pub mod sessions {
owned_by_user!(); owned_by_user!();
} }
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
pub mod catalogue {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "catalogue")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub id: String,
#[sea_orm(column_type = "Text")]
pub spec: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
/// configuration has moved in (issue #18).
pub mod settings {
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
pub name: String,
#[sea_orm(column_type = "Text")]
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}

View File

@@ -88,6 +88,11 @@ pub enum Command {
feed: Option<String>, feed: Option<String>,
#[serde(default)] #[serde(default)]
force: bool, force: bool,
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
/// Empty is every feed, as the schedule and the CLI mean it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
feeds: Vec<String>,
}, },
Reap { Reap {
#[serde(default)] #[serde(default)]
@@ -301,10 +306,10 @@ mod tests {
#[test] #[test]
fn commands_parse_from_the_wire_form() { fn commands_parse_from_the_wire_form() {
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: None, force: false })); assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp")); assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap(); let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
assert!(matches!(got, Command::Reap { dry_run: true })); assert!(matches!(got, Command::Reap { dry_run: true }));

View File

@@ -144,13 +144,19 @@ impl Ctx {
} }
/// Re-reads config.toml into the live snapshot. /// Re-reads config.toml into the live snapshot.
pub fn reload_cfg(&self, path: &std::path::Path) -> Result<()> { /// Keeps a changed catalogue or server settings: in the database, and for everything running
let fresh = config::Config::load(path)?; /// here from now on. What config.toml's save and reload did, before the database held them.
*self.cfg.write().unwrap() = std::sync::Arc::new(fresh); pub async fn store_cfg(&self, cfg: config::Config) -> Result<()> {
tracing::info!("config reloaded"); self.db.store_config(&cfg).await?;
self.set_cfg(cfg);
Ok(()) Ok(())
} }
fn set_cfg(&self, cfg: config::Config) {
*self.cfg.write().unwrap() = std::sync::Arc::new(cfg);
tracing::info!("config reloaded");
}
async fn torrents(&self) -> Result<&torrent::Torrents> { async fn torrents(&self) -> Result<&torrent::Torrents> {
let cfg = self.cfg(); let cfg = self.cfg();
self.torrents self.torrents
@@ -191,7 +197,7 @@ async fn main() -> Result<()> {
// A daemon owns the state; don't have two processes downloading the same thing. // A daemon owns the state; don't have two processes downloading the same thing.
let wire_cmd = match &cli.command { let wire_cmd = match &cli.command {
Command::Fetch { feed, force } => { Command::Fetch { feed, force } => {
Some(Cmd::Fetch { feed: feed.clone(), force: *force }) Some(Cmd::Fetch { feed: feed.clone(), force: *force, feeds: vec![] })
} }
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }), Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status), Command::Status => Some(Cmd::Status),
@@ -211,6 +217,14 @@ async fn main() -> Result<()> {
return ipc::proxy(&cfg.general.socket, cmd).await; return ipc::proxy(&cfg.general.socket, cmd).await;
} }
// copy-db fills an empty database from another, configuration included; taking config.toml
// into it first would have the copy collide with it.
let cfg = if matches!(cli.command, Command::CopyDb { .. }) {
cfg
} else {
assemble_config(&db, cfg, &config_path).await?
};
let is_daemon = matches!(cli.command, Command::Daemon { .. }); let is_daemon = matches!(cli.command, Command::Daemon { .. });
let (events, _) = broadcast::channel(1024); let (events, _) = broadcast::channel(1024);
let ctx = Arc::new(Ctx { let ctx = Arc::new(Ctx {
@@ -227,14 +241,14 @@ async fn main() -> Result<()> {
}); });
match cli.command { match cli.command {
Command::List => list(&ctx, &config_path).await, Command::List => list(&ctx).await,
Command::Daemon { web } => daemon(ctx, config_path, web, events).await, Command::Daemon { web } => daemon(ctx, config_path, web, events).await,
Command::Add { url, folder, keywords } => { Command::Add { url, folder, keywords } => {
add(&ctx, &config_path, &url, folder, keywords).await add(&ctx, &url, folder, keywords).await
} }
Command::Rm { feed } => rm(&ctx, &config_path, &feed).await, Command::Rm { feed } => rm(&ctx, &feed).await,
Command::User { cmd } => user_cmd(&ctx, cmd).await, Command::User { cmd } => user_cmd(&ctx, cmd).await,
Command::Import { file } => import(&ctx, &config_path, &file).await, Command::Import { file } => import(&ctx, &file).await,
Command::Export { file } => export(&ctx, &file).await, Command::Export { file } => export(&ctx, &file).await,
Command::CopyDb { from } => copy_db(&ctx, &from).await, Command::CopyDb { from } => copy_db(&ctx, &from).await,
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, _ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
@@ -340,10 +354,10 @@ async fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> { async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
match cmd { match cmd {
Cmd::Fetch { feed, force } => { Cmd::Fetch { feed, force, feeds } => {
// Make room before pulling more down, as the original did per download. // Make room before pulling more down, as the original did per download.
reap(ctx, false, false).await?; reap(ctx, false, false).await?;
fetch(ctx, feed.as_deref(), force).await fetch(ctx, feed.as_deref(), force, &feeds).await
} }
Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await, Cmd::Reap { dry_run } => reap(ctx, dry_run, true).await,
Cmd::Download { enclosure } => download_one(ctx, enclosure).await, Cmd::Download { enclosure } => download_one(ctx, enclosure).await,
@@ -496,7 +510,7 @@ async fn daemon(
} }
_ = ticker.tick() => { _ = ticker.tick() => {
// Per-feed schedule and TTL decide what actually gets polled. // Per-feed schedule and TTL decide what actually gets polled.
let job = run(&ctx, Cmd::Fetch { feed: None, force: false }); let job = run(&ctx, Cmd::Fetch { feed: None, force: false, feeds: vec![] });
if !until_stopped(&ctx, &rx_stop, job).await { if !until_stopped(&ctx, &rx_stop, job).await {
break; break;
} }
@@ -533,8 +547,9 @@ async fn start_web(
fresh.web.enabled = true; fresh.web.enabled = true;
fresh.web.bind = bind.clone(); fresh.web.bind = bind.clone();
fresh.web.token = crate::auth::new_session_token(); fresh.web.token = crate::auth::new_session_token();
fresh.save(config_path)?; // The token is config.toml's, not the database's: it decides who gets in.
ctx.reload_cfg(config_path)?; fresh.save_bootstrap(config_path)?;
ctx.set_cfg(fresh.clone());
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token); println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
} else { } else {
println!( println!(
@@ -549,7 +564,6 @@ async fn start_web(
let state = web::WebState { let state = web::WebState {
ctx: ctx.clone(), ctx: ctx.clone(),
config_path: config_path.to_path_buf(),
cmds: cmds.clone(), cmds: cmds.clone(),
events: events.clone(), events: events.clone(),
}; };
@@ -575,7 +589,6 @@ async fn shutdown() {
/// Subscribes to one feed, naming it from its own title. /// Subscribes to one feed, naming it from its own title.
async fn add( async fn add(
ctx: &Ctx, ctx: &Ctx,
config_path: &std::path::Path,
url: &str, url: &str,
folder: Option<String>, folder: Option<String>,
keywords: Vec<String>, keywords: Vec<String>,
@@ -587,7 +600,7 @@ async fn add(
anyhow::bail!("already subscribed as {:?}", existing.id); anyhow::bail!("already subscribed as {:?}", existing.id);
} }
let id = add_one(ctx, &mut cfg, url, folder, keywords).await?; let id = add_one(ctx, &mut cfg, url, folder, keywords).await?;
cfg.save(config_path)?; ctx.store_cfg(cfg).await?;
println!("added {id}"); println!("added {id}");
Ok(()) Ok(())
} }
@@ -659,7 +672,7 @@ fn url_stem(url: &str) -> String {
.unwrap_or_else(|| url.to_owned()) .unwrap_or_else(|| url.to_owned())
} }
async fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> { async fn rm(ctx: &Ctx, feed: &str) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone(); let mut cfg = (*ctx.cfg()).clone();
if cfg.feeds.remove(feed).is_none() { if cfg.feeds.remove(feed).is_none() {
// Derived from an OPML: drop it here, though the subscription will list it again // Derived from an OPML: drop it here, though the subscription will list it again
@@ -668,14 +681,14 @@ async fn rm(ctx: &Ctx, config_path: &std::path::Path, feed: &str) -> Result<()>
println!("removed {feed}; it came from an OPML subscription and may return on the next read"); println!("removed {feed}; it came from an OPML subscription and may return on the next read");
return Ok(()); return Ok(());
} }
cfg.save(config_path)?; ctx.store_cfg(cfg).await?;
// 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).await?; retire_group(ctx, feed).await?;
Ok(()) Ok(())
} }
async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> { async fn import(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
let text = std::fs::read_to_string(file) let text = std::fs::read_to_string(file)
.with_context(|| format!("reading {}", file.display()))?; .with_context(|| format!("reading {}", file.display()))?;
// The CLI speaks for the operator, as the shared web token does. // The CLI speaks for the operator, as the shared web token does.
@@ -687,7 +700,7 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
.ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?; .ok_or_else(|| anyhow::anyhow!("no admin account to subscribe: ipx user add <name> --admin"))?;
let doc = opml::OPML::from_str(&text) let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?; .map_err(|e| anyhow::anyhow!("{} is not OPML: {e}", file.display()))?;
let (added, had) = subscribe_opml(ctx, config_path, &doc, admin.id).await?; let (added, had) = subscribe_opml(ctx, &doc, admin.id).await?;
println!("subscribed {} to {added} feed(s); {had} already there", admin.name); println!("subscribed {} to {added} feed(s); {had} already there", admin.name);
Ok(()) Ok(())
} }
@@ -704,7 +717,6 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
/// before anything is touched: a 400 from the web, a message from the CLI. /// before anything is touched: a 400 from the web, a message from the CLI.
pub async fn subscribe_opml( pub async fn subscribe_opml(
ctx: &Ctx, ctx: &Ctx,
config_path: &std::path::Path,
doc: &opml::OPML, doc: &opml::OPML,
user_id: i64, user_id: i64,
) -> Result<(usize, usize)> { ) -> Result<(usize, usize)> {
@@ -751,8 +763,7 @@ pub async fn subscribe_opml(
ids.push(id); ids.push(id);
} }
if grew { if grew {
cfg.save(config_path)?; ctx.store_cfg(cfg).await?;
ctx.reload_cfg(config_path)?;
} }
let (mut added, mut had) = (0, 0); let (mut added, mut had) = (0, 0);
@@ -778,6 +789,40 @@ pub fn collect_outlines(outlines: &[opml::Outline], out: &mut Vec<(String, Strin
} }
} }
/// The configuration ipx runs with: config.toml for where things are and who may sign in, the
/// database for the feeds and the server settings (issue #18). The first time a database holds
/// neither, it takes them from config.toml, which is then cut down to the rest, the original kept
/// beside it as config.toml.pre-database.
async fn assemble_config(db: &db::Db, mut cfg: config::Config, path: &std::path::Path) -> Result<config::Config> {
for _ in 0..2 {
if let Some((stored, feeds)) = db.stored_config().await? {
if config::Config::file_holds_stored(path) {
tracing::warn!(
"config.toml still lists feeds or server settings; they are ignored, since the \
database holds them now. Change them in the web UI, or with ipx add and rm."
);
}
stored.apply(&mut cfg);
cfg.feeds = feeds;
return Ok(cfg);
}
if db.import_config(&cfg).await? {
if path.exists() {
let original = path.with_extension("toml.pre-database");
if !original.exists() {
std::fs::copy(path, &original)
.with_context(|| format!("keeping the original as {}", original.display()))?;
}
cfg.save_bootstrap(path)?;
}
tracing::info!(feeds = cfg.feeds.len(), "moved the feeds and server settings from config.toml into the database");
return Ok(cfg);
}
// Another ipx imported between our look and our insert; the next pass reads its copy.
}
anyhow::bail!("the database says it holds the configuration and then that it does not")
}
async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> { async fn copy_db(ctx: &Ctx, from: &std::path::Path) -> Result<()> {
anyhow::ensure!(from.exists(), "{} does not exist", from.display()); anyhow::ensure!(from.exists(), "{} does not exist", from.display());
let source = db::Db::open(&from.display().to_string()).await?; let source = db::Db::open(&from.display().to_string()).await?;
@@ -808,10 +853,10 @@ async fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
Ok(()) Ok(())
} }
async fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> { async fn list(ctx: &Ctx) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
if cfg.feeds.is_empty() { if cfg.feeds.is_empty() {
println!("No feeds configured in {}", config_path.display()); println!("No feeds configured. Add one with `ipx add <url>`, or in the web UI.");
return Ok(()); return Ok(());
} }
for (id, feed) in &cfg.feeds { for (id, feed) in &cfg.feeds {
@@ -847,7 +892,8 @@ async fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
Ok(()) Ok(())
} }
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> { /// `scope`, when not empty, narrows the scan to those feeds and the feeds inside any of them.
async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool, scope: &[String]) -> Result<()> {
let cfg = ctx.cfg(); let cfg = ctx.cfg();
let subs = subscriptions(ctx).await?; let subs = subscriptions(ctx).await?;
if let Some(id) = only if let Some(id) = only
@@ -858,7 +904,10 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
let mut scanned = 0; let mut scanned = 0;
let mut fresh: Vec<String> = vec![]; let mut fresh: Vec<String> = vec![];
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id)) { let in_scope = |s: &Sub| {
scope.is_empty() || scope.contains(&s.id) || s.cfg.group.as_ref().is_some_and(|g| scope.contains(g))
};
for sub in subs.iter().filter(|s| only.is_none_or(|o| o == s.id) && in_scope(s)) {
let (id, feed_cfg) = (&sub.id, &sub.cfg); let (id, feed_cfg) = (&sub.id, &sub.cfg);
let state = ctx.db.http_state(id).await?; let state = ctx.db.http_state(id).await?;
@@ -1670,6 +1719,32 @@ fn duration(secs: u64) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn the_first_start_moves_the_configuration_in_and_trims_the_file() {
let dir = std::env::temp_dir().join(format!("ipx-assemble-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(
&path,
"[general]\nschedule = \"every 2h\"\n[web]\ntoken = \"t\"\n[feeds.show]\nurl = \"http://x/show.xml\"\n",
)
.unwrap();
let db = db::Db::memory().await.unwrap();
let first = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
assert_eq!(first.feeds.keys().collect::<Vec<_>>(), ["show"]);
assert_eq!(first.general.schedule, "every 2h");
assert!(dir.join("config.toml.pre-database").exists(), "the original is kept");
assert!(!config::Config::file_holds_stored(&path), "and the file no longer lists them");
// The next start reads them from the database, the trimmed file notwithstanding.
let next = assemble_config(&db, config::Config::load(&path).unwrap(), &path).await.unwrap();
assert_eq!(next.feeds.keys().collect::<Vec<_>>(), ["show"]);
assert_eq!(next.general.schedule, "every 2h");
assert_eq!(next.web.token, "t");
std::fs::remove_dir_all(&dir).unwrap();
}
fn feed() -> config::Feed { fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees. // Whatever `ipx add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default(); let mut cfg = config::Config::default();

View File

@@ -16,7 +16,6 @@ use serde::Deserialize;
use tower::ServiceExt; use tower::ServiceExt;
use tower_http::services::ServeFile; use tower_http::services::ServeFile;
use serde::Serialize; use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
@@ -28,7 +27,6 @@ const COOKIE: &str = "ipx_token";
#[derive(Clone)] #[derive(Clone)]
pub struct WebState { pub struct WebState {
pub ctx: Arc<Ctx>, pub ctx: Arc<Ctx>,
pub config_path: PathBuf,
pub cmds: mpsc::Sender<Command>, pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>, pub events: broadcast::Sender<Event>,
} }
@@ -1146,12 +1144,7 @@ async fn entry_page(
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all")); let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty()); let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let db = &state.ctx.db; let db = &state.ctx.db;
// Currently Listening keeps its own order, pinned or not: it is what you are part-way through. let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
let order = crate::db::order_sql(
page.sort.as_deref().unwrap_or("published"),
page.dir.as_deref().unwrap_or("desc"),
filter != crate::db::Filter::InProgress,
);
let mut rows = let mut rows =
db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?; db.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order).await?;
let mut sanitizer = feed_sanitizer(); let mut sanitizer = feed_sanitizer();
@@ -1234,8 +1227,7 @@ async fn add_feed(
)); ));
} }
let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?; let id = crate::add_one(&state.ctx, &mut cfg, &url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.db.subscribe(user.id, &id).await?; state.ctx.db.subscribe(user.id, &id).await?;
explicit_on_add(&state, user.id, &id, body.allow_explicit).await?; explicit_on_add(&state, user.id, &id, body.allow_explicit).await?;
scan_soon(&state, Some(id.clone())).await; scan_soon(&state, Some(id.clone())).await;
@@ -1247,7 +1239,7 @@ async fn add_feed(
/// succeeded either way, so a daemon not taking commands is only logged. /// succeeded either way, so a daemon not taking commands is only logged.
async fn scan_soon(state: &WebState, feed: Option<String>) { async fn scan_soon(state: &WebState, feed: Option<String>) {
let force = feed.is_some(); let force = feed.is_some();
if state.cmds.send(Command::Fetch { feed, force }).await.is_err() { if state.cmds.send(Command::Fetch { feed, force, feeds: vec![] }).await.is_err() {
tracing::warn!("could not queue a scan: the daemon is not accepting commands"); tracing::warn!("could not queue a scan: the daemon is not accepting commands");
} }
} }
@@ -1383,8 +1375,7 @@ async fn patch_feed(
if let Some(v) = body.category { if let Some(v) = body.category {
feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); feed.category = v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty());
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
if url_changed { if url_changed {
// Refreshing a rotated auth token is the common case; entries and download history // Refreshing a rotated auth token is the common case; entries and download history
// are keyed by feed id, so they survive the change. // are keyed by feed id, so they survive the change.
@@ -1420,8 +1411,7 @@ async fn remove_feed(
state.ctx.db.drop_managed(&id).await?; state.ctx.db.drop_managed(&id).await?;
return Ok(StatusCode::NO_CONTENT); return Ok(StatusCode::NO_CONTENT);
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
crate::retire_group(&state.ctx, &id).await?; crate::retire_group(&state.ctx, &id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -1537,11 +1527,21 @@ struct FetchBody {
async fn fetch_now( async fn fetch_now(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<FetchBody>, Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
// "Check every feed" is every feed this person reads, not everyone's (issue #37).
let feeds = if body.feed.is_some() {
vec![]
} else {
state.ctx.db.subscriptions_for(user.id).await?.into_iter().map(|s| s.feed_id).collect()
};
if body.feed.is_none() && feeds.is_empty() {
return Ok(StatusCode::ACCEPTED); // nothing of theirs to check; an empty list would mean all
}
state state
.cmds .cmds
.send(Command::Fetch { feed: body.feed, force: body.force }) .send(Command::Fetch { feed: body.feed, force: body.force, feeds })
.await .await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?; .map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
@@ -1718,7 +1718,7 @@ async fn import_opml(
// file arrives as text, is read here, and is gone when the request ends. // file arrives as text, is read here, and is gone when the request ends.
let doc = opml::OPML::from_str(&body.xml) let doc = opml::OPML::from_str(&body.xml)
.map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?; .map_err(|e| ApiError::bad_request(format!("that is not an OPML file: {e}")))?;
let (added, already) = crate::subscribe_opml(&state.ctx, &state.config_path, &doc, user.id).await?; let (added, already) = crate::subscribe_opml(&state.ctx, &doc, user.id).await?;
if added > 0 { if added > 0 {
scan_soon(&state, None).await; scan_soon(&state, None).await;
} }
@@ -1792,8 +1792,7 @@ async fn patch_settings(
if let Some(v) = body.max_age_days { if let Some(v) = body.max_age_days {
cfg.general.max_age_days = v; cfg.general.max_age_days = v;
} }
cfg.save(&state.config_path)?; state.ctx.store_cfg(cfg).await?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -576,7 +576,7 @@ test('a second person has their own feeds and their own read state', async ({ br
await page.locator('#prefs').click(); await page.locator('#prefs').click();
await expect(page.locator('#modalCard')).toContainText('Subscriptions'); await expect(page.locator('#modalCard')).toContainText('Subscriptions');
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this'); await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
await page.locator('#modalCard .cardacts .btn').first().click(); await page.locator('#modalCard .cardx').click();
await expect(page.locator('#admin')).toHaveCount(0); await expect(page.locator('#admin')).toHaveCount(0);
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin'); expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
// Asking for it anyway goes back to the app, and its script is refused. // Asking for it anyway goes back to the app, and its script is refused.
@@ -1300,19 +1300,44 @@ test('a pinned feed, even one from inside a folder, goes to the top of the list'
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1); await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
}); });
test('a pinned item goes to the top of its list, and back when unpinned', async ({ page }) => { test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
await page.locator('.feed', { hasText: 'Test Show' }).click(); const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
await page.locator('.tabs button', { hasText: 'All' }).first().click(); await expect(row).toBeVisible();
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 }); await page.evaluate(() => setScanning('test-show', true));
const guids = () => page.locator('.ep').evaluateAll(rows => rows.map(r => r.dataset.guid)); await expect(row).toHaveClass(/\bscanning\b/);
const before = await guids(); await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
const last = before[before.length - 1]; await page.evaluate(() => setScanning('test-show', false));
const row = page.locator(`.ep[data-guid="${last}"]`); await expect(row).not.toHaveClass(/\bscanning\b/);
await row.locator('[data-a="flag"]').click(); await expect(row.locator('.badge')).toBeVisible();
await expect.poll(async () => (await guids())[0]).toBe(last); // Checking every feed says so on the rows, not in a toast (issue #37).
// It is the server's order, so it holds on a reload. await page.locator('#scanAll').click();
await page.reload(); await page.waitForTimeout(1500);
await expect.poll(async () => (await guids())[0]).toBe(last); await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
await page.locator(`.ep[data-guid="${last}"] [data-a="flag"]`).click(); });
await expect.poll(guids).toEqual(before);
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
// piper, made in an earlier test, subscribes to Test Show alone.
const ctx = await browser.newContext();
const piper = await ctx.newPage();
await piper.goto('/login');
await piper.locator('#name').fill('piper');
await piper.locator('#pw').fill('piperpassword');
await piper.locator('button[type=submit]').click();
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
// Listen as the page does, then ask.
await piper.evaluate(() => {
window.started = []; window.done = false;
const es = new EventSource('/api/events');
es.onmessage = m => { const ev = JSON.parse(m.data);
if (ev.ev === 'feed_start') window.started.push(ev.feed);
if (ev.ev === 'scan_done') window.done = true; };
});
await piper.waitForTimeout(500);
await piper.locator('#scanAll').click();
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
const started = await piper.evaluate(() => window.started);
expect(started.length, 'something was checked').toBeGreaterThan(0);
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
await ctx.close();
}); });

View File

@@ -32,12 +32,12 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#6b6b6b; /* between the icon's #929292 and #606060, to clear AA on the page ground */
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#9a5f0a; /* the EQ amber, taken down until white on it clears AA */ --accent2:#985e0a; /* the EQ amber, taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a; /* the same amber; the lighter one failed AA as text */
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }
@@ -124,7 +124,7 @@
--dim:#c4c4c8; --dim:#c4c4c8;
--faint:#a0a0a7; --faint:#a0a0a7;
--accent:#81d0ff; --accent:#81d0ff;
--accent2:#3584e4; --accent2:#3685e4; /* a hair lighter so the badge's dark count clears AA */
--ink:#1d1d20; --ink:#1d1d20;
--good:#78e9ab; --good:#78e9ab;
--warn:#ffc252; --warn:#ffc252;
@@ -141,7 +141,7 @@
--dim:#57575d; --dim:#57575d;
--faint:#66666c; --faint:#66666c;
--accent:#0461be; --accent:#0461be;
--accent2:#3584e4; --accent2:#1c71d8; /* blue 4, a step down from the accent blue, so white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#00753a; --good:#00753a;
--warn:#905400; --warn:#905400;
@@ -177,7 +177,7 @@
--dim:#5c616c; /* fg */ --dim:#5c616c; /* fg */
--faint:#686d78; --faint:#686d78;
--accent:#0060f0; /* link */ --accent:#0060f0; /* link */
--accent2:#fd7d00; --accent2:#bb5c00; /* the warning orange taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#23794f; --good:#23794f;
--warn:#a85400; --warn:#a85400;
@@ -196,7 +196,7 @@
--dim:#3d3a33; --dim:#3d3a33;
--faint:#5a564c; --faint:#5a564c;
--accent:#1a5fae; --accent:#1a5fae;
--accent2:#b58900; --accent2:#957100; /* taken down until white on it clears AA */
--ink:#ffffff; --ink:#ffffff;
--good:#216609; --good:#216609;
--warn:#795a00; --warn:#795a00;
@@ -210,7 +210,7 @@
--panel:#3b4252; /* nord1 */ --panel:#3b4252; /* nord1 */
--panel2:#434c5e; /* nord2 */ --panel2:#434c5e; /* nord2 */
--raise:#4c566a; /* nord3 */ --raise:#4c566a; /* nord3 */
--line:#434c5e; --line:#4c566a; /* nord3: nord2 is --panel2, and borders on it vanished */
--fg:#eceff4; /* nord6 */ --fg:#eceff4; /* nord6 */
--dim:#d8dee9; /* nord4 */ --dim:#d8dee9; /* nord4 */
--faint:#b4bccb; --faint:#b4bccb;
@@ -232,7 +232,7 @@
--dim:#3b4252; /* nord1 */ --dim:#3b4252; /* nord1 */
--faint:#4c566a; /* nord3 */ --faint:#4c566a; /* nord3 */
--accent:#3a5e8a; --accent:#3a5e8a;
--accent2:#b0674e; --accent2:#ab644c;
--ink:#ffffff; --ink:#ffffff;
--good:#446632; --good:#446632;
--warn:#7e590e; --warn:#7e590e;
@@ -254,7 +254,7 @@
--accent2:#2a5db0; --accent2:#2a5db0;
--ink:#ffffff; --ink:#ffffff;
--good:#237a23; --good:#237a23;
--warn:#a15f00; --warn:#9b5b00;
--bad:#c42b1c; --bad:#c42b1c;
--shadow:0 4px 16px rgba(0,0,0,.28); --shadow:0 4px 16px rgba(0,0,0,.28);
} }
@@ -368,6 +368,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
} }
.chev:hover{color:var(--fg)} .chev:hover{color:var(--fg)}
.chev.bad,.chev.bad:hover{color:var(--bad)} .chev.bad,.chev.bad:hover{color:var(--bad)}
/* A feed being checked: a small spinner in place of its unread count (issue #37). */
.feed.scanning .badge{display:none}
.feed.scanning::after{
content:"";flex:none;width:12px;height:12px;margin:0 6px;border-radius:50%;
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
}
@keyframes ipxspin{to{transform:rotate(360deg)}}
/* A feed's error mark, in the triangle's place: the same column as every folder's triangle, /* A feed's error mark, in the triangle's place: the same column as every folder's triangle,
a child's included, which is why it moves left by the child's indent. */ a child's included, which is why it moves left by the child's indent. */
.ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)} .ferr{position:absolute;left:-16px;top:0;bottom:0;width:24px;display:grid;place-items:center;color:var(--bad)}
@@ -421,9 +428,11 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
.tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3} .tile .txt b{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere;font-weight:500;font-size:13px;line-height:1.3}
.tile .txt small{display:block;color:var(--faint);font-size:11.5px} .tile .txt small{display:block;color:var(--faint);font-size:11.5px}
.tile:hover .txt b{color:var(--accent)} .tile:hover .txt b{color:var(--accent)}
/* Outlined, not filled: on --raise the warning and error colours fell short of AA in most light
themes, where on the row's own ground they clear it. */
.tag{ .tag{
font-size:11px;font-weight:600; font-size:11px;font-weight:600;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none; padding:0 4px;border-radius:4px;border:1px solid;color:var(--warn);flex:none;
} }
.art{ .art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none; border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
@@ -444,6 +453,8 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center; font-size:11px;font-weight:600;flex:none;min-width:26px;text-align:center;
} }
.badge.zero{background:var(--raise);color:var(--faint)} .badge.zero{background:var(--raise);color:var(--faint)}
/* A selected row is --raise too, and the zero count's pill vanished into it. */
.sel .badge.zero{background:var(--bg)}
/* Counts, sizes and times that change in place should not jiggle the text around them. */ /* Counts, sizes and times that change in place should not jiggle the text around them. */
.badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums} .badge,.feed small,.childrow small,.tile small,.ep,.fhead .sub,.dmeta,#status,#seekrow{font-variant-numeric:tabular-nums}
@@ -690,6 +701,9 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
.logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap} .logbar{display:flex;gap:8px;align-items:center;margin-bottom:9px;flex-wrap:wrap}
.logbar .grow{flex:1;min-width:120px} .logbar .grow{flex:1;min-width:120px}
.card h3{margin:0 0 14px;font-size:17px} .card h3{margin:0 0 14px;font-size:17px}
/* A dialog with nothing to confirm closes from the corner, beside its title, not from a lone button
at the foot of a long card. */
.cardx{float:right;margin:-5px -6px 0 8px}
.field{display:grid;gap:4px;margin-bottom:12px} .field{display:grid;gap:4px;margin-bottom:12px}
.field label{font-size:12px;color:var(--dim)} .field label{font-size:12px;color:var(--dim)}
.field .hint{font-size:11.5px;color:var(--faint)} .field .hint{font-size:11.5px;color:var(--faint)}
@@ -838,7 +852,8 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
:root[data-theme="classic"] .ep.sel .st, :root[data-theme="classic"] .ep.sel .st,
:root[data-theme="classic"] .ep.sel .fl, :root[data-theme="classic"] .ep.sel .fl,
:root[data-theme="classic"] .ep.sel .file, :root[data-theme="classic"] .ep.sel .file,
:root[data-theme="classic"] .ep.sel .kind{color:#fff} :root[data-theme="classic"] .ep.sel .kind,
:root[data-theme="classic"] .ep.sel .iconbtn:not(:hover){color:#fff}
/* Green and red stay green and red on the blue, just lighter so they read. */ /* Green and red stay green and red on the blue, just lighter so they read. */
:root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6} :root[data-theme="classic"] .ep.sel .kind.here{color:#a6f3a6}
:root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad} :root[data-theme="classic"] .ep.sel .kind.bad{color:#ffb8ad}

View File

@@ -1,3 +1,8 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>Sign in — iPodderX</title> <title>Sign in — iPodderX</title>
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png"> <link rel="icon" type="image/png" sizes="128x128" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -22,7 +27,8 @@
--shadow:0 8px 28px rgba(6,10,16,.55); --shadow:0 8px 28px rgba(6,10,16,.55);
--r:10px; --r:10px;
} }
:root[data-theme="light"] { /* Signed out, there is no account to take a theme from, so this follows the system. */
@media (prefers-color-scheme:light){:root {
--bg:#f2f4f7; --bg:#f2f4f7;
--panel:#ffffff; /* #FFFFFF device body */ --panel:#ffffff; /* #FFFFFF device body */
--panel2:#e9edf3; --panel2:#e9edf3;
@@ -30,15 +36,15 @@
--line:#d6d6d6; /* #D6D6D6 device edge */ --line:#d6d6d6; /* #D6D6D6 device edge */
--fg:#1a1a1a; /* #1A1A1A icon outline */ --fg:#1a1a1a; /* #1A1A1A icon outline */
--dim:#606060; /* #606060 */ --dim:#606060; /* #606060 */
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */ --faint:#6b6b6b;
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */ --accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
--accent2:#b06f10; --accent2:#985e0a;
--ink:#ffffff; --ink:#ffffff;
--good:#2f7d4f; --good:#2f7d4f;
--warn:#b06f10; --warn:#985e0a;
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }}
*{box-sizing:border-box} *{box-sizing:border-box}
html,body{height:100%} html,body{height:100%}
body{ body{

View File

@@ -218,7 +218,7 @@ function due(ts){
async function prefsModal(){ async function prefsModal(){
const g = await api('/api/settings'); const g = await api('/api/settings');
const admin = !!(S.me&&S.me.admin); const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3> openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
<div class="field"><label>Theme</label> <div class="field"><label>Theme</label>
<select id="stheme">${Object.entries(THEMES).map(([k,t])=> <select id="stheme">${Object.entries(THEMES).map(([k,t])=>
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div> `<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
@@ -238,8 +238,7 @@ async function prefsModal(){
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own. <span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div> ${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></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="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true); $('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true); $('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
@@ -371,7 +370,8 @@ function opmlModal(){
}; };
} }
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } // No toast: the spinners on the rows being checked say it (issue #37).
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
$('#scanAll').onclick=scanAll; $('#scanAll').onclick=scanAll;
$('#prefs').onclick=prefsModal; $('#prefs').onclick=prefsModal;
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while // Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while

View File

@@ -5,12 +5,8 @@ function connect(){
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
const refreshFeeds=soon(()=>loadFeeds(true)); const refreshFeeds=soon(()=>loadFeeds(true));
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); }); const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
let fresh={}; // Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const tellNew=soon(()=>{ const mine=id=>S.feeds.some(f=>f.id===id);
const feeds=Object.keys(fresh), n=feeds.reduce((a,k)=>a+fresh[k],0);
if(n) toast(feeds.length===1 ? `${feeds[0]}: ${n} new` : `${n} new in ${feeds.length} feeds`);
fresh={};
},900);
sse.onmessage=m=>{ sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return } let ev; try{ ev=JSON.parse(m.data) }catch{ return }
if(ev.ev==='progress'){ if(ev.ev==='progress'){
@@ -23,22 +19,28 @@ function connect(){
} }
else if(ev.ev==='download_done'){ else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); // Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
toast('Downloaded '+ev.path.split('/').pop()); refreshEntries(); refreshFeeds(); // everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
refreshEntries(); refreshFeeds();
} }
else if(ev.ev==='download_error'){ else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`); const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live'); if(bar) bar.classList.remove('live');
toast('Download failed: '+ev.msg,true); refreshEntries(); toast('Download failed: '+ev.msg,true); refreshEntries();
} }
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
else if(ev.ev==='feed_done'){ else if(ev.ev==='feed_done'){
if(ev.new){ fresh[ev.feed]=(fresh[ev.feed]||0)+ev.new; tellNew(); } setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries(); refreshFeeds(); if(ev.feed===S.feed||S.feed===':all') refreshEntries();
} }
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's // No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why. // red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error') refreshFeeds(); else if(ev.ev==='feed_error'){ setScanning(ev.feed,false); if(mine(ev.feed)) refreshFeeds(); }
else if(ev.ev==='scan_done'){ refreshFeeds(); refreshEntries(); } else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); refreshFeeds(); refreshEntries(); }
}; };
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); }; sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
} }

View File

@@ -156,7 +156,7 @@ function renderGroup(f,kids){
} }
async function feedAction(a,f){ async function feedAction(a,f){
if(a==='scan'){ toast('Scanning '+(f.title||f.id)+'…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); } if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); } if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
if(a==='rm') removeFeed(f); if(a==='rm') removeFeed(f);
if(a==='pin'){ if(a==='pin'){

View File

@@ -25,6 +25,22 @@ const VIEWS={
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'}, blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
/// holding one, carry a spinner.
const scanning=new Set<string>();
function setScanning(id: string, on: boolean){
if(on) scanning.add(id); else scanning.delete(id);
paintScanning();
}
function paintScanning(){
for(const row of $$('#feedlist .feed')){
const id=row.dataset.id;
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
row.classList.toggle('scanning',on);
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
}
}
function renderFeeds(){ function renderFeeds(){
const q=$('#feedFilter').value.trim().toLowerCase(); const q=$('#feedFilter').value.trim().toLowerCase();
const list=$('#feedlist'); const top=list.scrollTop; const list=$('#feedlist'); const top=list.scrollTop;
@@ -104,6 +120,7 @@ function renderFeeds(){
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); }; if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
list.appendChild(el); list.appendChild(el);
} }
paintScanning();
done(); done();
} }
function selectFeed(id){ function selectFeed(id){

View File

@@ -34,7 +34,6 @@ function pullShow(dy: number){
function refreshFeed(){ function refreshFeed(){
const f = S.feeds.find(x => x.id === S.feed); const f = S.feeds.find(x => x.id === S.feed);
if(!f && S.feed !== ':all') return; if(!f && S.feed !== ':all') return;
toast(f ? `Checking ${f.title || f.id} for new items…` : 'Checking every feed for new items…');
// New items arrive by the event stream when the scan finishes, as they do for a button press. // New items arrive by the event stream when the scan finishes, as they do for a button press.
api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})}) api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
.catch(e => toast(e.message, true)); .catch(e => toast(e.message, true));

View File

@@ -314,9 +314,7 @@ async function epAction(a: string, e, el, encId?: number){
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ try{
if(a==='play') play(e, encId!=null ? enc : undefined); if(a==='play') play(e, encId!=null ? enc : undefined);
// A pinned item sits at the top of its list (the server sorts it there), so the list is if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
// asked for again rather than the row redrawn where it stands.
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); loadEntries(); }
if(a==='read'){ await setRead(e,!e.read); redraw(); } if(a==='read'){ await setRead(e,!e.read); redraw(); }
if(a==='get'){ if(a==='get'){
if(!enc) return; if(!enc) return;

View File

@@ -191,8 +191,7 @@ function keysModal(){
['Anywhere'], ['Anywhere'],
[k('?'),'This list'],[k('Esc'),'Close a dialog'], [k('?'),'This list'],[k('Esc'),'Close a dialog'],
]; ];
openModal(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table> ?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
} }

View File

@@ -6,7 +6,7 @@
// the system, so no palette is written twice. // the system, so no palette is written twice.
const THEMES: Record<string, {name: string, modes: boolean}> = { const THEMES: Record<string, {name: string, modes: boolean}> = {
modern: {name: 'Modern', modes: true}, modern: {name: 'Modern', modes: true},
classic: {name: 'Classic, the 2004 Mac app', modes: false}, classic: {name: 'Classic', modes: false},
dracula: {name: 'Dracula', modes: true}, dracula: {name: 'Dracula', modes: true},
material: {name: 'Material', modes: true}, material: {name: 'Material', modes: true},
adwaita: {name: 'Adwaita', modes: true}, adwaita: {name: 'Adwaita', modes: true},

View File

@@ -26,7 +26,6 @@ const ICON={
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
log:fa('0 0 384 512','<path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L213.5 0c17 0 33.3 6.7 45.3 18.7L365.3 125.3c12 12 18.7 28.3 18.7 45.3L384 448c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm208-5.5l0 93.5c0 13.3 10.7 24 24 24L325.5 176 208 58.5zM120 256c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0zm0 96c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"/>'), // solid/file-lines
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 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-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 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-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
@@ -40,7 +39,6 @@ const ICON={
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
users:fa('0 0 640 512','<path fill="currentColor" d="M320 16a104 104 0 1 1 0 208 104 104 0 1 1 0-208zM96 88a72 72 0 1 1 0 144 72 72 0 1 1 0-144zM0 416c0-70.7 57.3-128 128-128 12.8 0 25.2 1.9 36.9 5.4-32.9 36.8-52.9 85.4-52.9 138.6l0 16c0 11.4 2.4 22.2 6.7 32L32 480c-17.7 0-32-14.3-32-32l0-32zm521.3 64c4.3-9.8 6.7-20.6 6.7-32l0-16c0-53.2-20-101.8-52.9-138.6 11.7-3.5 24.1-5.4 36.9-5.4 70.7 0 128 57.3 128 128l0 32c0 17.7-14.3 32-32 32l-86.7 0zM472 160a72 72 0 1 1 144 0 72 72 0 1 1 -144 0zM160 432c0-88.4 71.6-160 160-160s160 71.6 160 160l0 16c0 17.7-14.3 32-32 32l-256 0c-17.7 0-32-14.3-32-32l0-16z"/>'), // solid/users
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right