Trim the state database; Popular lists feeds the way Directory does
Drops the created columns on users, subscriptions and sessions, which were written by every insert and read by nothing, and migrate()'s add list, whose columns all predate 0.3.0. Removes Db::subscribed_feed_ids (no callers), Db::subscriber_count (one caller wanting > 0) and Managed.orphaned (never read). The old-database test now builds the tables with foreign keys on. Popular now lists the feeds inside an OPML or a Patreon creator, never the collection, as Directory does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
@@ -12,8 +12,10 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Directory lists the feeds inside an OPML one by one, and no longer the OPML itself, so you can
|
- Directory and Popular list the feeds inside an OPML one by one, and no longer the OPML itself,
|
||||||
subscribe to just the shows you want. Popular still counts an OPML as one feed.
|
so you can subscribe to just the shows you want.
|
||||||
|
- The database no longer records when accounts, subscriptions and sign-ins were created. Nothing
|
||||||
|
ever read it, and an existing database drops the columns on its next start.
|
||||||
|
|
||||||
## [0.5.1] - 2026-09-12
|
## [0.5.1] - 2026-09-12
|
||||||
|
|
||||||
|
|||||||
47
TODO.md
47
TODO.md
@@ -1,30 +1,27 @@
|
|||||||
# To do
|
# To do
|
||||||
|
|
||||||
## Cut what is no longer needed
|
## Trim the state database
|
||||||
|
|
||||||
From a whole-repo audit for over-engineering on 2026-09-12. Biggest cut first.
|
From an audit of the database layer and a read-only copy of production on 2026-09-12. The data
|
||||||
|
itself was clean: no leftover tables or indexes, 47 free pages, one stray `entry_state` row.
|
||||||
|
Check each against the code before cutting it.
|
||||||
|
|
||||||
- [x] **Pre-accounts adoption and the dead `entries` columns.** The copy of the old read state into
|
- [x] **`migrate()`'s add list.** All eight columns arrived in 0.2.0, and 0.5.0 only supports
|
||||||
`entry_state` and the `entries.read`, `flagged` and `position` columns are gone. Its other half,
|
upgrades from 0.3.0 on. Drop the list and its loop; keep the `retired` drop loop, which a
|
||||||
subscribing the first admin to the catalogue, was not dead and stays as `adopt_catalogue`.
|
database coming from 0.4.0 still needs. (`src/db.rs`)
|
||||||
(`src/db.rs`, `src/main.rs`)
|
- [x] **`Db::subscribed_feed_ids`.** No callers; its doc says the scanner walks it, and it does not.
|
||||||
- [x] **`contrib/` systemd units.** From before the container; nothing points at them.
|
(`src/db.rs`)
|
||||||
- [x] **`migrate_opml_children`.** A one-time move of OPML children out of `config.toml` that has
|
- [x] **`Db::subscriber_count`.** One caller, which only asks whether it is above zero:
|
||||||
run. Delete it and its call. (`src/main.rs`)
|
`subscriber_counts()?.contains_key(&id)`. (`src/db.rs`, `src/web.rs`)
|
||||||
- [x] **The legacy `interval_mins` key.** Production uses `schedule`. Delete the field, the fallback
|
- [x] **The `created` columns** on `users`, `subscriptions` and `sessions`. Written on every insert,
|
||||||
in `General::interval` and its test. (`src/config.rs`)
|
never read. Add them to `retired` and drop them from the inserts. (`src/db.rs`)
|
||||||
- [x] **`Db::entries` and `Db::count_entries`.** One-line wrappers only the tests call; the tests
|
- [x] **`Managed.orphaned`.** Selected by `managed_feeds()` on every call and never read;
|
||||||
call `entries_in` and `count_in` instead. (`src/db.rs`)
|
`FeedSummary.orphaned` is what the UI uses. (`src/db.rs`)
|
||||||
- [x] **`web::generate_token`.** Repeats `auth::new_session_token`. Use that. (`src/web.rs`)
|
|
||||||
- [x] **Page leftovers.** `globalEvery`, `S.busy`, `S.limit`, `unitOptions`' `firstLabel`, `--r`,
|
|
||||||
`.ep.open`, the phone `.ep .art`, the duplicate phone `.fhead.slim{flex-wrap}`, the second
|
|
||||||
`#sidebar{z-index}`, and the `on()` helper. (`web/index.html`)
|
|
||||||
- [x] **`logbuf` visitors.** `record_i64`, `record_u64` and `record_bool` repeat what `Visit`'s
|
|
||||||
defaults already do through `record_debug`. (`src/logbuf.rs`)
|
|
||||||
- [x] **The `infer` dependency.** Its torrent check is the `d8:announce` test on the next line.
|
|
||||||
- [x] **The `dirs` dependency.** `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `HOME` from `std::env`.
|
|
||||||
- [x] **The `tokio-stream` dependency.** `futures_util::stream::unfold` over the broadcast receiver.
|
|
||||||
- [x] **The icon inlined four times.** About 94 KB of base64 across both pages; serve it once as
|
|
||||||
`/icon.png` from `include_bytes!`, open without signing in like `/login`.
|
|
||||||
|
|
||||||
After these: `cargo test`, `node tests/page-smoke.js`, `npx playwright test`.
|
## Popular
|
||||||
|
|
||||||
|
- [x] **Popular lists feeds the way Directory does**: the feeds inside an OPML or a Patreon
|
||||||
|
creator, never the collection itself. (`src/web.rs`, `web/index.html`)
|
||||||
|
|
||||||
|
After these: `cargo test`, `node tests/page-smoke.js`, `npx playwright test`. Copy `state.db`
|
||||||
|
aside before deploying: `migrate()` drops columns on the first start.
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ entries feed_id, guid, title, link, published, description, first_seen,
|
|||||||
image, duration, episode, season PK (feed_id, guid)
|
image, duration, episode, season PK (feed_id, guid)
|
||||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||||
bytes_done, downloaded_at, last_error
|
bytes_done, downloaded_at, last_error
|
||||||
users id, name, pass_hash, is_admin, created
|
users id, name, pass_hash, is_admin
|
||||||
sessions token, user_id, created, seen
|
sessions token, user_id, seen
|
||||||
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
||||||
max_new_per_check, created PK (user_id, feed_id)
|
max_new_per_check PK (user_id, feed_id)
|
||||||
entry_state user_id, feed_id, guid, read, flagged, position
|
entry_state user_id, feed_id, guid, read, flagged, position
|
||||||
PK (user_id, feed_id, guid)
|
PK (user_id, feed_id, guid)
|
||||||
```
|
```
|
||||||
@@ -62,9 +62,12 @@ Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position
|
|||||||
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
before accounts; two bugs came from queries still reading them, and `migrate()` drops them from an
|
||||||
older database.
|
older database.
|
||||||
|
|
||||||
Schema changes: add the table or column to `SCHEMA`, and for a column also to the list in
|
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
||||||
`migrate()`, which does `PRAGMA table_info` then `ALTER TABLE ADD COLUMN`. `Db::memory()` runs the
|
that already exists alone, so a new column on one also needs an `ALTER TABLE ADD COLUMN` in
|
||||||
same path as `Db::open`, so a migration-only column cannot pass tests while missing in production.
|
`migrate()`, checked with `PRAGMA table_info` the way its `retired` list is for drops. None is
|
||||||
|
needed today: every column added so far predates 0.3.0, the oldest version an upgrade may start
|
||||||
|
from. `Db::memory()` runs the same path as `Db::open`, so a migration cannot pass the tests while
|
||||||
|
missing in production.
|
||||||
|
|
||||||
## Control socket
|
## Control socket
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds with an OPML counted as one, and every listable feed A to Z with an OPML's feeds in place of the OPML, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
|
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ reasoning lives. New write-ups go at the top.
|
|||||||
|
|
||||||
See [README.md](../README.md) for what the thing is.
|
See [README.md](../README.md) for what the thing is.
|
||||||
|
|
||||||
|
## 2026-09-12 — Trimming the state database
|
||||||
|
|
||||||
|
An audit of the database layer, with a read-only copy of production to check it against. The
|
||||||
|
data was already clean: no tables or indexes left from older versions, 47 free pages after the
|
||||||
|
column drops earlier the same day, and one stray `entry_state` row. The code had five things:
|
||||||
|
|
||||||
|
- `migrate()` still added eight columns to any table missing them. All eight shipped in 0.2.0 and
|
||||||
|
upgrades now start from 0.3.0 at the oldest, so the list and its loop went; the `retired` drop
|
||||||
|
list stays, since a database coming from 0.4.0 still has the old read columns.
|
||||||
|
- `created` on `users`, `subscriptions` and `sessions` was written by every insert and read by
|
||||||
|
nothing. They joined `retired`. The old-database test now builds all three tables, foreign keys
|
||||||
|
included, since `DROP COLUMN` on a table that references another was the part worth proving.
|
||||||
|
- `Db::subscribed_feed_ids` had no callers, though its doc said the scanner walked it.
|
||||||
|
`Db::subscriber_count` had one caller asking whether it was above zero, which
|
||||||
|
`subscriber_counts().contains_key` answers. `Managed.orphaned` was selected and never read.
|
||||||
|
|
||||||
## 2026-09-12 — Cutting what had outlived its reason
|
## 2026-09-12 — Cutting what had outlived its reason
|
||||||
|
|
||||||
A whole-repo audit for over-engineering listed twelve things to cut, and all of them went.
|
A whole-repo audit for over-engineering listed twelve things to cut, and all of them went.
|
||||||
|
|||||||
@@ -73,10 +73,9 @@ re-subscribing does not pull the back catalogue again.
|
|||||||
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
||||||
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
||||||
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
|
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
|
||||||
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is one
|
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||||
feed in Popular, since everyone subscribed to it counts for every feed inside; Directory lists the
|
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||||
feeds inside it one by one instead, and never the OPML, so you can take just the shows you want.
|
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||||
Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
|
||||||
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
|
||||||
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
|
||||||
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
|
||||||
|
|||||||
161
src/db.rs
161
src/db.rs
@@ -72,8 +72,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
pass_hash TEXT,
|
pass_hash TEXT,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0
|
||||||
created INTEGER NOT NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- What one person wants from a feed. The feed, its items and its files are shared; this
|
-- What one person wants from a feed. The feed, its items and its files are shared; this
|
||||||
@@ -85,7 +84,6 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|||||||
auto_download INTEGER,
|
auto_download INTEGER,
|
||||||
allow_explicit INTEGER,
|
allow_explicit INTEGER,
|
||||||
max_new_per_check INTEGER,
|
max_new_per_check INTEGER,
|
||||||
created INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, feed_id)
|
PRIMARY KEY (user_id, feed_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,7 +102,6 @@ CREATE TABLE IF NOT EXISTS entry_state (
|
|||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
created INTEGER NOT NULL,
|
|
||||||
seen INTEGER NOT NULL
|
seen INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
";
|
";
|
||||||
@@ -136,40 +133,30 @@ pub struct Managed {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub group_id: String,
|
pub group_id: String,
|
||||||
pub orphaned: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds the columns later versions introduced and drops the ones they retired. CREATE TABLE IF
|
/// Drops the columns later versions retired. CREATE TABLE IF NOT EXISTS leaves a table that
|
||||||
/// NOT EXISTS does nothing to a table that already exists, so an installed database needs both.
|
/// already exists alone, so an installed database needs this done explicitly. A new column on an
|
||||||
|
/// existing table would need an ALTER TABLE ADD COLUMN here too; none does yet, since every one
|
||||||
|
/// added so far predates 0.3.0, the oldest version an upgrade may start from.
|
||||||
fn migrate(conn: &Connection) -> Result<()> {
|
fn migrate(conn: &Connection) -> Result<()> {
|
||||||
let wanted: &[(&str, &str, &str)] = &[
|
let retired: &[(&str, &str)] = &[
|
||||||
("feeds", "image", "TEXT"),
|
|
||||||
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
|
|
||||||
("feeds", "group_id", "TEXT"),
|
|
||||||
("feeds", "managed", "INTEGER NOT NULL DEFAULT 0"),
|
|
||||||
("entries", "image", "TEXT"),
|
|
||||||
("entries", "duration", "INTEGER"),
|
|
||||||
("entries", "episode", "INTEGER"),
|
|
||||||
("entries", "season", "INTEGER"),
|
|
||||||
];
|
|
||||||
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
// Read state from before accounts, long since moved to entry_state. Two bugs came from
|
||||||
// queries still reading these after they stopped meaning anything, so they go.
|
// queries still reading these after they stopped meaning anything.
|
||||||
let retired: &[(&str, &str)] = &[("entries", "read"), ("entries", "flagged"), ("entries", "position")];
|
("entries", "read"),
|
||||||
let has = |table: &str, column: &str| -> Result<bool> {
|
("entries", "flagged"),
|
||||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
("entries", "position"),
|
||||||
let names = stmt
|
// Written by every insert and read by nothing.
|
||||||
.query_map([], |r| r.get::<_, String>(1))?
|
("users", "created"),
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
("subscriptions", "created"),
|
||||||
Ok(names.iter().any(|c| c == column))
|
("sessions", "created"),
|
||||||
};
|
];
|
||||||
for (table, column, ty) in wanted {
|
|
||||||
if !has(table, column)? {
|
|
||||||
tracing::info!(table, column, "adding column");
|
|
||||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN {column} {ty}"))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (table, column) in retired {
|
for (table, column) in retired {
|
||||||
if has(table, column)? {
|
let names: Vec<String> = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({table})"))?
|
||||||
|
.query_map([], |r| r.get(1))?
|
||||||
|
.collect::<rusqlite::Result<_>>()?;
|
||||||
|
if names.iter().any(|c| c == column) {
|
||||||
tracing::info!(table, column, "dropping column");
|
tracing::info!(table, column, "dropping column");
|
||||||
conn.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column}"))?;
|
conn.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column}"))?;
|
||||||
}
|
}
|
||||||
@@ -829,15 +816,14 @@ impl Db {
|
|||||||
}
|
}
|
||||||
for id in catalogue {
|
for id in catalogue {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
|
||||||
params![user_id, id, now()],
|
params![user_id, id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
// Feeds that exist only in the database (OPML children) count too.
|
// Feeds that exist only in the database (OPML children) count too.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created)
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) SELECT ?1, id FROM feeds",
|
||||||
SELECT ?1, id, ?2 FROM feeds",
|
params![user_id],
|
||||||
params![user_id, now()],
|
|
||||||
)?;
|
)?;
|
||||||
Ok(catalogue.len())
|
Ok(catalogue.len())
|
||||||
}
|
}
|
||||||
@@ -954,8 +940,8 @@ impl Db {
|
|||||||
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
pub fn subscribe(&self, user_id: i64, feed_id: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id, created) VALUES (?1, ?2, ?3)",
|
"INSERT OR IGNORE INTO subscriptions (user_id, feed_id) VALUES (?1, ?2)",
|
||||||
params![user_id, feed_id, now()],
|
params![user_id, feed_id],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -969,16 +955,6 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many people want this feed. Nobody means it stops being scanned.
|
|
||||||
pub fn subscriber_count(&self, feed_id: &str) -> Result<i64> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
Ok(conn.query_row(
|
|
||||||
"SELECT count(*) FROM subscriptions WHERE feed_id = ?1",
|
|
||||||
[feed_id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Overwrites one person's settings for a feed. A None field means: follow the feed.
|
/// Overwrites one person's settings for a feed. A None field means: follow the feed.
|
||||||
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
|
pub fn set_subscription(&self, user_id: i64, sub: &Sub) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
@@ -989,8 +965,8 @@ impl Db {
|
|||||||
.transpose()?;
|
.transpose()?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO subscriptions
|
"INSERT INTO subscriptions
|
||||||
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check, created)
|
(user_id, feed_id, keywords, auto_download, allow_explicit, max_new_per_check)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||||
ON CONFLICT(user_id, feed_id) DO UPDATE SET
|
ON CONFLICT(user_id, feed_id) DO UPDATE SET
|
||||||
keywords = excluded.keywords,
|
keywords = excluded.keywords,
|
||||||
auto_download = excluded.auto_download,
|
auto_download = excluded.auto_download,
|
||||||
@@ -1003,29 +979,18 @@ impl Db {
|
|||||||
sub.auto_download.map(|v| v as i64),
|
sub.auto_download.map(|v| v as i64),
|
||||||
sub.allow_explicit.map(|v| v as i64),
|
sub.allow_explicit.map(|v| v as i64),
|
||||||
sub.max_new_per_check,
|
sub.max_new_per_check,
|
||||||
now()
|
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feeds with at least one subscriber. What the scanner walks.
|
|
||||||
pub fn subscribed_feed_ids(&self) -> Result<Vec<String>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let mut stmt = conn.prepare("SELECT DISTINCT feed_id FROM subscriptions")?;
|
|
||||||
let out = stmt
|
|
||||||
.query_map([], |r| r.get::<_, String>(0))?
|
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- users and sessions ----
|
// ---- users and sessions ----
|
||||||
|
|
||||||
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
|
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (name, pass_hash, is_admin, created) VALUES (?1, ?2, ?3, ?4)",
|
"INSERT INTO users (name, pass_hash, is_admin) VALUES (?1, ?2, ?3)",
|
||||||
params![name, pass_hash, admin as i64, now()],
|
params![name, pass_hash, admin as i64],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
@@ -1093,7 +1058,7 @@ impl Db {
|
|||||||
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
|
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sessions (token, user_id, created, seen) VALUES (?1, ?2, ?3, ?3)",
|
"INSERT INTO sessions (token, user_id, seen) VALUES (?1, ?2, ?3)",
|
||||||
params![token, user_id, now()],
|
params![token, user_id, now()],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1195,7 +1160,7 @@ impl Db {
|
|||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read and starred, per person. The row is created on first touch.
|
/// Read and kept, per person. The row is created on first touch.
|
||||||
pub fn set_entry_flag(
|
pub fn set_entry_flag(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -1270,7 +1235,7 @@ impl Db {
|
|||||||
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
|
pub fn managed_feeds(&self) -> Result<Vec<Managed>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, url, title, group_id, orphaned FROM feeds
|
"SELECT id, url, title, group_id FROM feeds
|
||||||
WHERE managed = 1 AND group_id IS NOT NULL ORDER BY coalesce(title, id)",
|
WHERE managed = 1 AND group_id IS NOT NULL ORDER BY coalesce(title, id)",
|
||||||
)?;
|
)?;
|
||||||
Ok(stmt
|
Ok(stmt
|
||||||
@@ -1280,7 +1245,6 @@ impl Db {
|
|||||||
url: r.get(1)?,
|
url: r.get(1)?,
|
||||||
title: r.get(2)?,
|
title: r.get(2)?,
|
||||||
group_id: r.get(3)?,
|
group_id: r.get(3)?,
|
||||||
orphaned: r.get::<_, i64>(4)? != 0,
|
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
@@ -1423,8 +1387,8 @@ mod tests {
|
|||||||
fn every_sort_column_runs_and_orders_both_ways() {
|
fn every_sort_column_runs_and_orders_both_ways() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||||
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(1,'g',0);
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(1,'g');
|
||||||
INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
|
INSERT INTO feeds (id, url, title) VALUES ('f','u','Zebra'),('g','v','Aardvark');
|
||||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||||
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
|
('f','a','banana',100),('g','b','Apple',200),('f','c','cherry',300);
|
||||||
@@ -1457,8 +1421,8 @@ mod tests {
|
|||||||
fn deleting_a_shared_file_asks_about_everyone_else() {
|
fn deleting_a_shared_file_asks_about_everyone_else() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0),(3,'kit',0,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0),(3,'kit',0);
|
||||||
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0),(3,'f',0);
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f'),(3,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0);
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('f','a',0);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1,'f','a','u1','/tmp/a','done');",
|
(1,'f','a','u1','/tmp/a','done');",
|
||||||
@@ -1483,7 +1447,7 @@ mod tests {
|
|||||||
fn read_state_belongs_to_one_person() {
|
fn read_state_belongs_to_one_person() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||||
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, title, first_seen) VALUES
|
||||||
('f','a','One',100),('f','b','Two',200);",
|
('f','a','One',100),('f','b','Two',200);",
|
||||||
)
|
)
|
||||||
@@ -1526,27 +1490,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_old_database_loses_the_retired_read_columns() {
|
fn an_old_database_loses_its_retired_columns() {
|
||||||
let conn = Connection::open_in_memory().unwrap();
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
// As open() has it: a DROP COLUMN on a table that references another is the part worth
|
||||||
|
// proving, and it has to work with the foreign keys switched on.
|
||||||
|
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
|
"CREATE TABLE entries (feed_id TEXT NOT NULL, guid TEXT NOT NULL,
|
||||||
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
|
first_seen INTEGER NOT NULL, read INTEGER NOT NULL DEFAULT 0,
|
||||||
flagged INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
|
flagged INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (feed_id, guid));",
|
PRIMARY KEY (feed_id, guid));
|
||||||
|
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
pass_hash TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created INTEGER NOT NULL);
|
||||||
|
CREATE TABLE subscriptions (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
feed_id TEXT NOT NULL, created INTEGER NOT NULL, PRIMARY KEY (user_id, feed_id));
|
||||||
|
CREATE TABLE sessions (token TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created INTEGER NOT NULL, seen INTEGER NOT NULL);
|
||||||
|
INSERT INTO users VALUES (1, 'ray', NULL, 1, 0);
|
||||||
|
INSERT INTO subscriptions VALUES (1, 'f', 0);
|
||||||
|
INSERT INTO sessions VALUES ('t', 1, 0, 0);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// The same order as open(): the schema leaves the old table alone, migrate() fixes it.
|
// The same order as open(): the schema leaves the old tables alone, migrate() fixes them.
|
||||||
conn.execute_batch(SCHEMA).unwrap();
|
conn.execute_batch(SCHEMA).unwrap();
|
||||||
migrate(&conn).unwrap();
|
migrate(&conn).unwrap();
|
||||||
|
for (table, gone) in [
|
||||||
|
("entries", &["read", "flagged", "position"][..]),
|
||||||
|
("users", &["created"][..]),
|
||||||
|
("subscriptions", &["created"][..]),
|
||||||
|
("sessions", &["created"][..]),
|
||||||
|
] {
|
||||||
let cols: Vec<String> = conn
|
let cols: Vec<String> = conn
|
||||||
.prepare("PRAGMA table_info(entries)")
|
.prepare(&format!("PRAGMA table_info({table})"))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.query_map([], |r| r.get(1))
|
.query_map([], |r| r.get(1))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.collect::<rusqlite::Result<_>>()
|
.collect::<rusqlite::Result<_>>()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!cols.iter().any(|c| ["read", "flagged", "position"].contains(&c.as_str())), "{cols:?}");
|
assert!(!cols.iter().any(|c| gone.contains(&c.as_str())), "{table}: {cols:?}");
|
||||||
assert!(cols.iter().any(|c| c == "image"), "and it still gains the newer ones");
|
}
|
||||||
|
// And the rows come through it.
|
||||||
|
let kept: i64 = conn
|
||||||
|
.query_row("SELECT count(*) FROM subscriptions JOIN sessions USING (user_id)", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(kept, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1554,7 +1543,7 @@ mod tests {
|
|||||||
// Cutting this along with the dead read columns left the browser suite's admin with an
|
// Cutting this along with the dead read columns left the browser suite's admin with an
|
||||||
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
|
// empty sidebar: it is how a fresh install's first account gets config.toml's feeds.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test("INSERT INTO users (id, name, is_admin, created) VALUES (1,'admin',1,0);")
|
db.exec_for_test("INSERT INTO users (id, name, is_admin) VALUES (1,'admin',1);")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let subs = || -> i64 {
|
let subs = || -> i64 {
|
||||||
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
|
db.conn.lock().unwrap().query_row("SELECT count(*) FROM subscriptions", [], |r| r.get(0)).unwrap()
|
||||||
@@ -1582,7 +1571,7 @@ mod tests {
|
|||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1,'f','b','u1','/tmp/b','done');
|
(1,'f','b','u1','/tmp/b','done');
|
||||||
-- Read and starred belong to a person now, so say which one.
|
-- Read and starred belong to a person now, so say which one.
|
||||||
INSERT INTO users (id, name, is_admin, created) VALUES (7,'reader',1,0);
|
INSERT INTO users (id, name, is_admin) VALUES (7,'reader',1);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
(7,'f','b',1,0),
|
(7,'f','b',1,0),
|
||||||
(7,'f','c',1,1);",
|
(7,'f','c',1,1);",
|
||||||
@@ -1652,7 +1641,7 @@ mod tests {
|
|||||||
fn a_show_takes_over_what_its_creator_held() {
|
fn a_show_takes_over_what_its_creator_held() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
|
||||||
(1,'creator','a','u1','done','/x/a.mp3',NULL),
|
(1,'creator','a','u1','done','/x/a.mp3',NULL),
|
||||||
(2,'creator','b','u2','skipped',NULL,'explicit'),
|
(2,'creator','b','u2','skipped',NULL,'explicit'),
|
||||||
@@ -1688,9 +1677,9 @@ mod tests {
|
|||||||
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||||
INSERT INTO subscriptions (user_id, feed_id, allow_explicit, created) VALUES
|
INSERT INTO subscriptions (user_id, feed_id, allow_explicit) VALUES
|
||||||
(1,'group',1,0),(1,'show',NULL,0),(2,'group',1,0),(2,'show',0,0);",
|
(1,'group',1),(1,'show',NULL),(2,'group',1),(2,'show',0);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let explicit = |group| -> Vec<Option<bool>> {
|
let explicit = |group| -> Vec<Option<bool>> {
|
||||||
|
|||||||
@@ -154,8 +154,8 @@ mod tests {
|
|||||||
// One file serves both subscribers, so it takes both of them to release it.
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
||||||
INSERT INTO subscriptions (user_id, feed_id, created) VALUES (1,'f',0),(2,'f',0);
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'keep', 0),
|
('f', 'keep', 0),
|
||||||
('f', 'half', 0),
|
('f', 'half', 0),
|
||||||
@@ -189,7 +189,7 @@ mod tests {
|
|||||||
fn prune_keeps_entries_that_still_have_a_file() {
|
fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
|
|||||||
31
src/web.rs
31
src/web.rs
@@ -579,14 +579,10 @@ struct PopularRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
/// Every feed that may be listed, with everyone counted, you included, most subscribers
|
||||||
/// first. Popular is the top of one list and the directory is all of the other, and between
|
/// first. Popular is the top of it, the directory is all of it, and it is all that
|
||||||
/// them they are all that `subscribe_popular` will subscribe you to.
|
/// `subscribe_popular` will subscribe you to. An OPML or a Patreon creator is listed as the
|
||||||
///
|
/// feeds inside it and never itself: both lists are for finding a show.
|
||||||
/// `folders` says how an OPML appears. Popular lists the OPML once and not the feeds inside:
|
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> {
|
||||||
/// everyone subscribed to it counts for every one of them, and eighty of those would bury
|
|
||||||
/// everything anyone chose on purpose. The directory, which is for finding a show, lists the
|
|
||||||
/// feeds inside and never the OPML.
|
|
||||||
fn popular(state: &WebState, user_id: i64, folders: bool) -> Result<Vec<PopularRow>> {
|
|
||||||
let db = &state.ctx.db;
|
let db = &state.ctx.db;
|
||||||
let mine: std::collections::HashSet<String> =
|
let mine: std::collections::HashSet<String> =
|
||||||
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
|
||||||
@@ -599,11 +595,13 @@ fn popular(state: &WebState, user_id: i64, folders: bool) -> Result<Vec<PopularR
|
|||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
for s in &catalogue {
|
for s in &catalogue {
|
||||||
let n = counts.get(&s.id).copied().unwrap_or(0);
|
let n = counts.get(&s.id).copied().unwrap_or(0);
|
||||||
let inside = s.managed || s.cfg.group.is_some();
|
|
||||||
let hidden = if folders { inside } else { is_folder.contains(s.id.as_str()) };
|
|
||||||
// A feed inside an OPML that looks private is as private as the OPML.
|
// A feed inside an OPML that looks private is as private as the OPML.
|
||||||
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
|
||||||
if n == 0 || hidden || looks_private(&s.cfg) || folder.is_some_and(|f| looks_private(f)) {
|
if n == 0
|
||||||
|
|| is_folder.contains(s.id.as_str())
|
||||||
|
|| looks_private(&s.cfg)
|
||||||
|
|| folder.is_some_and(|f| looks_private(f))
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sum = db.feed_summary(&s.id)?;
|
let sum = db.feed_summary(&s.id)?;
|
||||||
@@ -618,7 +616,7 @@ async fn get_popular(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id, true)?;
|
let mut rows = popular(&state, user.id)?;
|
||||||
rows.truncate(10);
|
rows.truncate(10);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -628,7 +626,7 @@ async fn get_directory(
|
|||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
) -> Result<Json<Vec<PopularRow>>, ApiError> {
|
||||||
let mut rows = popular(&state, user.id, false)?;
|
let mut rows = popular(&state, user.id)?;
|
||||||
rows.sort_by_key(sort_name);
|
rows.sort_by_key(sort_name);
|
||||||
Ok(Json(rows))
|
Ok(Json(rows))
|
||||||
}
|
}
|
||||||
@@ -637,15 +635,14 @@ fn sort_name(p: &PopularRow) -> String {
|
|||||||
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
|
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribes by id, because the lists never show a URL. Checked against the same lists, so
|
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so
|
||||||
/// a guessed id cannot reach a private feed.
|
/// a guessed id cannot reach a private feed.
|
||||||
async fn subscribe_popular(
|
async fn subscribe_popular(
|
||||||
State(state): State<WebState>,
|
State(state): State<WebState>,
|
||||||
user: crate::db::User,
|
user: crate::db::User,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let listed = |folders| popular(&state, user.id, folders).map(|rows| rows.iter().any(|p| p.id == id));
|
if !popular(&state, user.id)?.iter().any(|p| p.id == id) {
|
||||||
if !(listed(true)? || listed(false)?) {
|
|
||||||
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
|
||||||
}
|
}
|
||||||
state.ctx.db.subscribe(user.id, &id)?;
|
state.ctx.db.subscribe(user.id, &id)?;
|
||||||
@@ -1095,7 +1092,7 @@ async fn remove_feed(
|
|||||||
{
|
{
|
||||||
state.ctx.db.unsubscribe(user.id, &child.id)?;
|
state.ctx.db.unsubscribe(user.id, &child.id)?;
|
||||||
}
|
}
|
||||||
if state.ctx.db.subscriber_count(&id)? > 0 {
|
if state.ctx.db.subscriber_counts()?.contains_key(&id) {
|
||||||
return Ok(StatusCode::NO_CONTENT);
|
return Ok(StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -613,8 +613,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await piper.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||||
const offered = piper.locator('#popular .childrow');
|
const offered = piper.locator('#popular .childrow');
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
// An OPML's own feeds ride on the OPML, and a key in a URL marks someone's paid feed.
|
// An OPML is listed as the feeds inside it, and a key in a URL marks someone's paid feed.
|
||||||
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toHaveCount(0);
|
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
|
||||||
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
// No URL reaches the page at all, so neither can a key, and the server holds the same line.
|
||||||
@@ -623,8 +623,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
expect(listed).not.toContain('.xml');
|
expect(listed).not.toContain('.xml');
|
||||||
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
|
||||||
|
|
||||||
// The directory is every listed feed A to Z, with an OPML's feeds in place of the OPML.
|
// Popular is the top ten of the directory, and the directory is every listed feed A to Z,
|
||||||
// Popular is the most subscribed, with an OPML as one feed, so its feeds cannot bury the rest.
|
// with an OPML's feeds in place of the OPML in both.
|
||||||
const dir = await (await piper.request.get('/api/directory')).json();
|
const dir = await (await piper.request.get('/api/directory')).json();
|
||||||
const top = await (await piper.request.get('/api/popular')).json();
|
const top = await (await piper.request.get('/api/popular')).json();
|
||||||
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
const names = dir.map(p => (p.title || p.id).toLowerCase());
|
||||||
@@ -632,9 +632,8 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
const ids = dir.map(p => p.id);
|
const ids = dir.map(p => p.id);
|
||||||
expect(ids).not.toContain('test-subscriptions');
|
expect(ids).not.toContain('test-subscriptions');
|
||||||
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
|
||||||
expect(top.length).toBeLessThanOrEqual(10);
|
expect(top.length).toBe(Math.min(10, dir.length));
|
||||||
expect(top.map(t => t.id)).not.toContain('grouped-show');
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(top.filter(t => t.id !== 'test-subscriptions').every(t => ids.includes(t.id))).toBe(true);
|
|
||||||
expect(ids).not.toContain('paid-show');
|
expect(ids).not.toContain('paid-show');
|
||||||
|
|
||||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
// Subscribe from the directory this time; the popular list shares the same rows.
|
||||||
|
|||||||
@@ -777,7 +777,7 @@ const VIEWS={
|
|||||||
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
||||||
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
||||||
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
||||||
blurb:'The ten feeds with the most subscribers here. An OPML counts as one feed.'},
|
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
||||||
':all':{title:'All Subscriptions',icon:ICON.all},
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
};
|
};
|
||||||
function renderFeeds(){
|
function renderFeeds(){
|
||||||
|
|||||||
Reference in New Issue
Block a user