Compare commits
36 Commits
directory-
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 47ad68c7da | |||
| ae58e4653a | |||
| dc3ee016db | |||
| f916ca972a | |||
| 7627af5bc8 | |||
| fbe7629420 | |||
| e882904c11 | |||
| f1a156c974 | |||
| f34423735a | |||
| 15bdce486d | |||
| 0c2c3a379e | |||
| 3e4d67fd2f | |||
| 2ed8781604 | |||
| d0ed953e84 | |||
| 4f1e7e23f6 | |||
| b18c92d1f5 | |||
| 9e5dcbb8c4 | |||
| ce485a82ac | |||
| f8371f255d | |||
| 1ae91170fd | |||
| 20f53183ac | |||
| 8d93b58deb | |||
| 0fdd609791 | |||
| 13cad18857 | |||
| 70990c70c7 | |||
| 30622fd3e1 | |||
| 1949f3e493 | |||
| 3d94bce4f5 | |||
| cd7a9b8530 | |||
| ca73b8136f | |||
| 6fde4b73cd | |||
| cdf2c98f9f | |||
| 6640ca9612 | |||
| 62a3241139 | |||
| 8f19d88fc9 | |||
| dbfe7918ac |
@@ -63,7 +63,7 @@ lines had no trace id, the access log's time was `ms`, and each event was logged
|
||||
| `ipx::scan` | `ev` and the event's own: `feed`, `new`, `downloaded`, `failed`, `bytes`, `msg`, `url`, `feeds`, `reason`, `from`, `to`; on a failure `error.type` and, from an HTTP error, `http.response.status_code` | the daemon's events, one line each, in words; warnings are feed and download failures |
|
||||
| `ipx::io` | `ev`, `feeds`, `pending`, `downloaded` on the `status` reply | commands arriving (`-> {...}`) and the healthcheck's answer |
|
||||
| `ipx` | message, sometimes fields | start-up, shutdown, account and config messages |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound) and `stall` (ms waiting mid-play) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
| `ipx::client` | `ev`, `user`, `feed`, `enclosure`, `deadair`; `msg`, `src`, `line` on an error; `ms` on a timing | what the page reports from the browser (#190): `page_error`, `rejection` and `player_error` as warnings; `play_start` (ms from play to sound), `stall` (ms waiting mid-play) and `list_shown` (ms from a click to its list drawn, with `feed` the view and `filter`, #246) as info. Lines carry the trace of the action in the page: its API requests send `traceparent`, one trace id per tap or key |
|
||||
|
||||
Events (`ev`): `feed_start`, `feed_done` (new, downloaded, failed, torrents), `feed_skip` (not due,
|
||||
routine), `feed_error` (msg), `feed_moved` (from, to: a permanent redirect followed, the address
|
||||
@@ -196,8 +196,8 @@ a count says something happened, the lines and traces say why.
|
||||
$Q prom 'max(max_over_time(ipx_deadair_busy_seconds{job="ipx"}[$range]))' 24h
|
||||
```
|
||||
`up` 0 means Prometheus cannot reach the port: the container is down or `metrics_bind` is
|
||||
unset. Connections in use at the pool's max (20 on Postgres) means something is waiting for
|
||||
one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
unset. The daemon keeps all 20 connections open on Postgres (#241), so `idle` is the pool's 20 less
|
||||
those in use; in use at 20, idle at 0, means something is waiting for one: find the slow requests or scan of that time in check 3 and 5. A worker queue that only
|
||||
grows means downloads are not keeping up, or the worker is stuck on one job. A runtime queue
|
||||
above 0 for more than a scrape or two means every thread is busy: something is blocking a
|
||||
thread or burning one. Memory, open files or tasks only climbing across a day with no restart
|
||||
@@ -215,11 +215,12 @@ a count says something happened, the lines and traces say why.
|
||||
```
|
||||
$Q metric 'sum by (ev, msg) (count_over_time({container="iPX"} |= "\"target\":\"ipx::client\"" | json | __error__="" | level="WARN" [$range]))' 7d
|
||||
$Q logs '{container="iPX"} |= "\"target\":\"ipx::client\"" | json | ev=~"play_start|stall" | line_format "{{.timestamp}} {{.user}} {{.ev}} {{.ms}}ms deadair={{.deadair}} {{.feed}}"' 24h
|
||||
$Q metric 'quantile_over_time(0.95, {container="iPX"} |= "\"ev\":\"list_shown\"" | json | __error__="" | unwrap ms [$range]) by (feed, filter)' 24h
|
||||
```
|
||||
A `player_error` is playback that failed in front of someone: `hls.js: ...` is DeadAir's
|
||||
playlist (its trace has the playlist and audio requests), `media error 4` a file the browser
|
||||
cannot play. A `page_error` or `rejection` is a bug in the page; `src` and `line` point into
|
||||
the minified `app.js`, so match the message to `web/src`. `play_start` over 2000 ms, or a
|
||||
the minified `app.js`, so match the message to `web/src`. A `list_shown` over 1000 ms is a list someone waited for: its trace says whether the API or the network held it. `play_start` over 2000 ms, or a
|
||||
`stall`, is worth a trace: was it the server, Cloudflare, or the network.
|
||||
|
||||
`docker logs --since 10m iPX 2>&1 | tail -5` shows the newest lines when Loki is behind.
|
||||
@@ -302,11 +303,15 @@ then; a steady stream is worth an issue, since Cloudflare can rate-limit it. A s
|
||||
`refused_private` is not 0 means an ipx server tried to send a feed that looks private: find which
|
||||
in that server's log, since the directory refuses it but the sender should not have sent it. Many
|
||||
`feed check failed` lines for one feed are the publisher's, not the directory's. Each feed is
|
||||
checked every six hours, 25 at a time, so `feeds checked` over a day should be about four times
|
||||
the feeds listed; none for over six hours means the refresher is stuck. `could not store a feed
|
||||
checked every twelve hours (#243), 25 at a time, so `feeds checked` over a day should be about
|
||||
twice the feeds listed; none for over twelve hours means the refresher is stuck. `could not store a feed
|
||||
check` or `could not read which feeds are due` is its database (`ipx_directory` on the
|
||||
`databases` project's Postgres). A config that does not load (`config changed but does not
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed.
|
||||
load`) keeps the old tokens: worth fixing before someone's new token is needed. Weekly, `Apple
|
||||
Podcasts synced` (`shows`, `accepted`) brings Apple's charts in as the `apple-podcasts` snapshot
|
||||
(#252); `Apple Podcasts' sync failed` retries in an hour, worth an issue when it keeps failing, and
|
||||
`Apple's podcast categories are not ipx's` (`added`, `gone`) is an issue to update `CATEGORIES` in
|
||||
`crates/feed`.
|
||||
|
||||
## Tower
|
||||
|
||||
@@ -349,8 +354,9 @@ container after a deploy.
|
||||
|
||||
## Rules
|
||||
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. Do not query production's Postgres
|
||||
directly: ask the user if a question needs the database.
|
||||
`docker logs` and `docker exec iPX ipx ...` are fine. On production's Postgres, `EXPLAIN (ANALYZE,
|
||||
BUFFERS)` of any query is allowed, read only, as CLAUDE.md's "Production's database" shows; any
|
||||
other query, ask the user first.
|
||||
|
||||
## What to do with what you find
|
||||
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@@ -19,7 +19,7 @@ service of the `content` project, beside audiobookshelf, immich and Plex (#208).
|
||||
| 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 `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/ipx/ipx.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/ipx/state.db`, SQLite, used until the move to Postgres on 2026-09-18 and kept for rollback | `/data/state.db` |
|
||||
| Downloads | `/mnt/user/ipx/downloads` | `/downloads` |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Directory | `ipx-directory` in the same project, `192.168.1.130:8100`, public at `directory.ipxreader.app` through the tunnel with no Access in front: its pages are anyone's to browse, read only (#215), and its API (`/v1`) takes a token for everything. Until 2026-10-12 it was `ipx-dir.sdf1.net` (#214). Config `/mnt/fast/appdata/ipx-directory/directory.toml` (token hashes; Tower's own token in `tower.token` beside it, and entered in ipx's Admin, Directory, which keeps it in the database's `settings`, with the address ipx reaches it at: `http://ipx-directory:8100`, inside Docker, not the public address, which went out through Cloudflare and the tunnel, 170-340 ms a call where the directory answers in 16 ms), database `ipx_directory` on the same Postgres, URL in `directory.env` beside the compose file. See [docs/directory.md](docs/directory.md) | `/config/directory.toml` |
|
||||
| Home page | `ipxreader.app` (and `www`): a static page from `site/`, on the Cloudflare Pages project `ipxreader`, not on Tower. See below | |
|
||||
| Web UI | `ipx-web` (Caddy) in the same project, at `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel. It serves the page and passes `/api` and `/media` to ipx, whose own port is published nowhere; its address on `ipx_default` is pinned to `192.168.64.10` (#203) | ipx `0.0.0.0:8099`, Caddy `:8080` |
|
||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | Caddy passes `Cf-Access-*` only from `192.168.64.1`, the `ipx_default` gateway cloudflared arrives from (`TUNNEL_PEER`; the subnet is pinned in the compose file); ipx trusts them only from Caddy, `192.168.64.10`, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||
@@ -181,6 +181,19 @@ npx playwright test # 66 browser tests against a real daemon on fixture fe
|
||||
node tests/load/run.js # k6: many people at once against a scratch daemon with 1,500 feeds
|
||||
```
|
||||
|
||||
**Builds share Tower with everything else, production included.** On 2026-10-16 a build of the
|
||||
directory followed in the same command by its tests' compile took Tower's load past 200 and the
|
||||
session was stopped. So:
|
||||
|
||||
* One cargo command at a time: a build, then the tests, as separate commands, never chained or
|
||||
run in the background beside another, and never two crates' builds at once. Do not tune `-j`.
|
||||
* Look at the load before and after each (`cat /proc/loadavg`). Above about 24, Tower's twelve
|
||||
threads twice over, wait for it to come down before starting another.
|
||||
* Build only the crate the change is in (`cargo build -p ipx-directory`), and run only its tests
|
||||
until the change is done; the whole workspace's once, at the end.
|
||||
* Builds and test binaries live under `target/`, a link to `/mnt/fast`; linking through
|
||||
`/mnt/user` (FUSE) once took the load to 248 on its own.
|
||||
|
||||
Things about the browser suite that have cost time:
|
||||
|
||||
* It starts **its own daemon and database** under `/tmp/ipx-ui-test`, wiped once per run. Playwright
|
||||
@@ -222,6 +235,25 @@ seeking through files, a flood of wrong passwords. Things about them:
|
||||
Non-trivial logic leaves one runnable check behind. Pure functions (`merge_policy`, `pick`,
|
||||
`matches_keywords`, `parse_interval`) are the easiest place to put it.
|
||||
|
||||
## Production's database
|
||||
|
||||
The owner allows `EXPLAIN (ANALYZE, BUFFERS)` of any query against production's Postgres, to see
|
||||
where a query's time goes; nothing else without asking. Run it read only, so nothing can change:
|
||||
|
||||
```sh
|
||||
U=$(grep -E '^IPX_DATABASE_URL=' /mnt/fast/arcane/projects/ipx/ipx.env | cut -d= -f2- | tr -d '"')
|
||||
psql "$U" -X -v ON_ERROR_STOP=1 <<'SQL'
|
||||
BEGIN READ ONLY;
|
||||
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
|
||||
ROLLBACK;
|
||||
SQL
|
||||
```
|
||||
|
||||
Never print the URL: it carries the password. Write the query as ipx sends it (`$1` parameters
|
||||
become `PREPARE`/`EXECUTE`, or values), for a real account. A query fast here and slow in ipx's
|
||||
trace is time outside the query: on 2026-10-15 the feed list's count took 5 ms in Postgres and 366
|
||||
ms in ipx, the difference being new connections, 80-330 ms each to open (#241).
|
||||
|
||||
## Things that are true and easy to get wrong
|
||||
|
||||
* **`enclosures.url` is globally UNIQUE.** It is the dedupe key and the reason one file serves every
|
||||
|
||||
76
Cargo.lock
generated
76
Cargo.lock
generated
@@ -17,6 +17,21 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -445,6 +460,27 @@ dependencies = [
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -620,9 +656,12 @@ version = "0.4.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
"zstd",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1820,7 +1859,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipx"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
@@ -1829,6 +1868,7 @@ dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-directory",
|
||||
"ipx-feed",
|
||||
@@ -1856,12 +1896,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ipx-directory"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"ipx-feed",
|
||||
"mimalloc",
|
||||
@@ -1877,6 +1918,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"toml",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"tracing-subscriber",
|
||||
@@ -2811,6 +2853,7 @@ version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"http",
|
||||
"httpdate",
|
||||
"opentelemetry",
|
||||
@@ -4651,6 +4694,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.13.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5458,3 +5502,31 @@ name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.1+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -15,19 +15,20 @@ anyhow = "1.0.104"
|
||||
argon2 = "0.6.0"
|
||||
atom_syndication = "0.12.10"
|
||||
axum = "0.8.9"
|
||||
flate2 = "1.1.10"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
clap = { version = "4.6.6", features = ["derive"] }
|
||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||
opentelemetry = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client"] }
|
||||
opentelemetry-otlp = { version = "0.33", default-features = false, features = ["trace", "http-proto", "reqwest-blocking-client", "gzip-http"] }
|
||||
opentelemetry_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||
opml = "1.1.6"
|
||||
mimalloc = "0.1.52"
|
||||
percent-encoding = "2.3.2"
|
||||
quick-xml = { version = "0.42.0", features = ["escape-html"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "brotli", "zstd", "stream", "json", "charset", "system-proxy"] }
|
||||
rss = "2.1.1"
|
||||
sea-orm = { version = "2.0.3", default-features = false, features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-json", "sqlite-use-returning-for-3_35"] }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
@@ -36,7 +37,7 @@ sha2 = "0.10.9"
|
||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal", "process"] }
|
||||
toml = "1.1.5"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||
tower-http = { version = "0.7.1", features = ["fs", "compression-br", "compression-gzip", "compression-zstd", "decompression-gzip"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
|
||||
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Answers and pages go out compressed, brotli, zstd or gzip as the caller takes them: the feed list ipx asks for was 603 KB of JSON. A snapshot may come gzipped, `/metrics` answers gzipped, and with `OTEL_EXPORTER_OTLP_COMPRESSION=gzip` traces go to the collector compressed (#253).
|
||||
- With `apple_country` set, Apple Podcasts' top charts for that country (the top 100 and the top 50 of each category) come into the directory every week, beside what servers share, without counting as subscribers; and a warning says when Apple's categories no longer match ipx's (#252).
|
||||
|
||||
### Changed
|
||||
|
||||
- Each feed is checked every twelve hours instead of six (#243).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the server keeps all ten of its database connections open, so a request does not wait for a new one (#241).
|
||||
|
||||
## [1.1.1] - 2026-10-15
|
||||
|
||||
### Changed
|
||||
@@ -45,7 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- `ipx-directory`, a directory of public podcasts that ipx servers share: each sends the public feeds its people subscribe to, and any server with a token browses them, each feed listed once it has been read. Tokens are in its config file, each read-only, read-write or admin, and revoked one at a time (#204, #205).
|
||||
- Traces: a span a request, joining the trace of the ipx server that called, and a trace a refresher round with a span a feed (#213).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/directory-v1.2.0...main
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.1...directory-v1.2.0
|
||||
[1.1.1]: https://git.sdf1.net/rays/ipx/compare/directory-v1.1.0...directory-v1.1.1
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/directory-v1.0.0...directory-v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/directory-v1.0.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx-directory"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -26,10 +26,12 @@ serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tokio.workspace = true
|
||||
toml.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-opentelemetry.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
flate2.workspace = true
|
||||
tower.workspace = true
|
||||
|
||||
273
crates/directory/src/apple.rs
Normal file
273
crates/directory/src/apple.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! Apple Podcasts, weekly (#252): its top charts for a country into the directory, beside what
|
||||
//! ipx servers share, and its categories checked against the copy ipx keeps (`ipx_feed`).
|
||||
//!
|
||||
//! The charts are a snapshot of their own, under `INSTANCE`, with no subscribers: Apple's shows
|
||||
//! are fetched, categorised and listed like any other feed, and the most subscribed stay what this
|
||||
//! directory's people subscribe to. Each show's RSS address comes from Apple's lookup API.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::{Shared, SnapshotEntry};
|
||||
|
||||
/// What the charts are filed under, as a server's snapshot is under its token's name.
|
||||
pub const INSTANCE: &str = "apple-podcasts";
|
||||
/// How often, and how long to wait after a sync fails before trying again.
|
||||
const EVERY: i64 = 7 * 86_400;
|
||||
const RETRY: Duration = Duration::from_secs(3600);
|
||||
/// The top this many overall, and of each category: about a thousand shows once they overlap.
|
||||
const TOP: u32 = 100;
|
||||
const TOP_OF_EACH: u32 = 50;
|
||||
/// Apple asks for no more than about twenty calls a minute.
|
||||
const PAUSE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Syncs when a week has passed since the last, forever.
|
||||
pub async fn run(dir: Shared, country: String) {
|
||||
let client = match reqwest::Client::builder()
|
||||
.user_agent(concat!("ipx-directory/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return tracing::error!(error = %e, "Apple Podcasts' sync could not start"),
|
||||
};
|
||||
loop {
|
||||
let last = dir.store.synced_at(INSTANCE).await.unwrap_or(None).unwrap_or(0);
|
||||
let wait = last + EVERY - crate::store::now();
|
||||
if wait > 0 {
|
||||
// Woken hourly at most, so a database restored from before the last sync is noticed.
|
||||
tokio::time::sleep(Duration::from_secs((wait as u64).min(3600))).await;
|
||||
continue;
|
||||
}
|
||||
let span = tracing::info_span!("apple_sync", country = %country);
|
||||
match sync(&dir, &client, &country).instrument(span).await {
|
||||
Ok(()) => {
|
||||
let _ = dir.store.set_synced(INSTANCE, crate::store::now()).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Apple Podcasts' sync failed; trying again in an hour");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync(dir: &Shared, client: &reqwest::Client, country: &str) -> Result<()> {
|
||||
let genres: Genres = get(client, "https://itunes.apple.com/WebObjects/MZStoreServices.woa/ws/genres?id=26").await?;
|
||||
let theirs = categories(&genres);
|
||||
let (added, gone) = differ(&theirs, ipx_feed::categories());
|
||||
if !added.is_empty() || !gone.is_empty() {
|
||||
tracing::warn!(
|
||||
added = %added.join(", "), gone = %gone.join(", "),
|
||||
"Apple's podcast categories are not ipx's: the copy in crates/feed (CATEGORIES) wants updating"
|
||||
);
|
||||
}
|
||||
|
||||
// The top overall, then the top of each category, each show once.
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let charts = std::iter::once((None, TOP)).chain(genre_ids(&genres).into_iter().map(|g| (Some(g), TOP_OF_EACH)));
|
||||
for (genre, n) in charts {
|
||||
let url = match &genre {
|
||||
Some(g) => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/genre={g}/json"),
|
||||
None => format!("https://itunes.apple.com/{country}/rss/toppodcasts/limit={n}/json"),
|
||||
};
|
||||
match get::<Chart>(client, &url).await {
|
||||
Ok(chart) => ids.extend(chart_ids(&chart)),
|
||||
// One category's chart missing is no reason to keep last week's whole list.
|
||||
Err(e) => tracing::info!(genre = ?genre, error = %format!("{e:#}"), "an Apple chart did not load"),
|
||||
}
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(id.clone()));
|
||||
anyhow::ensure!(!ids.is_empty(), "no chart had a show in it");
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for batch in ids.chunks(100) {
|
||||
let url = format!("https://itunes.apple.com/lookup?entity=podcast&id={}", batch.join(","));
|
||||
let found: Lookup = get(client, &url).await?;
|
||||
entries.extend(lookup_entries(&found));
|
||||
tokio::time::sleep(PAUSE).await;
|
||||
}
|
||||
let result = dir.store.snapshot(INSTANCE, &entries).await?;
|
||||
tracing::info!(
|
||||
shows = ids.len(), accepted = result.accepted, refused_private = result.refused_private,
|
||||
refused_blocked = result.refused_blocked, refused_invalid = result.refused_invalid,
|
||||
"Apple Podcasts synced"
|
||||
);
|
||||
// Fetched now, not when the refresher next looks.
|
||||
dir.wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get<T: serde::de::DeserializeOwned>(client: &reqwest::Client, url: &str) -> Result<T> {
|
||||
let span = tracing::info_span!("apple_get", url = %url);
|
||||
async {
|
||||
let resp = client.get(url).send().await.context("asking Apple")?.error_for_status()?;
|
||||
resp.json::<T>().await.context("reading Apple's answer")
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Apple's genres under Podcasts (26): each category's id and name, and its subcategories.
|
||||
#[derive(Deserialize)]
|
||||
struct Genres(BTreeMap<String, Genre>);
|
||||
#[derive(Deserialize)]
|
||||
struct Genre {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
subgenres: BTreeMap<String, Genre>,
|
||||
}
|
||||
|
||||
fn categories(g: &Genres) -> BTreeMap<String, Vec<String>> {
|
||||
g.0.get("26")
|
||||
.map(|podcasts| {
|
||||
podcasts.subgenres.values().map(|c| (c.name.clone(), c.subgenres.values().map(|s| s.name.clone()).collect())).collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn genre_ids(g: &Genres) -> Vec<String> {
|
||||
g.0.get("26").map(|p| p.subgenres.keys().cloned().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What Apple has that ipx's list lacks, and what ipx's has that Apple no longer does, as
|
||||
/// "Category" or "Category / Subcategory".
|
||||
fn differ(theirs: &BTreeMap<String, Vec<String>>, ours: &[(&str, &[&str])]) -> (Vec<String>, Vec<String>) {
|
||||
let flat = |c: &str, subs: &mut dyn Iterator<Item = String>| {
|
||||
std::iter::once(c.to_string()).chain(subs.map(|s| format!("{c} / {s}"))).collect::<Vec<_>>()
|
||||
};
|
||||
let theirs: std::collections::BTreeSet<String> =
|
||||
theirs.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().cloned())).collect();
|
||||
let ours: std::collections::BTreeSet<String> =
|
||||
ours.iter().flat_map(|(c, subs)| flat(c, &mut subs.iter().map(|s| s.to_string()))).collect();
|
||||
(theirs.difference(&ours).cloned().collect(), ours.difference(&theirs).cloned().collect())
|
||||
}
|
||||
|
||||
/// A chart, as Apple's iTunes RSS gives it: the shows' Apple ids, in order.
|
||||
#[derive(Deserialize)]
|
||||
struct Chart {
|
||||
feed: ChartFeed,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartFeed {
|
||||
// A chart of one show is that show, not a list of one.
|
||||
#[serde(default, deserialize_with = "one_or_many")]
|
||||
entry: Vec<ChartEntry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartEntry {
|
||||
id: ChartId,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartId {
|
||||
attributes: ChartIdAttributes,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct ChartIdAttributes {
|
||||
#[serde(rename = "im:id")]
|
||||
id: String,
|
||||
}
|
||||
|
||||
fn one_or_many<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<ChartEntry>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum OneOrMany {
|
||||
One(ChartEntry),
|
||||
Many(Vec<ChartEntry>),
|
||||
}
|
||||
Ok(match OneOrMany::deserialize(d)? {
|
||||
OneOrMany::One(e) => vec![e],
|
||||
OneOrMany::Many(v) => v,
|
||||
})
|
||||
}
|
||||
|
||||
fn chart_ids(c: &Chart) -> Vec<String> {
|
||||
c.feed.entry.iter().map(|e| e.id.attributes.id.clone()).collect()
|
||||
}
|
||||
|
||||
/// Apple's lookup answer: each show's RSS address and the category Apple files it under.
|
||||
#[derive(Deserialize)]
|
||||
struct Lookup {
|
||||
#[serde(default)]
|
||||
results: Vec<LookupResult>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LookupResult {
|
||||
#[serde(rename = "feedUrl")]
|
||||
feed_url: Option<String>,
|
||||
#[serde(rename = "primaryGenreName")]
|
||||
genre: Option<String>,
|
||||
}
|
||||
|
||||
fn lookup_entries(l: &Lookup) -> Vec<SnapshotEntry> {
|
||||
l.results
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let url = r.feed_url.as_deref()?.trim();
|
||||
(!url.is_empty()).then(|| SnapshotEntry {
|
||||
url: url.to_owned(),
|
||||
subscribers: 0,
|
||||
// "Podcasts" is the root Apple files a show under when it gives no category.
|
||||
category: r.genre.clone().filter(|g| g != "Podcasts"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apples_categories_are_read_and_compared_with_ipxs() {
|
||||
let genres: Genres = serde_json::from_str(
|
||||
r#"{"26": {"name": "Podcasts", "subgenres": {
|
||||
"1301": {"name": "Arts", "subgenres": {"1482": {"name": "Books"}, "1999": {"name": "Murals"}}},
|
||||
"1310": {"name": "Music"}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(genre_ids(&genres), ["1301", "1310"]);
|
||||
let ours: &[(&str, &[&str])] = &[("Arts", &["Books", "Design"]), ("Music", &[]), ("Hobbies", &[])];
|
||||
let (added, gone) = differ(&categories(&genres), ours);
|
||||
assert_eq!(added, ["Arts / Murals"]);
|
||||
assert_eq!(gone, ["Arts / Design", "Hobbies"]);
|
||||
// ipx's own copy is Apple's whole list, each category once.
|
||||
let mine = ipx_feed::categories();
|
||||
assert_eq!(mine.len(), 19);
|
||||
assert!(mine.iter().any(|(c, subs)| *c == "News" && subs.contains(&"Daily News")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_gives_its_shows_in_order_and_a_lookup_their_addresses() {
|
||||
let chart: Chart = serde_json::from_str(
|
||||
r#"{"feed": {"entry": [
|
||||
{"id": {"label": "https://x", "attributes": {"im:id": "1200361736"}}},
|
||||
{"id": {"label": "https://y", "attributes": {"im:id": "1028908750"}}}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(chart_ids(&chart), ["1200361736", "1028908750"]);
|
||||
let one: Chart = serde_json::from_str(r#"{"feed": {"entry": {"id": {"attributes": {"im:id": "7"}}}}}"#).unwrap();
|
||||
assert_eq!(chart_ids(&one), ["7"], "a chart of one");
|
||||
let empty: Chart = serde_json::from_str(r#"{"feed": {}}"#).unwrap();
|
||||
assert!(chart_ids(&empty).is_empty());
|
||||
|
||||
let found: Lookup = serde_json::from_str(
|
||||
r#"{"resultCount": 3, "results": [
|
||||
{"trackName": "The Daily", "feedUrl": "https://feeds.simplecast.com/Sl5CSM3S", "primaryGenreName": "Daily News"},
|
||||
{"trackName": "No feed"},
|
||||
{"trackName": "Rooted", "feedUrl": "https://example.com/rss", "primaryGenreName": "Podcasts"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let entries = lookup_entries(&found);
|
||||
assert_eq!(entries.len(), 2, "a show Apple has no feed for is left out");
|
||||
assert_eq!((entries[0].subscribers, entries[0].category.as_deref()), (0, Some("Daily News")));
|
||||
assert_eq!(entries[1].category, None, "Podcasts is no category");
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ pub struct Config {
|
||||
/// publishes: `0.0.0.0:9188`. Empty serves none.
|
||||
#[serde(default)]
|
||||
pub metrics_bind: String,
|
||||
/// Apple Podcasts' top charts for this country (its two letters: `ca`, `us`) come into the
|
||||
/// directory each week, and Apple's categories are checked against ipx's (#252). Empty, or
|
||||
/// left out, does neither.
|
||||
#[serde(default)]
|
||||
pub apple_country: String,
|
||||
#[serde(default)]
|
||||
pub tokens: Vec<TokenCfg>,
|
||||
}
|
||||
@@ -81,6 +86,14 @@ impl Config {
|
||||
if !seen.insert(t.name.as_str()) {
|
||||
bail!("two tokens are named {:?}", t.name);
|
||||
}
|
||||
// Apple's charts are filed under it; a server of that name would replace them.
|
||||
if t.name == crate::apple::INSTANCE {
|
||||
bail!("{:?} is the name Apple Podcasts' charts are kept under; give the token another", t.name);
|
||||
}
|
||||
}
|
||||
let c = cfg.apple_country.trim();
|
||||
if !c.is_empty() && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
bail!("apple_country is a country's two letters, such as \"ca\" or \"us\", not {c:?}");
|
||||
}
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! The types here are the wire format, shared with ipx, which asks the directory on its
|
||||
//! people's behalf.
|
||||
|
||||
pub mod apple;
|
||||
pub mod config;
|
||||
pub mod metrics;
|
||||
pub mod otel;
|
||||
@@ -117,8 +118,8 @@ pub struct Blocked {
|
||||
pub const MAX_SNAPSHOT: usize = 20_000;
|
||||
|
||||
/// How often each feed is fetched again. Metadata changes slowly, and every server subscribed
|
||||
/// to a feed fetches it itself anyway.
|
||||
pub const REFRESH_EVERY: i64 = 6 * 3600;
|
||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||
|
||||
pub struct Directory {
|
||||
pub store: Store,
|
||||
@@ -267,6 +268,12 @@ pub fn router(dir: Shared) -> Router {
|
||||
.merge(v1)
|
||||
// Anyone may browse, no token, read only (#215).
|
||||
.merge(site::router())
|
||||
// Brotli, zstd or gzip as the caller takes them: the feed list went to ipx as 603 KB of
|
||||
// JSON, most of a Directory page's time (#253). Inside the access log, which records what
|
||||
// was sent.
|
||||
.layer(tower_http::compression::CompressionLayer::new().br(true).zstd(true).gzip(true))
|
||||
// And takes a request's body gzipped: ipx sends its snapshot so, about 100 KB as JSON.
|
||||
.layer(tower_http::decompression::RequestDecompressionLayer::new().gzip(true))
|
||||
.layer(axum::middleware::from_fn(access_log))
|
||||
.with_state(dir)
|
||||
}
|
||||
@@ -476,6 +483,45 @@ mod tests {
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_snapshot_may_come_gzipped() {
|
||||
use std::io::Write as _;
|
||||
let (app, _) = app().await;
|
||||
let json = serde_json::json!([{"url": "https://example.com/feed.xml", "subscribers": 2}]).to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(json.as_bytes()).unwrap();
|
||||
// As ipx sends it (#253).
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/snapshot")
|
||||
.header("authorization", "Bearer secret-rw")
|
||||
.header("content-type", "application/json")
|
||||
.header("content-encoding", "gzip")
|
||||
.body(Body::from(gz.finish().unwrap()))
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got: serde_json::Value = serde_json::from_slice(&axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap()).unwrap();
|
||||
assert_eq!(got["accepted"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn answers_come_compressed_as_the_caller_asks() {
|
||||
let (app, _) = app().await;
|
||||
for (asked, sent) in [("br", Some("br")), ("zstd", Some("zstd")), ("gzip", Some("gzip")), ("identity", None)] {
|
||||
// A page, not an empty feed list: under 32 bytes is sent as it is.
|
||||
let req = axum::http::Request::builder()
|
||||
.uri("/")
|
||||
.header("accept-encoding", asked)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let got = resp.headers().get("content-encoding").map(|v| v.to_str().unwrap().to_owned());
|
||||
assert_eq!(got.as_deref(), sent, "asked for {asked} (#253)");
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_page(app: &Router, path: &str) -> (u16, String, Option<String>) {
|
||||
let req = axum::http::Request::builder().uri(path).body(Body::empty()).unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
@@ -92,7 +92,7 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
.ok()
|
||||
.filter(|u| !u.trim().is_empty())
|
||||
.with_context(|| format!("{} is not set: the directory needs a database URL", cfg.database_url_env))?;
|
||||
let store = Store::open(&url).await.context("opening the database")?;
|
||||
let store = Store::open(&url, true).await.context("opening the database")?;
|
||||
let dir = Directory::new(store, vec![])?;
|
||||
let dropped = dir.set_tokens(tokens).await?;
|
||||
|
||||
@@ -104,6 +104,11 @@ async fn serve(path: &std::path::Path) -> Result<()> {
|
||||
tokio::spawn(ipx_directory::metrics::serve(metrics));
|
||||
}
|
||||
tokio::spawn(ipx_directory::refresher(dir.clone()));
|
||||
let country = cfg.apple_country.trim().to_ascii_lowercase();
|
||||
if !country.is_empty() {
|
||||
tracing::info!(country = %country, "Apple Podcasts' charts are synced weekly");
|
||||
tokio::spawn(ipx_directory::apple::run(dir.clone(), country));
|
||||
}
|
||||
tokio::spawn(ipx_directory::reloader(dir.clone(), path.to_owned()));
|
||||
axum::serve(listener, ipx_directory::router(dir))
|
||||
.with_graceful_shutdown(async {
|
||||
|
||||
@@ -51,7 +51,9 @@ pub async fn serve(listener: tokio::net::TcpListener) {
|
||||
axum::routing::get(move || async move {
|
||||
([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(started))
|
||||
}),
|
||||
);
|
||||
)
|
||||
// Prometheus asks for gzip (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
if let Err(e) = axum::serve(listener, app).await {
|
||||
tracing::error!(error = %e, "metrics stopped");
|
||||
}
|
||||
|
||||
@@ -84,6 +84,11 @@ const SCHEMA: &[&str] = &[
|
||||
PRIMARY KEY (instance, feed_id)
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS snapshots_feed ON snapshots (feed_id)",
|
||||
// When each periodic job last finished: Apple's weekly sync (#252).
|
||||
"CREATE TABLE IF NOT EXISTS syncs (
|
||||
name TEXT PRIMARY KEY,
|
||||
at BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS blocked (
|
||||
id TEXT PRIMARY KEY,
|
||||
url TEXT,
|
||||
@@ -99,7 +104,7 @@ impl Store {
|
||||
pub async fn scratch() -> Self {
|
||||
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) else {
|
||||
return Self::open("sqlite::memory:").await.unwrap();
|
||||
return Self::open("sqlite::memory:", false).await.unwrap();
|
||||
};
|
||||
let (pid, n) = (std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
// A bare connection: Store::open would make the tables in the public schema, and the
|
||||
@@ -117,10 +122,11 @@ impl Store {
|
||||
let schema = format!("ipxd_{pid}_{n}");
|
||||
admin.db.execute_unprepared(&format!("CREATE SCHEMA {schema}")).await.unwrap();
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning")).await.unwrap()
|
||||
Self::open(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false).await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn open(url: &str) -> Result<Self> {
|
||||
/// `keep_open` for the server, which keeps its whole pool connected.
|
||||
pub async fn open(url: &str, keep_open: bool) -> Result<Self> {
|
||||
let mut url = url.to_owned();
|
||||
if url.starts_with("postgres") && !url.contains("options=") {
|
||||
// Postgres sends a notice for every CREATE ... IF NOT EXISTS on something that exists;
|
||||
@@ -134,6 +140,11 @@ impl Store {
|
||||
if url.contains(":memory:") {
|
||||
opts.max_connections(1);
|
||||
}
|
||||
// The server keeps every connection open, as ipx's daemon does: a Postgres connection takes
|
||||
// 80-330 ms to open (#241). Not a test, which would hold ten for one question.
|
||||
if keep_open && url.starts_with("postgres") {
|
||||
opts.max_connections(10).min_connections(10);
|
||||
}
|
||||
let db = sea_orm::Database::connect(opts).await?;
|
||||
for sql in SCHEMA {
|
||||
db.execute_unprepared(sql).await?;
|
||||
@@ -158,6 +169,23 @@ impl Store {
|
||||
Ok(self.db.query_all_raw(self.stmt(sql, values)).await?)
|
||||
}
|
||||
|
||||
/// When the job `name` last finished, Unix seconds.
|
||||
pub async fn synced_at(&self, name: &str) -> Result<Option<i64>> {
|
||||
Ok(match self.rows("SELECT at FROM syncs WHERE name = $1", vec![name.into()]).await?.first() {
|
||||
Some(r) => Some(r.try_get("", "at")?),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||
vec![name.into(), at.into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> Result<()> {
|
||||
self.rows("SELECT 1", vec![]).await?;
|
||||
Ok(())
|
||||
@@ -235,7 +263,8 @@ impl Store {
|
||||
.iter()
|
||||
.map(|r| r.try_get("", "instance"))
|
||||
.collect::<Result<_, _>>()?;
|
||||
for name in present.iter().filter(|n| !names.contains(n)) {
|
||||
// Apple's charts are no token's, and stay until the next sync replaces them.
|
||||
for name in present.iter().filter(|n| !names.contains(n) && n.as_str() != crate::apple::INSTANCE) {
|
||||
dropped += self.drop_instance(name).await?;
|
||||
}
|
||||
Ok(dropped)
|
||||
|
||||
@@ -1120,6 +1120,11 @@ const ALIASES: &[(&str, &str)] =
|
||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||
CATEGORIES
|
||||
}
|
||||
|
||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||
let name = name.trim().trim_end_matches([',', ';', '.']).trim_end();
|
||||
@@ -1135,6 +1140,9 @@ pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||
(name.to_string(), None)
|
||||
}
|
||||
|
||||
/// TypeSafe's System One endpoint, where Jev answers questions about a piece of state.
|
||||
pub const JEV_URL: &str = "https://api.typesafe.ai/v1/systemone";
|
||||
|
||||
/// A Directory category for a feed that names none of its own, which most blogs do not (#117):
|
||||
/// TypeSafe's Jev picks one of Apple's categories or subcategories from the feed's title and
|
||||
/// latest items, the subcategory being what a podcast's own category is stored as. The likeliest
|
||||
@@ -1159,7 +1167,7 @@ pub async fn classify(client: &reqwest::Client, key: &str, feed: &ParsedFeed) ->
|
||||
}},
|
||||
});
|
||||
let answer: serde_json::Value = client
|
||||
.post("https://api.typesafe.ai/v1/systemone")
|
||||
.post(JEV_URL)
|
||||
.bearer_auth(key)
|
||||
.json(&body)
|
||||
.timeout(FEED_TIMEOUT)
|
||||
|
||||
@@ -12,6 +12,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like, which Jev can pick on the server's TypeSafe key (`POST /api/signals/icon`) (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||
- With `OTEL_EXPORTER_OTLP_COMPRESSION=gzip`, traces go to the collector compressed; `/metrics` answers gzipped, and a snapshot goes to the directory gzipped (#253).
|
||||
- A Directory request's trace shows the call to the directory whole, with the size of its answer, and the steps that match it against your subscriptions (#250).
|
||||
- The page's report of how long a list took to show after a click (`list_shown`, with the view and filter) is logged under that click's trace (#246).
|
||||
|
||||
### Changed
|
||||
|
||||
- A feed that sends the same body again, as Patreon and Substack do whatever they are asked, is taken as unchanged instead of read again, and, without a schedule of its own, checked less often the longer it stays the same, up to every four hours, back on schedule when it changes (#242).
|
||||
- Feeds are fetched accepting brotli and zstd as well as gzip (#244).
|
||||
- On Postgres the daemon keeps all twenty of its database connections open, so a page load no longer waits for new ones, 80-330 ms each: the feed list and item lists took 0.3-1.6 s now and then, where their queries take 5-30 ms (#241).
|
||||
- Currently Listening and the Pinned filter answer in a few milliseconds: they read every item on the server, 1.3 s for Currently Listening (#249).
|
||||
|
||||
## [1.2.0] - 2026-10-15
|
||||
|
||||
### Added
|
||||
@@ -832,7 +848,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v1.3.0...main
|
||||
[1.3.0]: https://git.sdf1.net/rays/ipx/compare/v1.2.0...v1.3.0
|
||||
[1.2.0]: https://git.sdf1.net/rays/ipx/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/compare/v0.12.0...v1.0.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ipx"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -9,6 +9,7 @@ ipx-directory.workspace = true
|
||||
ammonia.workspace = true
|
||||
anyhow.workspace = true
|
||||
argon2.workspace = true
|
||||
flate2.workspace = true
|
||||
atom_syndication.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, subscriptions, users};
|
||||
use crate::entity::{api_tokens, blocklists, catalogue, deadair_prefs, enclosures, entries, feeds, hidden, sessions, settings, signal_verdicts, signals, subscriptions, users};
|
||||
use sea_orm::sea_query::{Expr, Func};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
@@ -63,6 +63,8 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
schema.create_table_from_entity(deadair_plans::Entity),
|
||||
schema.create_table_from_entity(deadair_prefs::Entity),
|
||||
schema.create_table_from_entity(deadair_saved::Entity),
|
||||
schema.create_table_from_entity(signals::Entity),
|
||||
schema.create_table_from_entity(signal_verdicts::Entity),
|
||||
] {
|
||||
orm.execute(table.if_not_exists()).await.context("creating the schema")?;
|
||||
}
|
||||
@@ -95,6 +97,26 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT body_hash FROM feeds LIMIT 0").await.is_err() {
|
||||
for sql in ["ALTER TABLE feeds ADD COLUMN body_hash TEXT", "ALTER TABLE feeds ADD COLUMN same_since BIGINT"] {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT typesafe_key FROM users LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE users ADD COLUMN typesafe_key TEXT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
if orm.execute_unprepared("SELECT description FROM signals LIMIT 0").await.is_err() {
|
||||
// Every verdict so far answered the old question, any one keyword on its own, which put
|
||||
// a game's remaster in AI Development (#259): all of them are asked again.
|
||||
for sql in ["ALTER TABLE signals ADD COLUMN description TEXT", "DELETE FROM signal_verdicts"] {
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
}
|
||||
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
}
|
||||
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
|
||||
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
|
||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||
@@ -149,7 +171,7 @@ 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
|
||||
/// lock, which is what rusqlite was set to, but for one.
|
||||
async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||
async fn connect(location: &str, keep_open: bool) -> Result<sea_orm::DatabaseConnection> {
|
||||
let mut opts = sea_orm::ConnectOptions::new(url_for(location));
|
||||
opts.sqlx_logging(false);
|
||||
// NORMAL, SQLite's own advice for WAL: synced at checkpoints, not after every commit. FULL,
|
||||
@@ -172,6 +194,13 @@ async fn connect(location: &str) -> Result<sea_orm::DatabaseConnection> {
|
||||
// past. Postgres gets twenty, where sqlx's ten filled in bursts: a page load asks for dozens
|
||||
// of covers at once, each with a lookup (#200). The server allows 100, shared with other apps.
|
||||
opts.max_connections(if is_postgres(location) { 20 } else { 8 });
|
||||
// The daemon keeps every connection open (#241): a Postgres connection takes 80-330 ms to
|
||||
// open, its password exchange most of it, and a page load asked for more than were idle, so
|
||||
// the feed list and item list waited on new ones, 300-900 ms where their queries took 5-30.
|
||||
// Not a command such as `ipx status`, nor a test, which would hold twenty for one question.
|
||||
if keep_open && is_postgres(location) {
|
||||
opts.min_connections(20);
|
||||
}
|
||||
sea_orm::Database::connect(opts)
|
||||
.await
|
||||
.with_context(|| format!("opening {}", redact(location)))
|
||||
@@ -294,19 +323,22 @@ pub struct FeedSummary {
|
||||
pub last_error: Option<String>,
|
||||
/// When this run of failures began; see the `error_since` column.
|
||||
pub error_since: Option<i64>,
|
||||
/// Since when the feed has sent the same body (#242).
|
||||
pub same_since: Option<i64>,
|
||||
pub entries: i64,
|
||||
pub downloaded: i64,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Opens the database at `location` (see `location()`): a postgres:// URL, or a SQLite file.
|
||||
pub async fn open(location: &str) -> Result<Self> {
|
||||
/// `keep_open` for the daemon, which keeps its whole pool connected.
|
||||
pub async fn open(location: &str, keep_open: bool) -> Result<Self> {
|
||||
if !is_postgres(location)
|
||||
&& let Some(dir) = Path::new(location).parent()
|
||||
{
|
||||
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
}
|
||||
let orm = connect(location).await?;
|
||||
let orm = connect(location, keep_open).await?;
|
||||
if !is_postgres(location) {
|
||||
// WAL, so the healthcheck's `ipx status` reads while the daemon writes. It is a
|
||||
// setting of the file, kept once made, and making it takes a lock that cannot wait out
|
||||
@@ -342,7 +374,7 @@ impl Db {
|
||||
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let pid = std::process::id();
|
||||
if let Some(url) = std::env::var("IPX_TEST_DATABASE_URL").ok().filter(|u| !u.is_empty()) {
|
||||
let admin = connect(&url).await?;
|
||||
let admin = connect(&url, false).await?;
|
||||
// Schemas an earlier run left behind: anything of ours not made by this process.
|
||||
let old = admin
|
||||
.query_all_raw(Statement::from_string(
|
||||
@@ -362,13 +394,13 @@ impl Db {
|
||||
// Every connection in the pool starts in it, so each test sees only its own tables.
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
let orm =
|
||||
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"))
|
||||
connect(&format!("{url}{sep}options=-c%20search_path%3D{schema}%20-c%20client_min_messages%3Dwarning"), false)
|
||||
.await?;
|
||||
create_missing(&orm).await?;
|
||||
return Ok(Self { orm, tmp: None });
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("ipx-test-{pid}-{n}.db"));
|
||||
let orm = connect(&path.display().to_string()).await?;
|
||||
let orm = connect(&path.display().to_string(), false).await?;
|
||||
// As Db::open has it, so the tests' connections share the file as the daemon's do.
|
||||
orm.execute_unprepared("PRAGMA journal_mode = WAL").await?;
|
||||
create_missing(&orm).await?;
|
||||
@@ -499,6 +531,7 @@ impl Db {
|
||||
last_error: f.last_error,
|
||||
orphaned: f.orphaned,
|
||||
error_since: f.error_since,
|
||||
same_since: f.same_since,
|
||||
category: f.category,
|
||||
},
|
||||
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
||||
@@ -530,6 +563,9 @@ pub struct HttpState {
|
||||
pub ttl_mins: Option<u64>,
|
||||
/// When the feed's current run of failures began, for backing off (`due_after`).
|
||||
pub error_since: Option<i64>,
|
||||
/// The last body's hash, and since when it has been that body (#242).
|
||||
pub body_hash: Option<String>,
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<feeds::Model> for HttpState {
|
||||
@@ -540,6 +576,8 @@ impl From<feeds::Model> for HttpState {
|
||||
last_checked: f.last_checked,
|
||||
ttl_mins: f.ttl_mins.map(|t| t.max(0) as u64),
|
||||
error_since: f.error_since,
|
||||
body_hash: f.body_hash,
|
||||
same_since: f.same_since,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,6 +644,16 @@ impl Db {
|
||||
}
|
||||
|
||||
/// 304, or any other poll that produced no new data: only the clock moves.
|
||||
/// A body read whole that was not the last one: its hash, and that it changed now (#242).
|
||||
pub async fn set_body(&self, feed_id: &str, hash: &str) -> Result<()> {
|
||||
self.exec(
|
||||
"UPDATE feeds SET body_hash = $2, same_since = $3 WHERE id = $1",
|
||||
vec![feed_id.into(), hash.into(), now().into()],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn touch_feed(&self, feed_id: &str, url: &str) -> Result<()> {
|
||||
self.exec(
|
||||
"INSERT INTO feeds (id, url, last_checked) VALUES ($1, $2, $3)
|
||||
@@ -1094,12 +1142,19 @@ fn entries_from(
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
guid: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
) -> String {
|
||||
let user = a.p(user_id);
|
||||
let mut scope = match feed_id {
|
||||
Some(f) => format!("e.feed_id = {}", a.p(f)),
|
||||
None => format!("e.feed_id IN (SELECT feed_id FROM subscriptions WHERE user_id = {user})"),
|
||||
};
|
||||
if let Some(id) = signal {
|
||||
scope = format!(
|
||||
"{scope} AND EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = {} AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)",
|
||||
a.p(id)
|
||||
);
|
||||
}
|
||||
if let Some(g) = guid {
|
||||
scope = format!("{scope} AND e.guid = {}", a.p(g));
|
||||
}
|
||||
@@ -1188,14 +1243,18 @@ impl Filter {
|
||||
// true and false, not 1 and 0: the columns are booleans on Postgres, and SQLite
|
||||
// reads true and false as 1 and 0.
|
||||
Self::All => "true",
|
||||
// An item with no row of state is unread, so this one keeps its coalesce.
|
||||
Self::Unread => "NOT coalesce(s.read, false)",
|
||||
Self::Flagged => "coalesce(s.flagged, false)",
|
||||
// Pinned and in progress need a row of state, said without coalesce so the database
|
||||
// can tell, and starts from this person's few rows instead of every item: Currently
|
||||
// Listening read all 574,000, 970 ms, where this takes 2 (#249).
|
||||
Self::Flagged => "s.flagged",
|
||||
Self::Downloaded => {
|
||||
"EXISTS (SELECT 1 FROM enclosures x
|
||||
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)"
|
||||
}
|
||||
Self::InProgress => {
|
||||
"coalesce(s.position, 0) > 5
|
||||
"s.position > 5
|
||||
AND (coalesce(s.duration, e.duration, 0) = 0
|
||||
OR s.position * 10 < coalesce(s.duration, e.duration) * 9)"
|
||||
}
|
||||
@@ -1239,14 +1298,37 @@ impl Db {
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_where(user_id, feed_id, filter, search, None, offset, limit, order).await
|
||||
self.entries_where(user_id, feed_id, filter, search, None, None, offset, limit, order).await
|
||||
}
|
||||
|
||||
/// A Signal's page of entries, as `entries_in` gives All Subscriptions'. The caller has made
|
||||
/// sure the Signal is this person's.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn signal_entries(
|
||||
&self,
|
||||
user_id: i64,
|
||||
signal: i64,
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
self.entries_where(user_id, None, filter, search, None, Some(signal), offset, limit, order).await
|
||||
}
|
||||
|
||||
pub async fn signal_count(&self, user_id: i64, signal: i64, filter: Filter, search: Option<&str>) -> Result<i64> {
|
||||
let mut a = Args::default();
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, None, filter, search, None, Some(signal)));
|
||||
let r = self.rows(&sql, a.0).await?;
|
||||
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
||||
}
|
||||
|
||||
/// One entry as `entries_in` gives it: what the player asks for again when the clock it
|
||||
/// plays in changes (DeadAir, #171).
|
||||
pub async fn entry(&self, user_id: i64, feed_id: &str, guid: &str) -> Result<Option<EntryRow>> {
|
||||
let order = order_sql("published", "desc");
|
||||
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), 0, 1, &order).await?.pop())
|
||||
Ok(self.entries_where(user_id, Some(feed_id), Filter::All, None, Some(guid), None, 0, 1, &order).await?.pop())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1257,12 +1339,13 @@ impl Db {
|
||||
filter: Filter,
|
||||
search: Option<&str>,
|
||||
guid: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
order: &str,
|
||||
) -> Result<Vec<EntryRow>> {
|
||||
let mut a = Args::default();
|
||||
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid);
|
||||
let from = entries_from(&mut a, user_id, feed_id, filter, search, guid, signal);
|
||||
// The page's keys first, then its rows: the sort then needs only `entries_by_time`, not
|
||||
// every item's row, and the show notes are read for the page alone (#188).
|
||||
let (limit, offset) = (a.p(limit), a.p(offset));
|
||||
@@ -1328,7 +1411,7 @@ impl Db {
|
||||
search: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let mut a = Args::default();
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None));
|
||||
let sql = format!("SELECT count(*) AS n {}", entries_from(&mut a, user_id, feed_id, filter, search, None, None));
|
||||
let r = self.rows(&sql, a.0).await?;
|
||||
Ok(r.first().context("count(*) always returns a row")?.try_get("", "n")?)
|
||||
}
|
||||
@@ -2461,6 +2544,239 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Unix seconds. Everything time-shaped in the DB is stored this way.
|
||||
/// A Signal as its owner's sidebar lists it (#254).
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct SignalRow {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub items: i64,
|
||||
pub unread: i64,
|
||||
}
|
||||
|
||||
/// What a Signal is changed to: each field None to leave it as it is.
|
||||
#[derive(Default)]
|
||||
pub struct SignalChange<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
pub words: Option<&'a [String]>,
|
||||
pub description: Option<Option<&'a str>>,
|
||||
pub icon: Option<Option<&'a str>>,
|
||||
}
|
||||
|
||||
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Ask {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// An item still to be put to Jev for someone, with each of their Signals that has not been
|
||||
/// asked about it.
|
||||
#[derive(Debug)]
|
||||
pub struct Unjudged {
|
||||
pub user_id: i64,
|
||||
pub key: String,
|
||||
pub feed_id: String,
|
||||
pub guid: String,
|
||||
pub feed_title: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub signals: Vec<Ask>,
|
||||
}
|
||||
|
||||
/// How far back a Signal looks when it is made: a month of items, not every back catalogue.
|
||||
pub const SIGNAL_DAYS: i64 = 30;
|
||||
|
||||
impl Db {
|
||||
pub async fn signals(&self, user_id: i64) -> Result<Vec<SignalRow>> {
|
||||
self.signal_rows(user_id, None).await
|
||||
}
|
||||
|
||||
/// One of this person's Signals: None for anyone else's, as for one that does not exist.
|
||||
pub async fn signal(&self, user_id: i64, id: i64) -> Result<Option<SignalRow>> {
|
||||
Ok(self.signal_rows(user_id, Some(id)).await?.pop())
|
||||
}
|
||||
|
||||
async fn signal_rows(&self, user_id: i64, id: Option<i64>) -> Result<Vec<SignalRow>> {
|
||||
let mut a = Args::default();
|
||||
let user = a.p(user_id);
|
||||
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
||||
// The items it shows, and of those the unread: what its list counts, by the same rules.
|
||||
let hits = |unread: &str| {
|
||||
format!(
|
||||
"(SELECT count(*) FROM signal_verdicts v
|
||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
|
||||
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
|
||||
not_hidden(&user)
|
||||
)
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread
|
||||
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
|
||||
hits("true"),
|
||||
hits("NOT coalesce(st.read, false)")
|
||||
);
|
||||
self.rows(&sql, a.0)
|
||||
.await?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
Ok(SignalRow {
|
||||
id: r.try_get("", "id")?,
|
||||
name: r.try_get("", "name")?,
|
||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||
description: r.try_get("", "description")?,
|
||||
icon: r.try_get("", "icon")?,
|
||||
items: r.try_get("", "items")?,
|
||||
unread: r.try_get("", "unread")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn add_signal(&self, user_id: i64, name: &str, words: &[String], description: Option<&str>) -> Result<i64> {
|
||||
let m = signals::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
name: Set(name.to_owned()),
|
||||
keywords: Set(serde_json::to_string(words)?),
|
||||
description: Set(description.map(str::to_owned)),
|
||||
created: Set(now()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&self.orm)
|
||||
.await?;
|
||||
Ok(m.id)
|
||||
}
|
||||
|
||||
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
|
||||
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
|
||||
/// this person's.
|
||||
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
|
||||
let SignalChange { name, words, description, icon } = change;
|
||||
if self.signal(user_id, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut m = signals::ActiveModel { id: Set(id), ..Default::default() };
|
||||
if let Some(n) = name {
|
||||
m.name = Set(n.to_owned());
|
||||
}
|
||||
if let Some(w) = words {
|
||||
m.keywords = Set(serde_json::to_string(w)?);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
m.description = Set(d.map(str::to_owned));
|
||||
}
|
||||
if let Some(i) = icon {
|
||||
m.icon = Set(i.map(str::to_owned));
|
||||
}
|
||||
if name.is_some() || words.is_some() || description.is_some() {
|
||||
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
||||
}
|
||||
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
|
||||
m.update(&self.orm).await?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn remove_signal(&self, user_id: i64, id: i64) -> Result<bool> {
|
||||
if self.signal(user_id, id).await?.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Not left to the cascade: SQLite enforces foreign keys only when asked to.
|
||||
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
||||
signals::Entity::delete_by_id(id).exec(&self.orm).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn typesafe_key_set(&self, user_id: i64) -> Result<bool> {
|
||||
Ok(users::Entity::find_by_id(user_id).one(&self.orm).await?.is_some_and(|u| u.typesafe_key.is_some()))
|
||||
}
|
||||
|
||||
pub async fn set_typesafe_key(&self, user_id: i64, key: Option<&str>) -> Result<()> {
|
||||
self.update_user(user_id, users::ActiveModel { typesafe_key: Set(key.map(str::to_owned)), ..Default::default() })
|
||||
.await
|
||||
}
|
||||
|
||||
/// Up to `limit` items, newest first, that someone with a key has a Signal not yet asked
|
||||
/// about: from the feeds they subscribe to, the last `SIGNAL_DAYS`, and not hidden from them.
|
||||
/// `skip` are people whose key TypeSafe has refused.
|
||||
pub async fn unjudged(&self, skip: &[i64], limit: i64) -> Result<Vec<Unjudged>> {
|
||||
let mut a = Args::default();
|
||||
let since = a.p(now() - SIGNAL_DAYS * 86_400);
|
||||
let skip = if skip.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("AND g.user_id NOT IN ({})", skip.iter().map(|u| a.p(*u)).collect::<Vec<_>>().join(", "))
|
||||
};
|
||||
let limit = a.p(limit);
|
||||
let sql = format!(
|
||||
"SELECT g.id AS signal_id, g.name, g.keywords, g.description AS signal_description, g.user_id, u.typesafe_key, e.feed_id, e.guid, e.title,
|
||||
e.description, f.title AS feed_title
|
||||
FROM signals g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
JOIN subscriptions s ON s.user_id = g.user_id
|
||||
JOIN entries e ON e.feed_id = s.feed_id
|
||||
LEFT JOIN feeds f ON f.id = e.feed_id
|
||||
WHERE u.typesafe_key IS NOT NULL {skip}
|
||||
AND coalesce(e.published, e.first_seen) >= {since}
|
||||
AND NOT EXISTS (SELECT 1 FROM signal_verdicts v
|
||||
WHERE v.signal_id = g.id AND v.feed_id = e.feed_id AND v.guid = e.guid)
|
||||
AND NOT EXISTS (SELECT 1 FROM hidden h
|
||||
WHERE h.user_id = g.user_id AND h.feed_id = e.feed_id AND h.guid = e.guid)
|
||||
ORDER BY coalesce(e.published, e.first_seen) DESC, e.feed_id, e.guid, g.user_id
|
||||
LIMIT {limit}"
|
||||
);
|
||||
let mut out: Vec<Unjudged> = Vec::new();
|
||||
for r in self.rows(&sql, a.0).await? {
|
||||
let (user_id, feed_id, guid): (i64, String, String) =
|
||||
(r.try_get("", "user_id")?, r.try_get("", "feed_id")?, r.try_get("", "guid")?);
|
||||
let signal = Ask {
|
||||
id: r.try_get("", "signal_id")?,
|
||||
name: r.try_get("", "name")?,
|
||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||
// Named apart from the item's own description, which is in the same row.
|
||||
description: r.try_get("", "signal_description")?,
|
||||
};
|
||||
// Rows of one item for one person come together, by the ORDER BY.
|
||||
match out.last_mut() {
|
||||
Some(u) if (u.user_id, &u.feed_id, &u.guid) == (user_id, &feed_id, &guid) => u.signals.push(signal),
|
||||
_ => out.push(Unjudged {
|
||||
user_id,
|
||||
key: r.try_get("", "typesafe_key")?,
|
||||
feed_id,
|
||||
guid,
|
||||
feed_title: r.try_get("", "feed_title")?,
|
||||
title: r.try_get("", "title")?,
|
||||
description: r.try_get("", "description")?,
|
||||
signals: vec![signal],
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn record_verdicts(&self, feed_id: &str, guid: &str, verdicts: &[(i64, bool)]) -> Result<()> {
|
||||
for (signal, hit) in verdicts {
|
||||
// Not for a Signal removed while Jev was being asked, which Postgres's foreign key
|
||||
// would refuse and SQLite would keep for nothing.
|
||||
// shortcut: keywords changed mid-call keep a verdict on the old ones for that item.
|
||||
self.exec(
|
||||
"INSERT INTO signal_verdicts (signal_id, feed_id, guid, hit)
|
||||
SELECT $1, $2, $3, $4 WHERE EXISTS (SELECT 1 FROM signals WHERE id = $1)
|
||||
ON CONFLICT (signal_id, feed_id, guid) DO UPDATE SET hit = excluded.hit",
|
||||
vec![(*signal).into(), feed_id.into(), guid.into(), (*hit).into()],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -2581,6 +2897,23 @@ mod tests {
|
||||
assert_eq!(db.http_state("f").await.unwrap().etag.as_deref(), Some("\"b\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_database_from_before_body_hashes_gains_the_columns() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
db.record_feed("f", "https://x/f", Some("F"), None, None, None, None, None, None).await.unwrap();
|
||||
db.set_body("f", "abc").await.unwrap();
|
||||
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("abc"));
|
||||
// A database from before #242.
|
||||
for sql in ["ALTER TABLE feeds DROP COLUMN body_hash", "ALTER TABLE feeds DROP COLUMN same_since"] {
|
||||
db.exec_for_test(sql).await.unwrap();
|
||||
}
|
||||
create_missing(&db.orm).await.unwrap();
|
||||
let st = db.http_state("f").await.unwrap();
|
||||
assert_eq!((st.body_hash, st.same_since), (None, None), "every feed is read whole once more");
|
||||
db.set_body("f", "def").await.unwrap();
|
||||
assert_eq!(db.http_state("f").await.unwrap().body_hash.as_deref(), Some("def"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_sort_column_runs_and_orders_both_ways() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
@@ -3041,6 +3374,70 @@ mod tests {
|
||||
assert_eq!(listening().await, ["h", "e"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_signal_shows_what_jev_said_fits_and_only_to_its_owner() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
let t = now();
|
||||
db.exec_for_test(&format!(
|
||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true), (2,'b',false);
|
||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'), (1,'g'), (2,'f');
|
||||
INSERT INTO entries (feed_id, guid, title, published, first_seen) VALUES
|
||||
('f','x','Monaco qualifying',{t},{t}),
|
||||
('g','y','Bread',{t},{t}),
|
||||
('g','old','Silverstone 1998',100,100),
|
||||
('h','z','Not subscribed',{t},{t});"
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let words = |w: &[&str]| w.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let f1 = db.add_signal(1, "F1", &words(&["formula 1"]), None).await.unwrap();
|
||||
let food = db.add_signal(1, "Food", &words(&["baking"]), Some("Bread and cakes")).await.unwrap();
|
||||
db.add_signal(2, "Theirs", &words(&["x"]), None).await.unwrap();
|
||||
|
||||
assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nobody has a key yet");
|
||||
db.set_typesafe_key(1, Some("k")).await.unwrap();
|
||||
let queue = db.unjudged(&[], 50).await.unwrap();
|
||||
let mut seen: Vec<_> = queue.iter().map(|u| (u.guid.as_str(), u.signals.len())).collect();
|
||||
seen.sort();
|
||||
assert_eq!(seen, [("x", 2), ("y", 2)], "each recent subscribed item once, with both Signals; old and unsubscribed left out");
|
||||
let ask = queue[0].signals.iter().find(|a| a.id == food).unwrap();
|
||||
assert_eq!((ask.name.as_str(), ask.description.as_deref()), ("Food", Some("Bread and cakes")), "Jev is asked about the name and the sentence");
|
||||
assert!(db.unjudged(&[1], 50).await.unwrap().is_empty(), "a refused key is passed over");
|
||||
|
||||
db.record_verdicts("f", "x", &[(f1, true), (food, false)]).await.unwrap();
|
||||
db.record_verdicts("g", "y", &[(f1, false), (food, true)]).await.unwrap();
|
||||
assert!(db.unjudged(&[], 50).await.unwrap().is_empty(), "nothing is asked twice");
|
||||
|
||||
let order = order_sql("published", "desc");
|
||||
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
|
||||
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
|
||||
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
|
||||
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
|
||||
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
|
||||
.await
|
||||
.unwrap();
|
||||
let f = db.signal(1, f1).await.unwrap().unwrap();
|
||||
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
|
||||
|
||||
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
||||
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
||||
assert!(!db.remove_signal(2, f1).await.unwrap());
|
||||
|
||||
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
|
||||
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
|
||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
|
||||
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
|
||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
|
||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
|
||||
assert!(db.remove_signal(1, food).await.unwrap());
|
||||
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();
|
||||
assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_lists_hide_items_from_whoever_keeps_them() {
|
||||
let db = Db::memory().await.unwrap();
|
||||
|
||||
@@ -55,6 +55,9 @@ pub fn configured(cfg: &config::Config) -> bool {
|
||||
target(cfg).is_some()
|
||||
}
|
||||
|
||||
/// A span the whole call, its answer read and parsed included: the directory's own span ends when it
|
||||
/// has answered, so a slow read of a large answer showed under no span (#250).
|
||||
#[tracing::instrument(name = "directory_call", skip_all, fields(http.request.method = %method, url.path = %path.split('?').next().unwrap_or(path), http.response.body.size))]
|
||||
async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &str, body: Option<&[SnapshotEntry]>) -> Result<T, DirError> {
|
||||
let (base, token) = target(&ctx.cfg()).ok_or(DirError::NotConfigured)?;
|
||||
let mut req = ctx.client.request(method, format!("{base}{path}")).bearer_auth(token).timeout(std::time::Duration::from_secs(20));
|
||||
@@ -62,8 +65,17 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
||||
if let Some(tp) = ipx_directory::otel::traceparent() {
|
||||
req = req.header("traceparent", tp);
|
||||
}
|
||||
// A snapshot is about 100 KB of JSON, sent gzipped; the directory unpacks it (#253).
|
||||
if let Some(b) = body {
|
||||
req = req.json(b);
|
||||
use std::io::Write as _;
|
||||
let json = serde_json::to_vec(b).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
gz.write_all(&json).map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let packed = gz.finish().map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
req = req
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header(reqwest::header::CONTENT_ENCODING, "gzip")
|
||||
.body(packed);
|
||||
}
|
||||
let resp = req.send().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
let status = resp.status();
|
||||
@@ -73,6 +85,7 @@ async fn call<T: DeserializeOwned>(ctx: &Ctx, method: reqwest::Method, path: &st
|
||||
}
|
||||
// A removal or an unblock answers 204, with nothing to read: that reads as null, which is ().
|
||||
let body = resp.bytes().await.map_err(|e| DirError::Unreachable(format!("{e:#}")))?;
|
||||
tracing::Span::current().record("http.response.body.size", body.len());
|
||||
serde_json::from_slice(if body.is_empty() { b"null" } else { &body }).map_err(|e| DirError::Unreachable(format!("its answer did not read: {e:#}")))
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ pub mod feeds {
|
||||
/// are re-derived instead. Customising one promotes it to config.
|
||||
#[sea_orm(default_value = false)]
|
||||
pub managed: bool,
|
||||
/// SHA-256 of the body last read, in hex: the same body again is read as "not modified"
|
||||
/// (#242). Added after the table was, so `create_missing` adds it, and `same_since`.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub body_hash: Option<String>,
|
||||
/// When the body last changed: the longer ago, the less often the feed is checked (#242).
|
||||
pub same_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -151,6 +157,12 @@ pub mod users {
|
||||
pub theme: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub theme_mode: Option<String>,
|
||||
/// Their own TypeSafe key, which Jev judges their Signals' items on (#254). Never sent
|
||||
/// back to the page.
|
||||
// shortcut: kept as it is, since every call needs it whole; encrypt it with a key of the
|
||||
// server's if the database is ever shared with anything else.
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub typesafe_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
@@ -276,6 +288,67 @@ pub mod hidden {
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// A feed someone makes from their own subscriptions (#254): the items Jev says fit its
|
||||
/// keywords, whichever feed they came from.
|
||||
pub mod signals {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signals")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub user_id: i64,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub name: String,
|
||||
/// JSON array of strings.
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub keywords: String,
|
||||
/// What its owner means by it, in a sentence of their own, when the name and the words
|
||||
/// are not enough (#259).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub description: Option<String>,
|
||||
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||
/// initials (#261).
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub icon: Option<String>,
|
||||
pub created: i64,
|
||||
}
|
||||
|
||||
owned_by_user!();
|
||||
}
|
||||
|
||||
/// What Jev said of each item a Signal has been asked about, fitting or not: an item with no row
|
||||
/// here is still to be asked.
|
||||
pub mod signal_verdicts {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "signal_verdicts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub signal_id: i64,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub feed_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||
pub guid: String,
|
||||
pub hit: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::signals::Entity",
|
||||
from = "Column::SignalId",
|
||||
to = "super::signals::Column::Id",
|
||||
on_delete = "Cascade"
|
||||
)]
|
||||
Signal,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
}
|
||||
|
||||
pub mod sessions {
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ pub enum Event {
|
||||
TorrentDeferred { feed: String, url: String },
|
||||
/// DeadAir finished a file's copy (#171), so a page showing it as in progress can stop.
|
||||
DeadairDone { feed: String, enclosure: i64, saved_s: i64 },
|
||||
/// Jev found items for someone's Signals (#258), so their page fetches the counts again.
|
||||
SignalHits { user: i64, hits: usize },
|
||||
Reaped { path: String, bytes: u64 },
|
||||
/// Terminal: a client that asked for work stops reading here.
|
||||
ScanDone {
|
||||
@@ -90,6 +92,7 @@ impl Event {
|
||||
Event::FeedStart { feed } => format!("{feed}: checking"),
|
||||
Event::TorrentDeferred { feed, .. } => format!("{feed}: torrent deferred"),
|
||||
Event::DeadairDone { feed, saved_s, .. } => format!("{feed}: DeadAir found the silences, {saved_s} s shorter"),
|
||||
Event::SignalHits { hits, .. } => format!("{hits} item(s) picked out for Signals"),
|
||||
Event::ScanDone { feeds, .. } => format!("scan complete, {feeds} feed(s)"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ mod ipc;
|
||||
mod logbuf;
|
||||
mod metrics;
|
||||
mod retention;
|
||||
mod signals;
|
||||
mod torrent;
|
||||
mod web;
|
||||
|
||||
@@ -159,6 +160,10 @@ pub struct Ctx {
|
||||
/// Wakes DeadAir's maker when a show is turned on, so its copies start at once instead of
|
||||
/// at the next pass (#171).
|
||||
pub deadair: tokio::sync::Notify,
|
||||
/// Wakes the Signals judge when there are new items or a Signal is made or changed (#254).
|
||||
pub signals: tokio::sync::Notify,
|
||||
/// People whose TypeSafe key was refused, passed over until they save another.
|
||||
pub signals_refused: std::sync::Mutex<std::collections::HashSet<i64>>,
|
||||
/// When the Directory was last cleared of dead feeds (`clean_directory`), 0 for not since
|
||||
/// starting.
|
||||
pub directory_cleaned: std::sync::atomic::AtomicI64,
|
||||
@@ -306,7 +311,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
let config_path = cli.config.clone().unwrap_or_else(config::config_path);
|
||||
let cfg = config::Config::load(&config_path)?;
|
||||
let db = db::Db::open(&db::location()).await?;
|
||||
let db = db::Db::open(&db::location(), matches!(cli.command, Command::Daemon { .. })).await?;
|
||||
|
||||
// A daemon owns the state; don't have two processes downloading the same thing.
|
||||
let wire_cmd = match &cli.command {
|
||||
@@ -367,6 +372,8 @@ async fn main() -> Result<()> {
|
||||
cfg_write: Default::default(),
|
||||
directory_cleaned: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
@@ -663,6 +670,7 @@ async fn daemon(
|
||||
// Off the worker: the first pass over a library is hours of ffmpeg, and scans and downloads
|
||||
// must not wait behind it.
|
||||
tokio::spawn(deadair::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(signals::run(ctx.clone(), rx_stop.clone()));
|
||||
tokio::spawn(directory::run(ctx.clone(), rx_stop.clone()));
|
||||
// The library's counts for /metrics. The Docker healthcheck asked for them every 30 s until
|
||||
// it was removed: Docker runs it with exec, which failed on Tower whenever root was logged
|
||||
@@ -1531,7 +1539,7 @@ async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
||||
pub fn due_at(cfg: &config::Config, feed: &config::Feed, state: &db::HttpState, subscribed: bool) -> Option<i64> {
|
||||
let last = state.last_checked?;
|
||||
let floor = if subscribed { 0 } else { 86_400 };
|
||||
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, last).max(floor) as i64)
|
||||
Some(last + due_after(cfg, feed, state.ttl_mins, state.error_since, state.same_since, last).max(floor) as i64)
|
||||
}
|
||||
|
||||
/// How long the daemon may sleep before a feed is due: until the earliest one, at least 30 s and
|
||||
@@ -1577,14 +1585,17 @@ pub fn due_after(
|
||||
feed: &config::Feed,
|
||||
ttl_mins: Option<u64>,
|
||||
error_since: Option<i64>,
|
||||
same_since: Option<i64>,
|
||||
last_checked: i64,
|
||||
) -> u64 {
|
||||
let mins = match feed.schedule.as_deref().and_then(config::parse_interval) {
|
||||
Some(explicit) => explicit,
|
||||
None => ttl_mins.unwrap_or(0).max(cfg.general.interval()),
|
||||
let explicit = feed.schedule.as_deref().and_then(config::parse_interval);
|
||||
let usual = match explicit {
|
||||
Some(explicit) => explicit * 60,
|
||||
// A schedule someone set is theirs; otherwise a feed long the same is checked less.
|
||||
None => quiet(ttl_mins.unwrap_or(0).max(cfg.general.interval()) * 60, same_since.map(|s| (last_checked - s).max(0) as u64)),
|
||||
};
|
||||
let failing_for = error_since.map(|since| (last_checked - since).max(0) as u64);
|
||||
backoff(mins * 60, failing_for)
|
||||
backoff(usual, failing_for)
|
||||
}
|
||||
|
||||
/// A failing feed waits as long as it has been failing, so the wait doubles with each failure
|
||||
@@ -1592,6 +1603,17 @@ pub fn due_after(
|
||||
/// never more than a day. Retried hourly, a feed dead for good cost a request, a warning and scan
|
||||
/// time every hour (#99). The first success clears error_since, and with it the backoff; a
|
||||
/// refresh someone asks for is not held back by it.
|
||||
/// A feed whose body has been the same for a while is checked less often, a twelfth of how long
|
||||
/// it has been the same, up to four hours, and on its schedule again once it changes: Patreon and
|
||||
/// Substack send the whole feed at every check, ignoring "not modified", 2.6 GB a day (#242).
|
||||
fn quiet(usual: u64, same_for: Option<u64>) -> u64 {
|
||||
const CEILING: u64 = 4 * 3600;
|
||||
match same_for {
|
||||
Some(f) => usual.max((f / 12).min(CEILING)),
|
||||
None => usual,
|
||||
}
|
||||
}
|
||||
|
||||
fn backoff(usual: u64, failing_for: Option<u64>) -> u64 {
|
||||
const CEILING: u64 = 86_400;
|
||||
match failing_for {
|
||||
@@ -1721,6 +1743,18 @@ async fn scan_one(
|
||||
return Ok(Outcome::Empty);
|
||||
}
|
||||
|
||||
// The same body as last time, from a server that ignores "not modified": nothing to read,
|
||||
// as a 304 would have said (#242). Not when something is missing that a read would fill, as
|
||||
// above, nor when someone asked for a full check.
|
||||
let hash = {
|
||||
use sha2::Digest as _;
|
||||
sha2::Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect::<String>()
|
||||
};
|
||||
if !force && state.body_hash.as_deref() == Some(hash.as_str()) && stored.entries > 0 && stored.image.is_some() {
|
||||
ctx.db.touch_feed(id, &feed_cfg.url).await?;
|
||||
return Ok(Outcome::NotModified);
|
||||
}
|
||||
|
||||
// A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml"
|
||||
// URL; sniffing the body also catches one served from a URL without that extension.
|
||||
if feed::is_opml(&bytes) {
|
||||
@@ -1792,6 +1826,9 @@ async fn scan_one(
|
||||
parsed.category.as_deref(),
|
||||
parsed.description.as_deref(),
|
||||
).await?;
|
||||
if state.body_hash.as_deref() != Some(hash.as_str()) {
|
||||
ctx.db.set_body(id, &hash).await?;
|
||||
}
|
||||
// Asked on a feed's first read, nothing of it stored yet, or when someone forces a refresh:
|
||||
// each call costs, and a feed already here without a category can wait for a forced one.
|
||||
// The answer becomes the catalogue's category, so a feed is not asked twice.
|
||||
@@ -1864,6 +1901,7 @@ async fn scan_one(
|
||||
}, store).await?;
|
||||
if scan.new_entries > 0 {
|
||||
ctx.db.rehide(id).await?; // what is new may hold someone's blocked words
|
||||
ctx.signals.notify_one();
|
||||
}
|
||||
// A new item takes a place among the newest and the oldest of them leaves the queue; a file a
|
||||
// filter lets through again may be outside them.
|
||||
@@ -2389,6 +2427,40 @@ mod tests {
|
||||
assert_eq!(at(Some(1000), false), Some(1000 + 86_400)); // listed, nobody subscribed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_long_the_same_is_checked_less_and_a_schedule_set_wins() {
|
||||
let (hour, day) = (3600u64, 86_400u64);
|
||||
assert_eq!(quiet(hour, None), hour);
|
||||
assert_eq!(quiet(hour, Some(6 * hour)), hour, "a few hours the same changes nothing");
|
||||
assert_eq!(quiet(hour, Some(day)), 2 * hour, "a day the same: every two hours");
|
||||
assert_eq!(quiet(hour, Some(30 * day)), 4 * hour, "never past four hours");
|
||||
assert_eq!(quiet(12 * hour, Some(30 * day)), 12 * hour, "never sooner than its schedule");
|
||||
let cfg = config::Config::default();
|
||||
let same = |f: &config::Feed| due_after(&cfg, f, None, None, Some(0), 3 * day as i64);
|
||||
assert_eq!(same(&feed()), 4 * hour);
|
||||
let set = config::Feed { schedule: Some("every 1h".into()), ..feed() };
|
||||
assert_eq!(same(&set), hour, "someone's own schedule wins");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_same_body_again_is_not_modified_unless_a_check_is_forced() {
|
||||
let base = empty_or_feed_server().await;
|
||||
let ctx = Arc::new(test_ctx(config::Config::default()).await);
|
||||
let cfg = config::Feed { url: format!("{base}/feed"), ..feed() };
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &db::HttpState::default(), false, None).await, Ok(Outcome::Feed(_))));
|
||||
let state = ctx.db.http_state("same").await.unwrap();
|
||||
let since = state.same_since.expect("when it was first read");
|
||||
assert!(state.body_hash.is_some());
|
||||
// Patreon's way: the whole feed again, no validator honoured (#242).
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, false, None).await, Ok(Outcome::NotModified)));
|
||||
assert_eq!(ctx.db.http_state("same").await.unwrap().same_since, Some(since), "still the same since then");
|
||||
assert!(matches!(scan_one(&ctx, "same", &cfg, &state, true, None).await, Ok(Outcome::Feed(_))), "a forced check reads it");
|
||||
// Changed: read, and the same from now.
|
||||
let moved = config::Feed { url: format!("{base}/fixed"), ..feed() };
|
||||
assert!(matches!(scan_one(&ctx, "same", &moved, &state, false, None).await, Ok(Outcome::Feed(_))));
|
||||
assert_ne!(ctx.db.http_state("same").await.unwrap().body_hash, state.body_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_listed_feed_nobody_subscribes_to_downloads_nothing() {
|
||||
let mut f = feed();
|
||||
@@ -2542,6 +2614,8 @@ mod tests {
|
||||
retry: Default::default(),
|
||||
cfg_write: Default::default(),
|
||||
deadair: Default::default(),
|
||||
signals: Default::default(),
|
||||
signals_refused: Default::default(),
|
||||
directory_wake: Default::default(),
|
||||
directory_shared: Default::default(),
|
||||
directory_resend: Default::default(),
|
||||
|
||||
@@ -135,7 +135,9 @@ pub fn spawn(bind: &str, s: Sources) -> anyhow::Result<std::net::SocketAddr> {
|
||||
let s = s.clone();
|
||||
async move { ([(axum::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")], render(&s)) }
|
||||
}),
|
||||
);
|
||||
)
|
||||
// Prometheus asks for gzip, every 15 s (#253).
|
||||
.layer(tower_http::compression::CompressionLayer::new().gzip(true));
|
||||
match tokio::net::TcpListener::from_std(listener) {
|
||||
Ok(l) => {
|
||||
if let Err(e) = axum::serve(l, app).await {
|
||||
|
||||
365
crates/ipx/src/signals.rs
Normal file
365
crates/ipx/src/signals.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! Signals (#254): feeds someone makes from their own subscriptions, holding the items TypeSafe's
|
||||
//! Jev says fit the keywords they gave. Jev is asked on their own key, once for each item, with a
|
||||
//! question for each of their Signals that has not had it yet, and what it says is kept in
|
||||
//! `signal_verdicts`, fitting or not, so nothing is asked twice.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing::Instrument;
|
||||
|
||||
use crate::Ctx;
|
||||
use crate::db::{Ask, Unjudged};
|
||||
use crate::feed;
|
||||
|
||||
/// Items put to Jev a pass. A pass ends early once there are none left, so this only says how
|
||||
/// long the queue is held before people who got a key later are looked at.
|
||||
const BATCH: i64 = 50;
|
||||
/// Between passes when nothing wakes it: a scan or a new Signal does, so this is a backstop.
|
||||
const IDLE: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
/// How sure Jev has to be that an item is about the keywords.
|
||||
const THRESHOLD: f64 = 0.5;
|
||||
/// Of an item's text, which is a podcast's whole show notes at times.
|
||||
const TEXT_CHARS: usize = 2000;
|
||||
|
||||
pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
let done = tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
done = pass(&ctx) => done,
|
||||
};
|
||||
match done {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "Signals could not be judged"),
|
||||
}
|
||||
tokio::select! {
|
||||
_ = stop.changed() => return,
|
||||
_ = ctx.signals.notified() => {}
|
||||
_ = tokio::time::sleep(IDLE) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One batch. True when there may be more waiting.
|
||||
async fn pass(ctx: &Ctx) -> Result<bool> {
|
||||
let skip: Vec<i64> = ctx.signals_refused.lock().unwrap().iter().copied().collect();
|
||||
let items = ctx.db.unjudged(&skip, BATCH).await?;
|
||||
if items.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let span = tracing::info_span!("signals", entries = items.len());
|
||||
async {
|
||||
let mut refused = HashSet::new();
|
||||
let mut hits_for = std::collections::BTreeMap::<i64, usize>::new();
|
||||
let mut stopped = false;
|
||||
for item in &items {
|
||||
if refused.contains(&item.user_id) {
|
||||
continue;
|
||||
}
|
||||
match judge(ctx, item).await {
|
||||
Ok(verdicts) => {
|
||||
let hits = verdicts.iter().filter(|(_, h)| *h).count();
|
||||
if hits > 0 {
|
||||
*hits_for.entry(item.user_id).or_default() += hits;
|
||||
tracing::info!(ev = "signal", user = item.user_id, feed = %item.feed_id, new = hits, "an item fits a Signal");
|
||||
}
|
||||
ctx.db.record_verdicts(&item.feed_id, &item.guid, &verdicts).await?;
|
||||
}
|
||||
// A key TypeSafe will not take is not sent the rest of the queue. It is tried
|
||||
// again once its owner saves one (`web::patch_me`) or the daemon restarts.
|
||||
Err(e) if refuses_key(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signal key");
|
||||
refused.insert(item.user_id);
|
||||
}
|
||||
// TypeSafe says the request itself is wrong, so every other item's would be too:
|
||||
// the rest wait for the next pass rather than each failing the same way (#257).
|
||||
Err(e) if malformed(&e) => {
|
||||
tracing::warn!(user = item.user_id, error = %format!("{e:#}"), "TypeSafe refused a Signals request as malformed");
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
// Left unjudged, so it is asked again next pass.
|
||||
Err(e) => tracing::warn!(user = item.user_id, feed = %item.feed_id, error = %format!("{e:#}"), "Jev could not judge an item"),
|
||||
}
|
||||
}
|
||||
// Once a pass, not once an item: a page fetches its counts again on each.
|
||||
for (user, hits) in hits_for {
|
||||
ctx.out.emit(crate::ipc::Event::SignalHits { user, hits });
|
||||
}
|
||||
let some_refused = !refused.is_empty();
|
||||
ctx.signals_refused.lock().unwrap().extend(refused);
|
||||
// A pass that only failed would otherwise go straight round again on the same items, and
|
||||
// one TypeSafe refused as malformed would ask again at once, however full.
|
||||
anyhow::Ok(!stopped && (full(&items) || some_refused))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A call TypeSafe answered with an error, and what it said: its body says why, where the status
|
||||
/// alone said only 422 (#257).
|
||||
#[derive(Debug)]
|
||||
struct Refused {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Refused {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "TypeSafe answered {}: {}", self.status, self.body)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Refused {}
|
||||
|
||||
fn status(e: &anyhow::Error) -> Option<u16> {
|
||||
e.downcast_ref::<Refused>().map(|r| r.status)
|
||||
}
|
||||
|
||||
/// Whether the batch was as long as `unjudged` gives, so more may be waiting. Its limit is on
|
||||
/// rows, one for each item and Signal: with two Signals, 50 is 25 items, and comparing the items
|
||||
/// with 50 took every batch for the last, 25 items every 5 minutes (#260).
|
||||
fn full(items: &[Unjudged]) -> bool {
|
||||
items.iter().map(|i| i.signals.len()).sum::<usize>() as i64 >= BATCH
|
||||
}
|
||||
|
||||
fn refuses_key(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(401 | 402 | 403))
|
||||
}
|
||||
|
||||
fn malformed(e: &anyhow::Error) -> bool {
|
||||
matches!(status(e), Some(400 | 422))
|
||||
}
|
||||
|
||||
async fn judge(ctx: &Ctx, item: &Unjudged) -> Result<Vec<(i64, bool)>> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!(
|
||||
"jev",
|
||||
user = item.user_id,
|
||||
signal_count = item.signals.len(),
|
||||
"http.response.status_code" = tracing::field::Empty,
|
||||
error = tracing::field::Empty,
|
||||
);
|
||||
let answer: serde_json::Value = async {
|
||||
let r = ctx.client.post(url).bearer_auth(&item.key).json(&request(item)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
tracing::Span::current().record("error", body.as_str());
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
anyhow::Ok(r.json().await?)
|
||||
}
|
||||
.instrument(span)
|
||||
.await?;
|
||||
// A question Jev left unanswered stays unjudged and is asked again.
|
||||
Ok(item.signals.iter().filter_map(|a| hit(&answer, a.id).map(|h| (a.id, h))).collect())
|
||||
}
|
||||
|
||||
/// The item as Jev sees it, as text, which is what its docs show: an object with an empty feed
|
||||
/// title in it sent a null. One yes-or-no question for each Signal, keyed by its id.
|
||||
fn request(item: &Unjudged) -> serde_json::Value {
|
||||
let text: String = text_of(item.description.as_deref().unwrap_or("")).chars().take(TEXT_CHARS).collect();
|
||||
let state = format!(
|
||||
"Feed: {}\nTitle: {}\n\n{text}",
|
||||
item.feed_title.as_deref().unwrap_or(""),
|
||||
item.title.as_deref().unwrap_or("")
|
||||
);
|
||||
let questions: serde_json::Map<String, serde_json::Value> =
|
||||
item.signals.iter().map(|a| (key(a.id), question(a))).collect();
|
||||
serde_json::json!({ "model": "jev-latest", "state": state, "questions": questions })
|
||||
}
|
||||
|
||||
fn key(id: i64) -> String {
|
||||
format!("signal_{id}")
|
||||
}
|
||||
|
||||
/// One subject, named by the Signal and described by its words and its owner's sentence. Asked
|
||||
/// whether an item was about any one of its words, AI Development (ai, development, programming)
|
||||
/// took a game's remaster at 0.90 and a C++ tutorial at 0.99; asked about the subject, 0.06 and
|
||||
/// 0.02, with AI code review still 0.94 (#259). `criteria` is an object of what true and false
|
||||
/// mean: a sentence on its own was refused with a 422 (#257).
|
||||
fn question(a: &Ask) -> serde_json::Value {
|
||||
let mut said = format!("The reader follows a subject they call \"{}\", described by these words: {}.", a.name, a.keywords.join(", "));
|
||||
if let Some(d) = &a.description {
|
||||
said.push_str(&format!(" In their own words: {d}"));
|
||||
}
|
||||
serde_json::json!({
|
||||
"type": "noul",
|
||||
"instructions": format!("{said} Is this item about that subject?"),
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing.",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether Jev says the item fits Signal `id`; None when the answer does not say.
|
||||
fn hit(answer: &serde_json::Value, id: i64) -> Option<bool> {
|
||||
answer["answers"][key(id)]["noul"].as_f64().map(|p| p >= THRESHOLD)
|
||||
}
|
||||
|
||||
/// An icon for a Signal, picked by Jev from the ones the page found for its words (#263): Jev
|
||||
/// chooses among options and cannot name one, so the page searches Font Awesome, whose index is
|
||||
/// ipx-web's, and sends the likeliest. On the server's key, not the Signal owner's: it is the
|
||||
/// server's page doing it, once a Signal.
|
||||
pub async fn pick_icon(ctx: &Ctx, key: &str, name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> Result<String> {
|
||||
let url = std::env::var("IPX_TYPESAFE_URL").unwrap_or_else(|_| feed::JEV_URL.to_owned());
|
||||
let span = tracing::info_span!("jev_icon", candidates = candidates.len(), "http.response.status_code" = tracing::field::Empty);
|
||||
async {
|
||||
let r = ctx.client.post(url).bearer_auth(key).json(&icon_request(name, words, description, candidates)).timeout(feed::FEED_TIMEOUT).send().await?;
|
||||
let status = r.status().as_u16();
|
||||
tracing::Span::current().record("http.response.status_code", status);
|
||||
if !r.status().is_success() {
|
||||
let body: String = r.text().await.unwrap_or_default().chars().take(500).collect();
|
||||
return Err(Refused { status, body }.into());
|
||||
}
|
||||
let answer: serde_json::Value = r.json().await?;
|
||||
chosen(&answer, candidates).ok_or_else(|| anyhow::anyhow!("Jev chose no icon offered: {answer}"))
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
|
||||
fn icon_request(name: &str, words: &[String], description: Option<&str>, candidates: &[(String, String)]) -> serde_json::Value {
|
||||
let mut state = format!("Signal: {name}\nListens for: {}", words.join(", "));
|
||||
if let Some(d) = description {
|
||||
state.push_str(&format!("\nIn their words: {d}"));
|
||||
}
|
||||
let criteria: serde_json::Map<String, serde_json::Value> =
|
||||
candidates.iter().map(|(id, label)| (id.clone(), label.clone().into())).collect();
|
||||
serde_json::json!({
|
||||
"model": "jev-latest",
|
||||
"state": state,
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": criteria,
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
/// Jev's choice, only if it is one it was offered.
|
||||
fn chosen(answer: &serde_json::Value, candidates: &[(String, String)]) -> Option<String> {
|
||||
let c = answer["answers"]["icon"]["choice"].as_str()?;
|
||||
candidates.iter().find(|(id, _)| id == c).map(|(id, _)| id.clone())
|
||||
}
|
||||
|
||||
/// Show notes are HTML: Jev is given their words, and an image's alt and title text, which is all
|
||||
/// the words some posts have. xkcd's is a comic and its hover text; dropping the tag whole sent Jev
|
||||
/// "Ground Effect" alone, an F1 aerodynamics term, and the comic went into Formula 1 (#267).
|
||||
fn text_of(html: &str) -> String {
|
||||
let mut out = String::with_capacity(html.len());
|
||||
let mut tag: Option<String> = None;
|
||||
for c in html.chars() {
|
||||
match (&mut tag, c) {
|
||||
(None, '<') => tag = Some(String::new()),
|
||||
(Some(t), '>') => {
|
||||
let mut said: Vec<&str> = Vec::new();
|
||||
for name in ["alt", "title"] {
|
||||
if let Some(v) = attr(t, name).filter(|v| !v.trim().is_empty() && !said.contains(v)) {
|
||||
said.push(v);
|
||||
}
|
||||
}
|
||||
for v in said {
|
||||
out.push(' ');
|
||||
out.push_str(v);
|
||||
}
|
||||
out.push(' ');
|
||||
tag = None;
|
||||
}
|
||||
(Some(t), c) => t.push(c),
|
||||
(None, c) => out.push(c),
|
||||
}
|
||||
}
|
||||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A quoted attribute's value in a tag's text, `alt="..."` or `alt='...'`.
|
||||
fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
for q in ['"', '\''] {
|
||||
let key = format!(" {name}={q}");
|
||||
if let Some(at) = lower.find(&key) {
|
||||
let rest = &tag[at + key.len()..];
|
||||
return rest.find(q).map(|end| &rest[..end]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_noul_of_a_half_or_more_is_a_fit() {
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "model": "jev-1.13.0", "answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.5 }, "signal_4": { "type": "noul", "noul": 0.49 } } });
|
||||
assert_eq!(hit(&answer, 3), Some(true));
|
||||
assert_eq!(hit(&answer, 4), Some(false));
|
||||
assert_eq!(hit(&answer, 5), None, "a question not answered is asked again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_is_one_jev_was_offered() {
|
||||
let offered = vec![("solid/robot".to_string(), "Robot: ai, bot".to_string()), ("solid/laptop-code".to_string(), "Laptop Code".to_string())];
|
||||
let r = icon_request("AI Development", &["ai".into()], Some("AI for writing software."), &offered);
|
||||
assert_eq!(r["questions"]["icon"]["type"], "choice");
|
||||
assert_eq!(r["questions"]["icon"]["criteria"]["solid/robot"], "Robot: ai, bot");
|
||||
assert!(r["state"].as_str().unwrap().contains("In their words: AI for writing software."));
|
||||
// As TypeSafe answered on 2026-10-16.
|
||||
let answer = serde_json::json!({ "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34 } } });
|
||||
assert_eq!(chosen(&answer, &offered).as_deref(), Some("solid/laptop-code"));
|
||||
let stray = serde_json::json!({ "answers": { "icon": { "choice": "solid/skull" } } });
|
||||
assert_eq!(chosen(&stray, &offered), None, "never one it was not offered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_images_words_are_read_with_the_text() {
|
||||
let xkcd = r#"<img src="https://imgs.xkcd.com/comics/ground_effect.png" title="Runners wear sneakers." alt="Runners wear sneakers." referrerpolicy="no-referrer">"#;
|
||||
assert_eq!(text_of(xkcd), "Runners wear sneakers.", "alt and title once, when they say the same");
|
||||
assert_eq!(text_of("<p>Qualifying <b>report</b></p><img alt='A grid'>"), "Qualifying report A grid");
|
||||
assert_eq!(text_of(r#"<img src="x.png" alt="">"#), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_batch_is_full_by_its_rows_not_its_items() {
|
||||
let item = |n: usize| Unjudged {
|
||||
user_id: 1, key: "k".into(), feed_id: "f".into(), guid: "g".into(), feed_title: None, title: None, description: None,
|
||||
signals: (0..n as i64).map(|id| Ask { id, name: "s".into(), keywords: vec![], description: None }).collect(),
|
||||
};
|
||||
assert!(full(&(0..25).map(|_| item(2)).collect::<Vec<_>>()), "25 items of two Signals each is the 50 asked for");
|
||||
assert!(!full(&(0..24).map(|_| item(2)).collect::<Vec<_>>()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_signal_is_its_own_question_on_the_items_words() {
|
||||
let item = Unjudged {
|
||||
user_id: 1,
|
||||
key: "k".into(),
|
||||
feed_id: "f".into(),
|
||||
guid: "g".into(),
|
||||
feed_title: Some("Pit Lane".into()),
|
||||
title: Some("Monaco".into()),
|
||||
description: Some("<p>Qualifying <b>report</b></p>".into()),
|
||||
signals: vec![
|
||||
Ask { id: 3, name: "F1".into(), keywords: vec!["formula 1".into(), "f1".into()], description: None },
|
||||
Ask { id: 4, name: "Food".into(), keywords: vec!["cooking".into()], description: Some("Recipes, not restaurants.".into()) },
|
||||
],
|
||||
};
|
||||
let r = request(&item);
|
||||
assert_eq!(r["state"], "Feed: Pit Lane\nTitle: Monaco\n\nQualifying report");
|
||||
let q = &r["questions"]["signal_3"];
|
||||
assert_eq!(q["type"], "noul");
|
||||
let says = q["instructions"].as_str().unwrap();
|
||||
assert!(says.contains("\"F1\"") && says.contains("formula 1, f1"), "one subject, by its name and its words: {says}");
|
||||
assert!(!says.contains("own words"), "no sentence, none said");
|
||||
assert!(q["criteria"]["true"].is_string() && q["criteria"]["false"].is_string(), "TypeSafe takes an object, not a sentence");
|
||||
assert!(r["questions"]["signal_4"]["instructions"].as_str().unwrap().contains("In their own words: Recipes, not restaurants."));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,10 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
|
||||
.route("/api/feeds/{id}/entries", get(entries))
|
||||
.route("/api/entries", get(all_entries))
|
||||
.route("/api/signals", get(list_signals).post(add_signal))
|
||||
.route("/api/signals/icon", post(pick_signal_icon))
|
||||
.route("/api/signals/{id}", patch(patch_signal).delete(remove_signal))
|
||||
.route("/api/signals/{id}/entries", get(signal_entries))
|
||||
.route("/api/entries/{feed_id}/{guid}", get(one_entry))
|
||||
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
|
||||
.route("/api/entries/{feed_id}/{guid}/position", post(set_position))
|
||||
@@ -382,6 +386,7 @@ async fn me(
|
||||
Json(serde_json::json!({
|
||||
"name": user.name, "admin": user.is_admin, "sign_out": sign_out, "blocked": blocked,
|
||||
"deadair_saved": saved, "theme": theme, "version": env!("CARGO_PKG_VERSION"),
|
||||
"typesafe_key_set": state.ctx.db.typesafe_key_set(user.id).await.unwrap_or(false),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -389,6 +394,9 @@ async fn me(
|
||||
struct MePatch {
|
||||
/// Words that hide an item in every feed you read.
|
||||
blocked: Option<Vec<String>>,
|
||||
/// The TypeSafe key Signals are judged on: a key saves it, null or empty forgets it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
typesafe_key: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// Saves the block list for every feed. The theme is not the account's: each browser keeps its
|
||||
@@ -401,9 +409,189 @@ async fn patch_me(
|
||||
if let Some(words) = body.blocked {
|
||||
state.ctx.db.set_blocklist(user.id, "", &clean_words(words)?).await?;
|
||||
}
|
||||
if let Some(key) = body.typesafe_key {
|
||||
let key = key.map(|k| k.trim().to_owned()).filter(|k| !k.is_empty());
|
||||
if key.as_ref().is_some_and(|k| k.len() > 500 || !k.bytes().all(|b| b.is_ascii_graphic())) {
|
||||
return Err(ApiError::bad_request("that does not look like a TypeSafe key"));
|
||||
}
|
||||
state.ctx.db.set_typesafe_key(user.id, key.as_deref()).await?;
|
||||
state.ctx.signals_refused.lock().unwrap().remove(&user.id);
|
||||
state.ctx.signals.notify_one();
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ---- Signals (#254): each person's own, so each route finds the Signal by its owner ----
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignalBody {
|
||||
name: Option<String>,
|
||||
keywords: Option<Vec<String>>,
|
||||
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
description: Option<Option<String>>,
|
||||
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
|
||||
#[serde(default, deserialize_with = "double_option")]
|
||||
icon: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
|
||||
/// and a plain name gets through.
|
||||
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
|
||||
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
|
||||
let ok = i.split_once('/').is_some_and(|(style, name)| {
|
||||
["solid", "regular", "brands"].contains(&style)
|
||||
&& (1..=64).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||
});
|
||||
if !ok {
|
||||
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
|
||||
}
|
||||
Ok(Some(i))
|
||||
}
|
||||
|
||||
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
|
||||
let d = d.map(|d| d.trim().to_owned()).filter(|d| !d.is_empty());
|
||||
if d.as_ref().is_some_and(|d| d.chars().count() > 500) {
|
||||
return Err(ApiError::bad_request("a Signal's description is up to 500 characters"));
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn signal_name(name: &str) -> Result<String, ApiError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > 100 {
|
||||
return Err(ApiError::bad_request("a Signal needs a name of up to 100 characters"));
|
||||
}
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn signal_words(words: Vec<String>) -> Result<Vec<String>, ApiError> {
|
||||
let words = clean_words(words)?;
|
||||
// Each is in the question Jev is asked about every item.
|
||||
if words.is_empty() || words.len() > 50 {
|
||||
return Err(ApiError::bad_request("a Signal needs between 1 and 50 keywords"));
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
async fn list_signals(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
) -> Result<Json<Vec<crate::db::SignalRow>>, ApiError> {
|
||||
Ok(Json(state.ctx.db.signals(user.id).await?))
|
||||
}
|
||||
|
||||
async fn add_signal(
|
||||
State(state): State<WebState>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<SignalBody>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
|
||||
let words = signal_words(body.keywords.unwrap_or_default())?;
|
||||
let description = signal_description(body.description.flatten())?;
|
||||
let icon = signal_icon(body.icon.flatten())?;
|
||||
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
|
||||
if icon.is_some() {
|
||||
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
|
||||
state.ctx.db.set_signal(user.id, id, change).await?;
|
||||
}
|
||||
state.ctx.signals.notify_one();
|
||||
Ok(Json(serde_json::json!({ "id": id })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IconAsk {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
description: Option<String>,
|
||||
/// `[style/name, label]` for each icon the page found for the Signal's words.
|
||||
candidates: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Jev's pick of an icon for a Signal, on the server's TypeSafe key (#263). Answers `{ "icon": ... }`,
|
||||
/// or 404 when the server has no key.
|
||||
async fn pick_signal_icon(
|
||||
State(state): State<WebState>,
|
||||
_user: crate::db::User,
|
||||
Json(body): Json<IconAsk>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let Ok(key) = std::env::var("TYPESAFE_KEY") else {
|
||||
return Err(ApiError::not_found("picking an icon is not set up on this server"));
|
||||
};
|
||||
let name = signal_name(&body.name)?;
|
||||
let words = clean_words(body.keywords)?;
|
||||
let description = signal_description(body.description)?;
|
||||
// Each one is in the question, and comes back as the Signal's icon: only real icon names.
|
||||
let mut candidates: Vec<(String, String)> = Vec::new();
|
||||
for (id, label) in body.candidates {
|
||||
let id = signal_icon(Some(id))?.ok_or_else(|| ApiError::bad_request("an empty icon"))?;
|
||||
if !candidates.iter().any(|(c, _)| *c == id) {
|
||||
candidates.push((id, label.chars().take(200).collect()));
|
||||
}
|
||||
}
|
||||
if candidates.is_empty() || candidates.len() > 60 {
|
||||
return Err(ApiError::bad_request("between 1 and 60 icons to choose from"));
|
||||
}
|
||||
let icon = crate::signals::pick_icon(&state.ctx, &key, &name, &words, description.as_deref(), &candidates)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %format!("{e:#}"), "Jev could not pick an icon");
|
||||
ApiError::bad_gateway("no icon could be picked just now")
|
||||
})?;
|
||||
Ok(Json(serde_json::json!({ "icon": icon })))
|
||||
}
|
||||
|
||||
async fn patch_signal(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
Json(body): Json<SignalBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let name = body.name.as_deref().map(signal_name).transpose()?;
|
||||
let words = body.keywords.map(signal_words).transpose()?;
|
||||
let description = body.description.map(signal_description).transpose()?;
|
||||
let icon = body.icon.map(signal_icon).transpose()?;
|
||||
let asks_again = name.is_some() || words.is_some() || description.is_some();
|
||||
let change = crate::db::SignalChange {
|
||||
name: name.as_deref(),
|
||||
words: words.as_deref(),
|
||||
description: description.as_ref().map(|d| d.as_deref()),
|
||||
icon: icon.as_ref().map(|i| i.as_deref()),
|
||||
};
|
||||
if !state.ctx.db.set_signal(user.id, id, change).await? {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
if asks_again {
|
||||
state.ctx.signals.notify_one();
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_signal(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if !state.ctx.db.remove_signal(user.id, id).await? {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn signal_entries(
|
||||
State(state): State<WebState>,
|
||||
Path(id): Path<i64>,
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
if state.ctx.db.signal(user.id, id).await?.is_none() {
|
||||
return Err(ApiError::not_found("no such Signal"));
|
||||
}
|
||||
entry_page(&state, user.id, None, Some(id), &page).await
|
||||
}
|
||||
|
||||
fn theme_ok(theme: &str, mode: &str) -> bool {
|
||||
(1..=20).contains(&theme.len())
|
||||
&& theme.bytes().all(|b| b.is_ascii_lowercase())
|
||||
@@ -770,11 +958,11 @@ async fn feed_rows(state: &WebState, user: &crate::db::User, only: Option<&str>)
|
||||
.schedule
|
||||
.as_deref()
|
||||
.and_then(crate::config::parse_interval),
|
||||
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, 0) / 60,
|
||||
every_mins: crate::due_after(&cfg, feed, ttl_mins, None, None, 0) / 60,
|
||||
last_checked: s.last_checked,
|
||||
next_check: s
|
||||
.last_checked
|
||||
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, t) as i64),
|
||||
.map(|t| t + crate::due_after(&cfg, feed, ttl_mins, s.error_since, s.same_since, t) as i64),
|
||||
failing: s
|
||||
.error_since
|
||||
.filter(|since| crate::db::now() - since >= FLAG_AFTER_SECS)
|
||||
@@ -831,14 +1019,17 @@ fn dir_error(e: crate::directory::DirError) -> ApiError {
|
||||
/// The directory's listings as this person sees them: which are theirs already, matched by
|
||||
/// address, since the directory and this server name a feed differently.
|
||||
async fn rows_for(state: &WebState, user_id: i64, listed: Vec<ipx_directory::Listing>) -> Result<Vec<PopularRow>> {
|
||||
// A step each, as the feed list has: a Directory page of 983 ms showed 19 under spans (#250).
|
||||
let mine: std::collections::HashSet<String> =
|
||||
state.ctx.db.subscriptions_for(user_id).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
state.ctx.db.subscriptions_for(user_id).instrument(step("subscriptions")).await?.into_iter().map(|s| s.feed_id).collect();
|
||||
let here: std::collections::HashMap<String, String> = crate::subscriptions(&state.ctx)
|
||||
.instrument(step("catalogue"))
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|s| mine.contains(&s.id))
|
||||
.filter_map(|s| ipx_directory::store::normalise(&s.cfg.url).map(|u| (u, s.id)))
|
||||
.collect();
|
||||
let _matching = step("match").entered();
|
||||
Ok(listed
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
@@ -1056,6 +1247,14 @@ impl ApiError {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
}
|
||||
}
|
||||
|
||||
/// A service ipx asks on someone's behalf failed them.
|
||||
fn bad_gateway(msg: impl Into<String>) -> Self {
|
||||
Self {
|
||||
error: anyhow::Error::msg(msg.into()),
|
||||
status: StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
@@ -1094,6 +1293,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signal_icon_is_a_style_and_a_plain_name() {
|
||||
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
|
||||
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
|
||||
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
|
||||
// It becomes part of an address and a style attribute on the page.
|
||||
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
|
||||
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_plain_theme_name_is_handed_on() {
|
||||
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
|
||||
@@ -1109,8 +1319,9 @@ mod tests {
|
||||
let u = crate::db::User { id: 1, name: "dee".into(), pass_hash: None, is_admin: false, created: None, last_login: None };
|
||||
let e = |ev: &str| ClientEvent {
|
||||
ev: ev.into(), msg: Some("x".repeat(10_000)), src: None, line: None, feed: None, enclosure: None, deadair: None, ms: Some(5),
|
||||
filter: Some("unread".into()),
|
||||
};
|
||||
for ev in ["page_error", "rejection", "player_error", "play_start", "stall"] {
|
||||
for ev in ["page_error", "rejection", "player_error", "play_start", "stall", "list_shown"] {
|
||||
assert_eq!(client_event(u.clone(), Json(e(ev))).await, StatusCode::NO_CONTENT, "{ev}");
|
||||
}
|
||||
assert_eq!(client_event(u, Json(e("anything"))).await, StatusCode::BAD_REQUEST);
|
||||
@@ -1227,7 +1438,7 @@ async fn entries(
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
subscribed(&state, &user, &id).await?;
|
||||
entry_page(&state, user.id, Some(&id), &page).await
|
||||
entry_page(&state, user.id, Some(&id), None, &page).await
|
||||
}
|
||||
|
||||
/// A feed's items and files are for the people who subscribe to it (#129). Anyone signed in
|
||||
@@ -1248,24 +1459,26 @@ async fn all_entries(
|
||||
user: crate::db::User,
|
||||
Query(page): Query<Page>,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
entry_page(&state, user.id, None, &page).await
|
||||
entry_page(&state, user.id, None, None, &page).await
|
||||
}
|
||||
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None.
|
||||
/// One feed's page of items, or every subscribed feed's when `feed` is None, or a Signal's.
|
||||
async fn entry_page(
|
||||
state: &WebState,
|
||||
user_id: i64,
|
||||
feed: Option<&str>,
|
||||
signal: Option<i64>,
|
||||
page: &Page,
|
||||
) -> Result<Json<EntryPage>, ApiError> {
|
||||
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
|
||||
let search = page.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
|
||||
let db = &state.ctx.db;
|
||||
let order = crate::db::order_sql(page.sort.as_deref().unwrap_or("published"), page.dir.as_deref().unwrap_or("desc"));
|
||||
let mut rows = db
|
||||
.entries_in(user_id, feed, filter, search, page.offset, page.limit.clamp(1, 200), &order)
|
||||
.instrument(step("entries_in"))
|
||||
.await?;
|
||||
let limit = page.limit.clamp(1, 200);
|
||||
let mut rows = match signal {
|
||||
Some(id) => db.signal_entries(user_id, id, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
|
||||
None => db.entries_in(user_id, feed, filter, search, page.offset, limit, &order).instrument(step("entries_in")).await?,
|
||||
};
|
||||
step("sanitize").in_scope(|| {
|
||||
let mut sanitizer = feed_sanitizer();
|
||||
for row in &mut rows {
|
||||
@@ -1275,7 +1488,10 @@ async fn entry_page(
|
||||
}
|
||||
});
|
||||
crate::deadair::for_reader(&state.ctx, user_id, &mut rows).instrument(step("deadair")).await?;
|
||||
let total = db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?;
|
||||
let total = match signal {
|
||||
Some(id) => db.signal_count(user_id, id, filter, search).instrument(step("count_in")).await?,
|
||||
None => db.count_in(user_id, feed, filter, search).instrument(step("count_in")).await?,
|
||||
};
|
||||
Ok(Json(EntryPage { total, entries: rows }))
|
||||
}
|
||||
|
||||
@@ -2252,12 +2468,14 @@ struct ClientEvent {
|
||||
enclosure: Option<i64>,
|
||||
deadair: Option<bool>,
|
||||
ms: Option<u64>,
|
||||
/// The list's filter, for `list_shown`: all, unread, flagged.
|
||||
filter: Option<String>,
|
||||
}
|
||||
|
||||
async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> StatusCode {
|
||||
// From the browser: only the kinds the page sends, and nothing long enough to fill the log.
|
||||
let cut = |s: &Option<String>| s.as_deref().map(|s| s.chars().take(500).collect::<String>());
|
||||
let (msg, src, feed) = (cut(&e.msg), cut(&e.src), cut(&e.feed));
|
||||
let (msg, src, feed, filter) = (cut(&e.msg), cut(&e.src), cut(&e.feed), cut(&e.filter));
|
||||
let (ev, line, enclosure, deadair, ms, user) = (e.ev.as_str(), e.line, e.enclosure, e.deadair, e.ms, user.name.as_str());
|
||||
match ev {
|
||||
"page_error" | "rejection" | "player_error" => tracing::warn!(
|
||||
@@ -2268,6 +2486,11 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
|
||||
target: "ipx::client", ev, ms, feed, enclosure, deadair, user,
|
||||
"{user}'s page: {ev} {} ms", ms.unwrap_or(0)
|
||||
),
|
||||
// From a click to the list drawn (#246).
|
||||
"list_shown" => tracing::info!(
|
||||
target: "ipx::client", ev, ms, feed, filter, user,
|
||||
"{user}'s page: list_shown {} ms", ms.unwrap_or(0)
|
||||
),
|
||||
_ => return StatusCode::BAD_REQUEST,
|
||||
}
|
||||
StatusCode::NO_CONTENT
|
||||
|
||||
@@ -41,6 +41,7 @@ id, and Caddy's request lines theirs. ipx also serves Prometheus metrics (`[web]
|
||||
| `crates/ipx/src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||
| `crates/ipx/src/directory.rs` | Asking the directory service, and sharing this server's public feeds with it | — |
|
||||
| `crates/ipx/src/metrics.rs` | `/metrics` for Prometheus | — |
|
||||
| `crates/ipx/src/signals.rs` | Signals: asks Jev, on each person's TypeSafe key, which of their items fit their Signals | — |
|
||||
| `crates/directory/src/lib.rs` | The directory's HTTP API, its token check, the refresher | — |
|
||||
| `crates/directory/src/store.rs` | The directory's database: feeds, snapshots, blocked | — |
|
||||
| `crates/directory/src/config.rs` | The directory's config file: its tokens and their access | — |
|
||||
@@ -60,7 +61,10 @@ and the files, and no page.
|
||||
## A scan
|
||||
|
||||
1. Skip the feed unless `last_checked + max(schedule, ttl)` has passed (`--force` ignores this).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there.
|
||||
Without a schedule of its own, a feed whose body has been the same for a while waits longer:
|
||||
a twelfth of how long it has been the same, up to four hours (#242).
|
||||
2. Conditional GET with the stored `ETag` / `Last-Modified`. `304` ends it there, and so does a
|
||||
body identical to the last one (`body_hash`), from a server that ignores the validators.
|
||||
3. Sniff the body: RSS, then Atom, then OPML. An OPML is a live subscription — its feeds are
|
||||
re-derived into the database each scan, never written to config.toml. A Patreon creator link
|
||||
(a token, no `show=`) with more than one show is treated the same way, before any fetch: its
|
||||
@@ -81,7 +85,8 @@ and the files, and no page.
|
||||
|
||||
```
|
||||
feeds id, url, title, image, category, description, etag, last_modified,
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed
|
||||
last_checked, ttl_mins, last_error, orphaned, group_id, managed,
|
||||
body_hash, same_since
|
||||
entries feed_id, guid, title, link, published, description, first_seen,
|
||||
image, duration, episode, season PK (feed_id, guid)
|
||||
enclosures id, feed_id, guid, url UNIQUE, mime, length, path, state,
|
||||
@@ -92,8 +97,17 @@ subscriptions user_id, feed_id, keywords, auto_download, allow_explicit,
|
||||
max_new_per_check PK (user_id, feed_id)
|
||||
entry_state user_id, feed_id, guid, read, flagged, position
|
||||
PK (user_id, feed_id, guid)
|
||||
signals id, user_id, name, keywords
|
||||
signal_verdicts signal_id, feed_id, guid, hit PK (signal_id, feed_id, guid)
|
||||
```
|
||||
|
||||
A Signal (#254, [signals.md](signals.md)) is a feed someone makes from their own subscriptions. `signals::run`, a task of its
|
||||
own beside DeadAir's, woken by a scan with something new or a Signal made or changed, asks Jev
|
||||
about each item of the last 30 days that has no verdict yet for one of its owner's Signals: one
|
||||
call an item, a noul question a Signal, on the owner's `users.typesafe_key`. Every answer is kept,
|
||||
fitting or not, so nothing is asked twice; new keywords drop the Signal's verdicts. A Signal's
|
||||
items are `entries_where` with an `EXISTS` on its hits, so read state is the item's own.
|
||||
|
||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
||||
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@ art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||
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
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. A feed that keeps failing
|
||||
be polled *less* often, and a per-feed `schedule` overrides both. Without a schedule of its own,
|
||||
a feed that sends the same body check after check (Patreon, Substack, which ignore "not
|
||||
modified") is checked less often the longer it stays the same, up to every four hours, and is
|
||||
back on schedule once it changes. A feed that keeps failing
|
||||
waits as long as it has been failing before the next try, up to a day, and is back on schedule
|
||||
after its first success. A moment's failure to look its host up does not count: the network, not
|
||||
the feed, failed, and it is tried again in ten minutes. Admin-only from the UI.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`ipx-directory` is a catalogue of public podcasts and blogs that ipx servers share (#204). An ipx
|
||||
server sends it the public feeds its people subscribe to, and how many of them do. The directory
|
||||
fetches each feed's title, artwork, category, description and latest twenty items every six hours,
|
||||
fetches each feed's title, artwork, category, description and latest twenty items every twelve hours,
|
||||
and any server holding a token can browse it. It never downloads a file, and it never stores
|
||||
one's address: every ipx server still fetches its own feeds.
|
||||
|
||||
@@ -81,6 +81,8 @@ bind = "0.0.0.0:8100"
|
||||
# Prometheus metrics, on a port of their own and not the one the tunnel publishes. Empty, or
|
||||
# left out, serves none.
|
||||
metrics_bind = "0.0.0.0:9188"
|
||||
# Apple Podcasts' top charts for this country, weekly (see below). Empty, or left out: none.
|
||||
apple_country = "ca"
|
||||
# The environment variable holding the database URL: postgres://... or sqlite://...
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"
|
||||
|
||||
@@ -100,6 +102,23 @@ production does with `deployment.environment.name=production`. Its version is in
|
||||
`crates/directory/Cargo.toml`, logged at start and answered by `ipx-directory --version`; its
|
||||
changelog is `crates/directory/CHANGELOG.md`.
|
||||
|
||||
## Apple Podcasts
|
||||
|
||||
With `apple_country` set, the directory takes Apple Podcasts' top charts for that country once a
|
||||
week (#252): the top 100 overall and the top 50 of each of Apple's categories, about a thousand
|
||||
shows. Apple's lookup API gives each one's RSS address, and they go in as a snapshot of their own,
|
||||
`apple-podcasts`, with no subscribers: fetched, sorted into categories and listed like any other
|
||||
feed, while the most subscribed stay what the servers sharing here subscribe to. A show Apple drops
|
||||
from its charts leaves the directory at the next sync, unless a server shares it. An admin can drop
|
||||
the snapshot as any server's (`DELETE /v1/instances/apple-podcasts`); the next sync brings it back.
|
||||
No token may be named `apple-podcasts`.
|
||||
|
||||
The same sync compares Apple's list of categories with the one ipx keeps (`CATEGORIES` in
|
||||
`crates/feed`) and logs a warning naming what differs, for whoever checks the log to update it.
|
||||
It runs at start when the last sync is a week old, and a failed one tries again an hour later. Each
|
||||
sync is a trace (`apple_sync`, a span for each of Apple's answers), and a line `Apple Podcasts
|
||||
synced` says how many shows came and went in.
|
||||
|
||||
## Running one
|
||||
|
||||
```yaml
|
||||
|
||||
368
docs/signals.md
Normal file
368
docs/signals.md
Normal file
@@ -0,0 +1,368 @@
|
||||
# Signals
|
||||
|
||||
A Signal is a feed someone makes from their own subscriptions (#254). They give it a name, the
|
||||
words to listen for (`formula 1`, `f1`) and, when those are not enough, a sentence saying what they
|
||||
mean (#259). TypeSafe's Jev reads each item of the feeds they subscribe to and picks out the ones
|
||||
about that subject: the name, the words and the sentence together, not any one word. Those items show in the Signal as well as in their
|
||||
own feed. The rest stays noise, where it was.
|
||||
|
||||
Each person brings their own TypeSafe key, entered in Settings. Nothing is judged for someone
|
||||
without one. Jev is asked once for each item, on that person's key, with one question for each of
|
||||
their Signals that has not heard about the item yet.
|
||||
|
||||
## What someone sees
|
||||
|
||||
- **The sidebar.** A Signals folder comes first in the feed list, under All Subscriptions (#255,
|
||||
#256). It is closed or open as an OPML's folder is, remembered in each browser. Its row is like
|
||||
any folder's (#265); New signal is the + on the folder's page. Inside the folder are the
|
||||
person's Signals, each with its items and unread counted under its name.
|
||||
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||
**Auto** asks Jev to choose (#263), and a Signal made without an icon gets Jev's pick by
|
||||
itself, a moment after it is made. Every Signal's tile is in the accent tint the folder has.
|
||||
- **Each Signal's row** says "12 items · 3 unread", as a feed's row counts its own.
|
||||
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with the + for a new one.
|
||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
||||
out without a key. Edit changes the name, the words or the sentence; Delete removes the Signal
|
||||
and leaves the items in their feeds.
|
||||
- **Read state is the item's.** Reading an item in a Signal reads it in its feed, and the other way
|
||||
round. The counts follow without a reload (#258).
|
||||
- **Settings, TypeSafe key.** A password field. A key saves on leaving the field, and the bin
|
||||
forgets it. The key is never sent back to the page, which only learns whether one is set.
|
||||
|
||||
## How an item gets into a Signal
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant W as Scan worker
|
||||
participant J as Signals judge<br/>(signals::run)
|
||||
participant DB as Database
|
||||
participant T as TypeSafe<br/>api.typesafe.ai
|
||||
participant P as The page
|
||||
|
||||
W->>DB: new entries stored
|
||||
W->>J: ctx.signals.notify_one()
|
||||
Note over J: also woken by a new Signal,<br/>new keywords, a key saved,<br/>or every 5 minutes
|
||||
J->>DB: unjudged(skip refused keys, 50)
|
||||
DB-->>J: items, each with the Signals<br/>that have not heard of it
|
||||
loop each item
|
||||
J->>T: POST /v1/systemone<br/>one noul question per Signal
|
||||
T-->>J: answers: noul 0..1 per question
|
||||
J->>DB: record_verdicts (hit = noul >= 0.5)
|
||||
end
|
||||
J-->>P: signal_hits event (SSE), once a pass per person
|
||||
P->>DB: GET /api/signals (counts),<br/>and the open Signal's list
|
||||
```
|
||||
|
||||
The judge is a task of its own beside DeadAir's (`crates/ipx/src/signals.rs`), not a job on the
|
||||
scan worker, so a slow TypeSafe never holds up a scan.
|
||||
|
||||
### Which items are asked about
|
||||
|
||||
`Db::unjudged` finds, newest first, up to 50 items that are:
|
||||
|
||||
- in a feed the Signal's owner subscribes to,
|
||||
- published (or, with no date, first seen) in the last 30 days (`SIGNAL_DAYS`),
|
||||
- not hidden from them by their block lists,
|
||||
- not yet in `signal_verdicts` for that Signal,
|
||||
- for someone with a key that TypeSafe has not refused since the daemon started.
|
||||
|
||||
So a new Signal looks back a month, and after that sees each new item. A show subscribed to today
|
||||
has its back catalogue skipped, apart from its last month.
|
||||
|
||||
Rows for one item and one person are grouped, so an item is one call whatever the number of
|
||||
Signals. A full batch means there may be more, and the next pass starts at once. Otherwise the
|
||||
judge sleeps until woken or for 5 minutes.
|
||||
|
||||
### Every answer is kept
|
||||
|
||||
`signal_verdicts` records each answer, fitting or not. An item with no row has not been asked yet;
|
||||
an item with a row is never asked again. So the cost is one call per item per person, once.
|
||||
Changing a Signal's name, words or sentence deletes its verdicts, and everything in the window is
|
||||
asked again. The first start with sentences (#259) deleted every verdict, since they answered the
|
||||
old question.
|
||||
|
||||
## TypeSafe
|
||||
|
||||
Jev is TypeSafe's System One model, already used to pick a feed's Directory category
|
||||
(`feed::classify`). The address is `feed::JEV_URL`, `https://api.typesafe.ai/v1/systemone`.
|
||||
Signals use the **noul** question type, which answers how likely a statement is true, from 0 to 1.
|
||||
Questions in one call are judged independently and in parallel.
|
||||
|
||||
### A request
|
||||
|
||||
Sent with `Authorization: Bearer <the person's key>`, for an item and two of their Signals:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Feed: Pit Lane\nTitle: Monaco qualifying\n\nQualifying report from the Monaco Grand Prix.",
|
||||
"questions": {
|
||||
"signal_3": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"F1\", described by these words: formula 1, f1. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
},
|
||||
"signal_4": {
|
||||
"type": "noul",
|
||||
"instructions": "The reader follows a subject they call \"Food\", described by these words: baking. In their own words: Bread and cakes, not restaurants. Is this item about that subject?",
|
||||
"criteria": {
|
||||
"true": "The item's main subject is that subject, as the name, the words and the reader's description together describe it.",
|
||||
"false": "It is about something else, or touches the subject only in passing."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `state` is text: the feed's title, the item's title, and its show notes with the HTML taken out,
|
||||
cut to 2,000 characters (`TEXT_CHARS`).
|
||||
- Each question is keyed `signal_<id>`, and its answer comes back under the same key.
|
||||
- `criteria` is an object saying what true and false mean. A sentence there is refused with a 422
|
||||
(#257).
|
||||
- **One subject, not any word.** The first question was "Is this item about any of these: ai,
|
||||
development, programming?", so one broad word was enough: AI Development took Sega pulling a game
|
||||
remaster (0.90), a Surface Laptop review (0.95) and a C++ tutorial (0.99), 54 items in all. Asked
|
||||
about the subject, with the sentence "AI tools for writing software: coding agents, AI dev tools,
|
||||
vibe coding, AI in code review.", those scored 0.06, 0.16 and 0.02, and 5 of the 54 stayed in,
|
||||
AI code review in the Linux kernel highest at 0.94 (#259).
|
||||
|
||||
### Its answer
|
||||
|
||||
What TypeSafe answered to a request of this shape, on 2026-10-16:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-1.13.0",
|
||||
"answers": {
|
||||
"signal_3": { "type": "noul", "noul": 0.96 },
|
||||
"signal_4": { "type": "noul", "noul": 0.02 }
|
||||
},
|
||||
"usage": { "input_tokens": 389, "output_tokens": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
0.5 or more is a fit (`THRESHOLD`): the item goes into Signal 3 and not Signal 4. A question
|
||||
missing from the answer is left unjudged and asked again next pass.
|
||||
|
||||
### When TypeSafe says no
|
||||
|
||||
| TypeSafe answers | What the judge does |
|
||||
|---|---|
|
||||
| 401, 402, 403 | The key is refused. That person is passed over until they save a key or the daemon restarts, so a bad key is not sent the whole queue. Logged once: "TypeSafe refused a Signal key". |
|
||||
| 400, 422 | The request itself is wrong, so every item's would be. The pass ends; the items wait for the next one. Logged: "TypeSafe refused a Signals request as malformed", with TypeSafe's reason. |
|
||||
| anything else, or no answer | That item is left unjudged and asked again next pass. Logged: "Jev could not judge an item". |
|
||||
|
||||
The error keeps TypeSafe's reply, cut to 500 characters, for example:
|
||||
|
||||
```
|
||||
TypeSafe answered 422: {"detail":[{"type":"model_attributes_type","loc":["body","questions","3","noul","criteria"],"msg":"Input should be a valid dictionary or object to extract fields from","input":"Yes if the subject is one of them."}]}
|
||||
```
|
||||
|
||||
## The API
|
||||
|
||||
Every route is the signed-in person's own. A Signal of someone else's is not found, as one that
|
||||
does not exist.
|
||||
|
||||
### `GET /api/signals`
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "AI Development", "keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"icon": "solid/laptop-code", "items": 48, "unread": 5 },
|
||||
{ "id": 2, "name": "Apple", "keywords": ["apple", "mac", "iphone"], "description": null, "icon": null, "items": 272, "unread": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
`items` counts the items Jev picked out that are still in a subscribed feed and not hidden, what
|
||||
its list shows; `unread` those of them not read.
|
||||
|
||||
### `POST /api/signals`
|
||||
|
||||
```json
|
||||
{ "name": "Formula 1", "keywords": ["formula 1", "f1"], "description": "Grand prix racing, not road cars." }
|
||||
```
|
||||
|
||||
Answers `{ "id": 3 }` and wakes the judge. A name is 1 to 100 characters. Between 1 and 50 words
|
||||
are allowed, each up to 200 characters, kept once whatever their case. `description` is optional,
|
||||
up to 500 characters. Anything else is a `400` saying so.
|
||||
|
||||
### `PATCH /api/signals/{id}`
|
||||
|
||||
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
||||
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
|
||||
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
|
||||
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
|
||||
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
|
||||
|
||||
### `DELETE /api/signals/{id}`
|
||||
|
||||
Answers `204`. The Signal and its verdicts go; the items stay in their feeds.
|
||||
|
||||
### `GET /api/signals/{id}/entries`
|
||||
|
||||
The same query as a feed's list, `offset`, `limit`, `filter`, `q`, `sort`, `dir`:
|
||||
|
||||
```
|
||||
GET /api/signals/3/entries?filter=unread&sort=published&dir=desc&limit=50
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 1,
|
||||
"entries": [
|
||||
{
|
||||
"guid": "sig-1", "feed_id": "pit-lane", "title": "Monaco qualifying",
|
||||
"link": "https://pitlane.example/monaco", "published": 1792170000,
|
||||
"description": "<p>Qualifying report…</p>", "read": false, "flagged": false,
|
||||
"image": null, "duration": null, "episode": null, "season": null, "position": 0,
|
||||
"enclosures": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Reading or pinning one is the item's own route, `POST /api/entries/{feed_id}/{guid}/flags`, as
|
||||
anywhere else.
|
||||
|
||||
### `PATCH /api/me`, the key
|
||||
|
||||
```json
|
||||
{ "typesafe_key": "ts_…" }
|
||||
```
|
||||
|
||||
A string saves the key and lets the judge try it again. `null` or an empty string forgets it. A
|
||||
key is up to 500 printable characters. `GET /api/me` says `"typesafe_key_set": true` and nothing
|
||||
more.
|
||||
|
||||
### `POST /api/signals/icon`
|
||||
|
||||
Jev's pick of an icon, on the **server's** `TYPESAFE_KEY`, not the person's: it is the page's own
|
||||
doing, once a Signal. Jev chooses among options and cannot name one, so the page searches Font
|
||||
Awesome's index for each word of the name, words and sentence, the best six each, and sends up to 40:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "AI Development",
|
||||
"keywords": ["ai", "development", "programming"],
|
||||
"description": "AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"candidates": [["solid/robot", "Robot: ai, android, bot, machine"], ["solid/laptop-code", "Laptop Code: computer, cpu, dell, demo"], ["solid/code", "Code: brackets, code, development, html"]]
|
||||
}
|
||||
```
|
||||
|
||||
ipx asks a **choice** question, each candidate a choice and its label and terms what it means:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": "Signal: AI Development\nListens for: ai, development, programming\nIn their words: AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.",
|
||||
"questions": { "icon": {
|
||||
"type": "choice",
|
||||
"instructions": "Which icon best stands for this subject in a list of the reader's topics?",
|
||||
"criteria": { "solid/robot": "Robot: ai, android, bot, machine", "solid/laptop-code": "Laptop Code: computer, cpu, dell, demo" }
|
||||
} }
|
||||
}
|
||||
```
|
||||
|
||||
TypeSafe answered, on 2026-10-16, for six candidates:
|
||||
|
||||
```json
|
||||
{ "model": "jev-1.13.0", "answers": { "icon": { "type": "choice", "choice": "solid/laptop-code", "confidence": 0.34,
|
||||
"probabilities": { "solid/robot": 0.4, "solid/code": 0.14, "solid/laptop-code": 0.45, "solid/brain": 0.01, "solid/microchip": 0.0, "solid/terminal": 0.0 } } } }
|
||||
```
|
||||
|
||||
The answer is `{ "icon": "solid/laptop-code" }`, only ever one it was offered. Each candidate has to
|
||||
be an icon name the API takes, 1 to 60 of them. `404` when the server has no key, `502` when Jev
|
||||
fails (logged: "Jev could not pick an icon"). The call has a `jev_icon` span.
|
||||
|
||||
### The event
|
||||
|
||||
Once a pass, for each person it found items for, the judge sends this on the event stream
|
||||
(`/api/events`):
|
||||
|
||||
```json
|
||||
{ "ev": "signal_hits", "user": 2, "hits": 3 }
|
||||
```
|
||||
|
||||
A page with Signals fetches `/api/signals` again, and the open Signal's list.
|
||||
|
||||
## Data
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ signals : owns
|
||||
signals ||--o{ signal_verdicts : "has heard of"
|
||||
entries ||--o{ signal_verdicts : "judged in"
|
||||
users {
|
||||
bigint id
|
||||
text typesafe_key "NULL: no key"
|
||||
}
|
||||
signals {
|
||||
bigint id
|
||||
bigint user_id
|
||||
text name
|
||||
text keywords "JSON array"
|
||||
text description "NULL: none"
|
||||
text icon "solid/robot, NULL: initials"
|
||||
bigint created
|
||||
}
|
||||
signal_verdicts {
|
||||
bigint signal_id "PK"
|
||||
text feed_id "PK"
|
||||
text guid "PK"
|
||||
bool hit
|
||||
}
|
||||
```
|
||||
|
||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||
- A Signal's list is `entries_where` with
|
||||
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
||||
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
||||
work as for All Subscriptions.
|
||||
- A verdict for a Signal deleted while Jev was being asked is not stored. Postgres's foreign key
|
||||
would refuse it, and SQLite would keep it for nothing.
|
||||
|
||||
## Watching it
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Traces | A `signals` span for each pass (`entries`), and a `jev` span for each call (`user`, `signal_count`, `http.response.status_code`, and TypeSafe's reply as `error` when it refuses). |
|
||||
| Log, counted | `ev="signal"`, "an item fits a Signal", for each item with a fit: `user`, `feed`, `new` (how many of their Signals it fits). |
|
||||
| Log, warnings | The three in the table above. |
|
||||
| Events | `signal_hits` on the socket and `/api/events`. |
|
||||
|
||||
## Limits
|
||||
|
||||
- **The key is kept in plain text** in `users.typesafe_key`, because every call needs it whole. It
|
||||
should be encrypted with a server key if the database is ever shared with anything else.
|
||||
- **Refused keys are remembered in memory**, so a restart tries each once again.
|
||||
- **Keywords changed while an item is being judged** can leave that one item's verdict from the old
|
||||
words.
|
||||
- **There is no meter** of how many calls a key has made. It is one call per item in the person's
|
||||
feeds in the window: a person with 1,500 feeds busy enough to publish 3,000 items a month spends
|
||||
about 3,000 calls the first month and then as many as come in.
|
||||
- **Only the last 30 days** are ever judged, by publish date.
|
||||
|
||||
## Testing
|
||||
|
||||
- `cargo test -p ipx`: the request's shape and the threshold (`signals::tests`), and the queue,
|
||||
verdicts, ownership, unread counts and new keywords (`db::tests::a_signal_shows_what_jev_said_fits_and_only_to_its_owner`),
|
||||
on SQLite and Postgres both.
|
||||
- `node --test tests/page-unit.js`: a Signal's place id and its list's address.
|
||||
- `npx playwright test -g Signal`: makes a Signal from the folder's +, and checks that the matching
|
||||
item shows and that reading it reads it in its feed and clears the badge. TypeSafe is stood in for
|
||||
by the fixture server's `/jev` (`IPX_TYPESAFE_URL`), which refuses the shape TypeSafe refuses, and
|
||||
`/signal.xml` is dated today, since only the last 30 days are judged.
|
||||
@@ -185,6 +185,15 @@ ts("Page response time", [loki(f'quantile_over_time(0.95, {WEB} | unwrap duratio
|
||||
loki(f'max_over_time({WEB} | unwrap duration [$__interval]) by ()', ref="B", legend="slowest")],
|
||||
16, w=8, unit="s", desc="A page waits on ipx's /api/page-check before it is sent.")
|
||||
y += 8
|
||||
# What the person waits for, from the page itself (#246): a click to the list drawn.
|
||||
LIST = SEL + ' |= "\\"ev\\":\\"list_shown\\"" | json | __error__=""'
|
||||
ts("A click to the list shown", [loki(f'quantile_over_time(0.95, {LIST} | unwrap ms [$__interval]) by ()', legend="95th percentile"),
|
||||
loki(f'quantile_over_time(0.5, {LIST} | unwrap ms [$__interval]) by ()', ref="B", legend="median"),
|
||||
loki(f'max_over_time({LIST} | unwrap ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||
0, w=12, unit="ms", desc="Measured in the browser, network and drawing included. The API's part is in the click's trace.")
|
||||
table("Slowest lists by view", [loki(f'topk(10, max_over_time({LIST} | unwrap ms [$__range]) by (feed, filter))', instant=True)],
|
||||
12, rename={"feed": "View", "filter": "Filter", "Value": "Slowest (ms)"}, sort=[{"displayName": "Slowest (ms)", "desc": True}])
|
||||
y += 8
|
||||
|
||||
# ---- ipx: the API it answers behind ipx-web
|
||||
row("ipx: API")
|
||||
@@ -250,13 +259,10 @@ row("ipx: process")
|
||||
# Open connections stay open, idle, for ten minutes after a burst, so open at the max is not
|
||||
# pressure; drawn as a line of its own it read as the pool maxing out (#199).
|
||||
ts("Database connections", [prom('max(max_over_time(ipx_db_connections{job="ipx", state="in_use"}[$__interval]))', legend="in use"),
|
||||
prom('sum(ipx_db_connections{job="ipx"})', ref="B", legend="open"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="most the pool opens")],
|
||||
0, w=8, desc="In use reaching the most the pool opens means requests and scans are waiting for a connection. "
|
||||
"Open connections stay open, idle, for ten minutes after a burst.")["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byName", "options": "open"},
|
||||
"properties": [{"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [4, 4]}},
|
||||
{"id": "custom.fillOpacity", "value": 0}, {"id": "color", "value": {"mode": "fixed", "fixedColor": "#8e8e8e"}}]}]
|
||||
prom('min(min_over_time(ipx_db_connections{job="ipx", state="idle"}[$__interval]))', ref="B", legend="ready"),
|
||||
prom('max(ipx_db_connections_max{job="ipx"})', ref="C", legend="the pool")],
|
||||
0, w=8, desc="The daemon keeps the whole pool open (#241), so in use and ready add up to the pool. In use reaching "
|
||||
"the pool, ready at 0, means requests and scans are waiting for a connection (#251).")
|
||||
ts("Memory", [prom('ipx_process_resident_bytes{job="ipx"}', legend="resident")], 8, w=8, unit="bytes")
|
||||
ts("Open files and sockets", [prom('ipx_process_open_fds{job="ipx"}', legend="open")], 16, w=8)
|
||||
y += 8
|
||||
@@ -279,7 +285,7 @@ row("ipx-directory")
|
||||
stat("Snapshots", loki(f'sum(count_over_time({DIR} | message="snapshot" [$__range])) or vector(0)', instant=True), 0,
|
||||
desc="ipx sends one when what it shares changes, and every half hour (#211).")
|
||||
stat("Feed checks", loki(f'sum(sum_over_time({DIR} | message="feeds checked" | unwrap checked [$__range])) or vector(0)', instant=True), 4,
|
||||
desc="Each feed every six hours, 25 at a time.")
|
||||
desc="Each feed every twelve hours, five at a time.")
|
||||
stat("Failed feed checks", loki(f'sum(count_over_time({DIR} | message="feed check failed" [$__range])) or vector(0)', instant=True), 8,
|
||||
thresholds=GOOD_BAD, desc="Mostly the publishers': a feed gone or moved.")
|
||||
stat("Requests", loki(f'sum(count_over_time({DIR_HTTP} [$__range])) or vector(0)', instant=True), 12)
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ipx-web",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
@@ -15,6 +18,15 @@
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-free": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ipx-web",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Builds the ipx web pages from web/src (web/build.mjs, run by build.rs) and tests them. The Rust tests cover the server.",
|
||||
"scripts": {
|
||||
@@ -16,6 +16,7 @@
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "7.3.1",
|
||||
"@swc/core": "^1.16.2",
|
||||
"@swc/html": "^1.16.2",
|
||||
"hls.js": "1.6.19"
|
||||
|
||||
@@ -60,6 +60,7 @@ module.exports = defineConfig({
|
||||
// production's Tempo and never opens production's database.
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||
IPX_DATABASE_URL: '',
|
||||
IPX_TYPESAFE_URL: 'http://127.0.0.1:8792/jev',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -29,4 +29,4 @@
|
||||
bind = "0.0.0.0:8100"
|
||||
database_url_env = "DIRECTORY_DATABASE_URL"</code></pre>
|
||||
<pre><code>docker exec ipx-directory ipx-directory token new my-server --access rw</code></pre>
|
||||
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.</p>
|
||||
<p>That prints the token, to give to the server, and the lines to add to <code>directory.toml</code>, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every twelve hours.</p>
|
||||
|
||||
@@ -23,8 +23,6 @@ export const options = {
|
||||
'http_req_duration{name:all-entries}': p95(50, 100),
|
||||
'http_req_duration{name:feed-entries}': p95(50, 100),
|
||||
'http_req_duration{name:search}': p95(60, 100),
|
||||
'http_req_duration{name:directory}': p95(250, 200),
|
||||
'http_req_duration{name:listed}': p95(300, 200),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,11 +42,7 @@ export default function () {
|
||||
const found = http.get(`${BASE}/api/entries?q=weather&limit=50`, as(u, 'search'));
|
||||
check(found, { 'a search of everything you read': r => r.status === 200 && r.json().total === 30 * ITEMS });
|
||||
|
||||
const dir = http.get(`${BASE}/api/directory`, as(u, 'directory'));
|
||||
check(dir, { 'the whole Directory': r => r.status === 200 && r.json().length === FEEDS });
|
||||
|
||||
const listed = http.get(`${BASE}/api/directory/${feedId(Math.floor(Math.random() * FEEDS))}`, as(u, 'listed'));
|
||||
check(listed, { "a feed's page in the Directory": r => r.status === 200 && r.json().items.length === ITEMS });
|
||||
// Not the Directory: since #204 it is ipx-directory's, which this harness does not start (#245).
|
||||
|
||||
sleep(1 + Math.random() * 2);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,8 @@ async function seed() {
|
||||
}
|
||||
// The media test's listener takes the feeds with files. They may be downloaded already: the
|
||||
// first start subscribes the admin to the whole catalogue, so the first scan fetched them.
|
||||
for (let n = 0; n < MEDIA; n++) await api('listener', `/api/popular/${feedId(n)}`, { method: 'POST' });
|
||||
// By OPML as the others: /api/popular asks the directory, which this harness does not start (#245).
|
||||
await api('listener', '/api/opml', { method: 'POST', body: JSON.stringify({ xml: opml(Array.from({ length: MEDIA }, (_, n) => n)) }) });
|
||||
for (let t = Date.now(); ; await new Promise(r => setTimeout(r, 500))) {
|
||||
const got = await api('listener', '/api/entries?filter=downloaded&limit=50');
|
||||
if (got.entries.flatMap(e => e.enclosures).filter(x => x.path).length >= MEDIA) break;
|
||||
|
||||
@@ -125,8 +125,77 @@ test("a file plays from DeadAir's playlist when it has one", () => {
|
||||
assert.equal(run(`mediaSrc({ id: 7, deadair: 0 })`), '/media/7/deadair.m3u8');
|
||||
});
|
||||
|
||||
test('kept lists: the newest few dozen, and a changed feed forgets its own and All Subscriptions\'', () => {
|
||||
run('lists.clear()');
|
||||
for (let i = 0; i < 45; i++) run(`keepList(listKey('f${i}'), {total: 0, entries: []})`);
|
||||
assert.equal(run('lists.size'), 40, 'never more than LISTS_KEPT');
|
||||
assert.equal(run(`lists.has(listKey('f0'))`), false, 'the oldest go first');
|
||||
run(`keepList(listKey('f10'), {total: 1, entries: []})`);
|
||||
run(`keepList(listKey(':all'), {total: 0, entries: []}); keepList(listKey(':all', 'unread'), {total: 0, entries: []})`);
|
||||
run(`forgetLists('f10')`);
|
||||
assert.equal(run(`lists.has(listKey('f10'))`), false);
|
||||
assert.equal(run(`lists.has(listKey(':all')) || lists.has(listKey(':all', 'unread'))`), false, 'All Subscriptions holds its items too');
|
||||
assert.equal(run(`lists.has(listKey('f11'))`), true, 'the rest stay');
|
||||
});
|
||||
|
||||
test('a Patreon feed is told by its address', () => {
|
||||
assert.equal(run(`isPatreon({ url: 'https://www.patreon.com/rss/show?auth=x' })`), true);
|
||||
assert.equal(run(`isPatreon({ url: 'https://example.com/feed' })`), false);
|
||||
assert.equal(run('isPatreon(null)'), false);
|
||||
});
|
||||
|
||||
test('a Signal is a place of its own, found by its id', () => {
|
||||
run(`S.signals = [{id: 7, name: 'F1', keywords: ['f1'], unread: 2}]`);
|
||||
assert.equal(run(`signalId(':signal:7')`), 7);
|
||||
assert.equal(run(`signalId(':all')`), null);
|
||||
assert.equal(run(`signalId('signal-7')`), null, 'a feed id is never a Signal');
|
||||
assert.equal(run(`signalOf(':signal:7').name`), 'F1');
|
||||
assert.equal(run(`signalOf(':signal:8')`), undefined);
|
||||
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
||||
run(`S.signals = []`);
|
||||
});
|
||||
|
||||
test('the icon picker finds icons by the start of their words, the one named it first', () => {
|
||||
const all = JSON.stringify([
|
||||
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
|
||||
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
||||
['brands/apple', 'Apple', 'fruit ios mac'],
|
||||
]);
|
||||
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
||||
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
||||
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
||||
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
||||
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
|
||||
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
|
||||
});
|
||||
|
||||
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
|
||||
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
|
||||
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
|
||||
{ icon: 'solid/car' });
|
||||
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
|
||||
{ keywords: ['f1', 'gp'] });
|
||||
});
|
||||
|
||||
test('a Signal draws its icon only when it is a style and a plain name', () => {
|
||||
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
|
||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
||||
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
||||
});
|
||||
|
||||
test('Jev is offered the icons found for each word of a Signal', () => {
|
||||
const all = JSON.stringify([
|
||||
['solid/robot', 'Robot', 'ai bot machine'], ['solid/code', 'Code', 'programming brackets'],
|
||||
['solid/car', 'Car', 'auto vehicle'], ['solid/the-red-yeti', 'The Red Yeti', ''],
|
||||
]);
|
||||
const ids = JSON.parse(run(`JSON.stringify(iconCandidates(${all}, 'AI Development', ['programming'], 'The tools', 40).map(c => c[0]))`));
|
||||
assert.deepEqual(ids, ['solid/robot', 'solid/code'], 'each word its icons, "the" none');
|
||||
const brands = JSON.stringify([
|
||||
['brands/mandalorian', 'Mandalorian', 'disney plus helmet star wars'], ['brands/apple', 'Apple', 'fruit ios mac'],
|
||||
['solid/fort-awesome', 'Fort', 'castle'], ['solid/apple-whole', 'Apple Whole', 'fruit'],
|
||||
]);
|
||||
const offered = q => JSON.parse(run(`JSON.stringify(iconCandidates(${brands}, ${JSON.stringify(q)}, [], null, 40).map(c => c[0]))`));
|
||||
assert.deepEqual(offered('Disney castle'), ['solid/fort-awesome'], 'a brand only by its own name, never its search terms (#264)');
|
||||
assert.deepEqual(offered('Apple'), ['brands/apple', 'solid/apple-whole']);
|
||||
assert.equal(run(`signalCounts({items: 12, unread: 1})`), '12 items · 1 unread');
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
||||
// Regression: a ReferenceError in the script left the shell rendered and the sidebar
|
||||
// empty, with every handler below the error dead. Server-side checks all passed.
|
||||
// Four top-level feeds in the fixture config; the OPML's children are inside a closed folder.
|
||||
await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 });
|
||||
// The Signals folder is above them, and no feed.
|
||||
await expect(page.locator('.feed:not(.sigs)')).toHaveCount(5, { timeout: 15_000 });
|
||||
await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible();
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
@@ -403,7 +404,8 @@ test('an OPML subscription is a collapsible folder', async ({ page }) => {
|
||||
// Only a folder has a triangle, and it is a button that says whether the folder is open.
|
||||
const chev = page.locator('.feed.group .chev');
|
||||
await expect(chev).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator('.feed:not(.group) .chev')).toHaveCount(0);
|
||||
// The Signals folder is a folder too (#255).
|
||||
await expect(page.locator('.feed:not(.group):not(.sigs) .chev')).toHaveCount(0);
|
||||
await expect(chev).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Closed by default: the children are not listed until the folder is opened.
|
||||
@@ -1141,6 +1143,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||
async () => { await page.locator('#feedlist .sigs').click(); await page.locator('#content .acts [data-a="new"]').click(); },
|
||||
];
|
||||
for (const open of dialogs) {
|
||||
await open();
|
||||
@@ -1668,7 +1671,8 @@ test.describe('an item with no files, on a phone', () => {
|
||||
});
|
||||
|
||||
test('a pinned feed, even one from inside a folder, goes to the top of the list', async ({ page }) => {
|
||||
const rows = page.locator('#feedlist .feed');
|
||||
// The Signals folder is first, under All Subscriptions (#256), and no feed.
|
||||
const rows = page.locator('#feedlist .feed:not(.sigs)');
|
||||
const group = page.locator('.feed.group').first();
|
||||
if ((await group.locator('.chev').getAttribute('aria-expanded')) !== 'true') await group.locator('.chev').click();
|
||||
const child = page.locator('.feed.child').first();
|
||||
@@ -2015,3 +2019,111 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p
|
||||
const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top)));
|
||||
expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1);
|
||||
});
|
||||
|
||||
test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => {
|
||||
// #247. Every list's answer held back a second and a half, as a slow connection would.
|
||||
await page.route(/\/api\/(entries|feeds\/[^/]+\/entries)\?/, async route => {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await route.continue();
|
||||
});
|
||||
await page.evaluate(() => { lists.clear(); fetching.clear(); });
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await expect(page.locator('#eps .listload'), 'waited for, and said so').toBeVisible();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 10_000 });
|
||||
// Back to All Subscriptions: drawn from what was kept, before the slow answer could come.
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 500 });
|
||||
await expect(page.locator('#eps .listload')).toHaveCount(0);
|
||||
await page.unroute(/\/api\/(entries|feeds\/[^/]+\/entries)\?/);
|
||||
});
|
||||
|
||||
test('Currently Listening and the Directory show at once once seen, with a spinner the first time', async ({ page }) => {
|
||||
// #248. Both answers held back a second and a half.
|
||||
const slow = /\/api\/(directory$|entries\?filter=in_progress)/;
|
||||
await page.route(slow, async route => { await new Promise(r => setTimeout(r, 1500)); await route.continue(); });
|
||||
// What the page fetched ahead when it loaded is let finish and then forgotten, and no more is
|
||||
// fetched ahead, or the first visit would find the Directory already here, as it is meant to.
|
||||
await page.waitForFunction(() => pageFetching.size === 0 && fetching.size === 0);
|
||||
await page.evaluate(() => { window.preloadLists = () => {}; pages.clear(); });
|
||||
for (const [id, drawn] of [[':listening', '#listening .childrow, #listening .hint'], [':directory', '#directory > *:not(.listload)']]) {
|
||||
await page.locator(`.place[data-id="${id}"]`).click();
|
||||
await expect(page.locator('.listload'), `${id}: waited for, and said so`).toBeVisible();
|
||||
await expect(page.locator(drawn).first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('.place[data-id=":all"]').click();
|
||||
await page.locator(`.place[data-id="${id}"]`).click();
|
||||
await expect(page.locator(drawn).first(), `${id}: drawn from what was kept`).toBeVisible({ timeout: 500 });
|
||||
await expect(page.locator('.listload')).toHaveCount(0);
|
||||
}
|
||||
await page.unroute(slow);
|
||||
});
|
||||
|
||||
test('the page comes compressed, and the compressed copies cannot be asked for by name', async ({ page }) => {
|
||||
// #253: brotli made at build, served in place of the file.
|
||||
const js = await page.request.get('/app.js', { headers: { 'Accept-Encoding': 'br, gzip' } });
|
||||
expect(js.status()).toBe(200);
|
||||
expect(js.headers()['content-encoding']).toBe('br');
|
||||
expect(js.headers()['content-type']).toContain('javascript');
|
||||
const gz = await page.request.get('/app.css', { headers: { 'Accept-Encoding': 'gzip' } });
|
||||
expect(gz.headers()['content-encoding']).toBe('gzip');
|
||||
// The admin page's script, under its compressed name, would skip the check that keeps it to admins.
|
||||
for (const p of ['/admin.js.br', '/admin.html.gz', '/app.js.br']) {
|
||||
expect((await page.request.get(p)).status(), p).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
test('a Signal holds the items Jev says fit its keywords, read where they are read', async ({ page }) => {
|
||||
// #254. Jev is the fixture server's stand-in; the show is added here and removed at the end.
|
||||
expect((await page.request.patch('/api/me', { data: { typesafe_key: 'test-key' } })).status()).toBe(204);
|
||||
const { id: feed } = await (await page.request.post('/api/feeds', { data: { url: 'http://127.0.0.1:8792/signal.xml' } })).json();
|
||||
await page.reload();
|
||||
|
||||
// The + on the folder's page (#265); making one opens the folder to show it.
|
||||
await page.locator('#feedlist .sigs').click();
|
||||
await page.locator('#content .acts [data-a="new"]').click();
|
||||
await page.locator('#sgname').fill('F1');
|
||||
await page.locator('#sgkwin').fill('monaco');
|
||||
await page.keyboard.press('Enter');
|
||||
// An icon of its own, found by searching Font Awesome Free (#261).
|
||||
await page.locator('#sgicon').click();
|
||||
await page.locator('#sgiconq').fill('flag checkered');
|
||||
await page.locator('[data-ic="solid/flag-checkered"]').click();
|
||||
await page.locator('#sgsave').click();
|
||||
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
||||
await expect(row).toHaveClass(/sel/);
|
||||
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
|
||||
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
|
||||
|
||||
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||
const sg = (await signals()).find(s => s.name === 'F1');
|
||||
const shown = async () => (await (await page.request.get(`/api/signals/${sg.id}/entries`)).json()).entries.map(e => e.title);
|
||||
await expect.poll(shown, { timeout: 15_000 }).toEqual(['Monaco qualifying']);
|
||||
|
||||
await page.reload();
|
||||
await row.click();
|
||||
await page.locator('.ep', { hasText: 'Monaco qualifying' }).click();
|
||||
await expect.poll(async () => (await (await page.request.get(`/api/entries/${encodeURIComponent(feed)}/sig-1`)).json()).read,
|
||||
{ timeout: 5_000 }).toBe(true);
|
||||
expect((await signals()).find(s => s.id === sg.id).unread).toBe(0);
|
||||
// And the sidebar says so without a reload: it kept saying 1 (#258).
|
||||
await expect(row.locator('.badge')).toHaveText('0');
|
||||
|
||||
expect((await page.request.get(`/api/signals/${sg.id + 1000}/entries`)).status(), 'no such Signal').toBe(404);
|
||||
|
||||
await page.request.delete(`/api/signals/${sg.id}`);
|
||||
await page.request.delete(`/api/feeds/${encodeURIComponent(feed)}`);
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
});
|
||||
|
||||
test('a Signal with no key to judge it links to where one is got and where it goes', async ({ page }) => {
|
||||
await page.request.patch('/api/me', { data: { typesafe_key: null } });
|
||||
const { id } = await (await page.request.post('/api/signals', { data: { name: 'Keyless', keywords: ['x'] } })).json();
|
||||
await page.reload();
|
||||
await page.evaluate(id => selectFeed(':signal:' + id), id);
|
||||
await expect(page.locator('#content a[href="https://typesafe.ai"]')).toBeVisible();
|
||||
await page.locator('#content [data-keylink]').click();
|
||||
await expect(page.locator('#stskey')).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.request.delete(`/api/signals/${id}`);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,35 @@ const dir = __dirname;
|
||||
const port = Number(process.env.FIXTURE_PORT || 8792);
|
||||
|
||||
http.createServer((req, res) => {
|
||||
// Jev, for Signals (#254): sure of an item whose text has a question's keyword in it. Refuses
|
||||
// a request in the shape TypeSafe refuses, as it does (#257).
|
||||
if (req.method === 'POST' && req.url === '/jev') {
|
||||
let body = '';
|
||||
req.on('data', c => { body += c; });
|
||||
req.on('end', () => {
|
||||
const { state, questions } = JSON.parse(body);
|
||||
if (Object.values(questions).some(q => typeof q.criteria !== 'object')) {
|
||||
res.writeHead(422, { 'content-type': 'application/json' }).end('{"detail":"criteria should be an object"}');
|
||||
return;
|
||||
}
|
||||
const title = String(state).toLowerCase();
|
||||
const answers = {};
|
||||
for (const [id, q] of Object.entries(questions)) {
|
||||
const words = /described by these words: ([^.]*)\./.exec(q.instructions)[1].split(', ');
|
||||
answers[id] = { noul: words.some(w => title.includes(w.toLowerCase())) ? 0.9 : 0.1 };
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ answers }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Dated today, since a Signal looks at the last 30 days only.
|
||||
if (req.url === '/signal.xml') {
|
||||
const now = new Date().toUTCString();
|
||||
const item = (guid, title) => `<item><guid>${guid}</guid><title>${title}</title><pubDate>${now}</pubDate></item>`;
|
||||
res.writeHead(200, { 'content-type': 'application/xml' }).end(`<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>Signal Show</title>${item('sig-1', 'Monaco qualifying')}${item('sig-2', 'Baking bread')}</channel></rss>`);
|
||||
return;
|
||||
}
|
||||
const name = decodeURIComponent(req.url.split('?')[0].replace(/^\//, '')) || 'index';
|
||||
const file = path.join(dir, path.basename(name));
|
||||
fs.readFile(file, (err, body) => {
|
||||
|
||||
@@ -11,6 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.1.0] - 2026-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need, and a Signal without one links to typesafe.ai to get one and to the key's place in Settings (#254, #266). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with its own page listing them and a + there for a new one (#255, #256, #265). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261), or one picked for it with Auto, which a Signal made without an icon gets by itself; each Signal's row counts its items and unread as a feed's does (#263).
|
||||
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
||||
|
||||
### Changed
|
||||
|
||||
- The page reports how long each list you open takes to show, from the click to the list drawn, for the dashboard (#246).
|
||||
- The page's files are compressed once when built, brotli at its strongest and gzip, and sent in place of the files: the script 30 KB where it was 37, the stylesheet 11 where it was 14. Traces go to the collector compressed (#253).
|
||||
- The page and the API's answers are sent compressed, zstd or gzip, which the feed list and item lists, over 100 KB each, were not; live updates and audio are not compressed (#239).
|
||||
|
||||
## [1.0.2] - 2026-10-15
|
||||
|
||||
### Fixed
|
||||
@@ -49,7 +62,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- On a phone, the Subscribe button on a Directory feed's page is full size again, not squashed to a sliver (#212).
|
||||
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...main
|
||||
[unreleased]: https://git.sdf1.net/rays/ipx/compare/web-v1.1.0...main
|
||||
[1.1.0]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.2...web-v1.1.0
|
||||
[1.0.2]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.1...web-v1.0.2
|
||||
[1.0.1]: https://git.sdf1.net/rays/ipx/compare/web-v1.0.0...web-v1.0.1
|
||||
[1.0.0]: https://git.sdf1.net/rays/ipx/releases/tag/web-v1.0.0
|
||||
|
||||
@@ -57,6 +57,21 @@ http://{$WEB_BIND::8080} {
|
||||
}
|
||||
}
|
||||
|
||||
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
|
||||
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
|
||||
# until it had enough to send, nor /media, which is audio and video already compressed.
|
||||
@compress not path /api/events /media/*
|
||||
encode @compress zstd gzip
|
||||
|
||||
# The copies web/build.mjs compressed are served in place of their file, never asked for by
|
||||
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
|
||||
# admins (#253).
|
||||
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
|
||||
@packed path *.br *.gz
|
||||
handle @packed {
|
||||
respond 404
|
||||
}
|
||||
|
||||
# Before anything else, so no page or API call sees them: only ipx sets these.
|
||||
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
|
||||
request_header @forged -Cf-Access-Authenticated-User-Email
|
||||
@@ -91,7 +106,9 @@ http://{$WEB_BIND::8080} {
|
||||
rewrite / /index.html
|
||||
rewrite /admin /admin.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
|
||||
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
|
||||
@@ -102,18 +119,22 @@ http://{$WEB_BIND::8080} {
|
||||
}
|
||||
rewrite * /login.html
|
||||
header Cache-Control no-cache
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
|
||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
||||
# keeps them a year; the icons a day, under names that do not change.
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
|
||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
||||
header /favicon.ico Content-Type image/png
|
||||
@icons path *.png *.svg *.ico *.woff2
|
||||
header @icons Cache-Control "public, max-age=86400"
|
||||
handle {
|
||||
file_server
|
||||
file_server {
|
||||
precompressed br gzip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
web/app.css
30
web/app.css
@@ -501,9 +501,9 @@ a{color:var(--accent)}
|
||||
}
|
||||
#applogo{display:flex;flex:none}
|
||||
#applogo img{width:28px;height:28px;border-radius:7px}
|
||||
/* The logo in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||
/* The logo, and the app-icon tiles, in the mode's own variant. Dark unless the page says light, as the palette is: with
|
||||
Auto, data-mode arrives with the script, and until then the page is drawn dark. */
|
||||
:root[data-mode="light"] .logo.ondark,:root:not([data-mode="light"]) .logo.onlight{display:none}
|
||||
:root[data-mode="light"] .ondark,:root:not([data-mode="light"]) .onlight{display:none}
|
||||
/* Classic is the 2004 app, so it wears the 2004 app's icon. */
|
||||
.logo.classic,:root[data-theme="classic"] .logo:not(.classic){display:none}
|
||||
:root[data-theme="classic"] .logo.classic{display:block}
|
||||
@@ -590,6 +590,26 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
||||
.feed.lastpin{margin-bottom:9px}
|
||||
.feed.lastpin::after{content:"";position:absolute;left:8px;right:8px;bottom:-5px;border-bottom:1px solid var(--line)}
|
||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
|
||||
and follows the theme as the page's own icons do. */
|
||||
.ficon{
|
||||
display:block;width:50%;height:50%;background:currentColor;
|
||||
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
|
||||
}
|
||||
.iconpick{flex:none;border-radius:9px}
|
||||
.iconpick .art{width:38px;height:38px;font-size:13px}
|
||||
.icongrid{
|
||||
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
|
||||
max-height:220px;overflow-y:auto;margin-top:8px;
|
||||
}
|
||||
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
|
||||
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||
.iconcell .ficon{width:20px;height:20px}
|
||||
/* Auto in the icon picker, while an icon is picked: the scan spinner, beside the word. */
|
||||
.dlink.busy::after{
|
||||
content:"";display:inline-block;width:10px;height:10px;margin-left:6px;vertical-align:-1px;border-radius:50%;
|
||||
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite;
|
||||
}
|
||||
/* Only a folder has a triangle, hung in the margin so every feed's art lines up with the places
|
||||
above it. The button is the row's full height and 24 px wide: a near miss used to open the
|
||||
folder's page, and 16 px left the triangle cramped against the art. */
|
||||
@@ -756,6 +776,8 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
|
||||
display:grid;place-items:center;color:var(--faint);font-weight:600;overflow:hidden;
|
||||
}
|
||||
.art>.tile{width:100%;height:100%;display:block}
|
||||
#pdead .tile{width:26px;height:26px;border-radius:6px}
|
||||
/* Initials, tinted by a hash of the name so two "TS" in a row can be told apart. Mixed into the
|
||||
page ground, not --raise: that is also the selected row, and a selected tile vanished into it. */
|
||||
.art.ini{background:color-mix(in srgb,var(--tint) 24%,var(--bg));color:var(--tint)}
|
||||
@@ -797,6 +819,10 @@ body.scan-this .fhead [data-a=scan] .i,body.scan-any .fhead [data-a=scanall] .i,
|
||||
#pulltip{display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px;overflow:hidden;
|
||||
font-size:12px;color:var(--faint)}
|
||||
/* What a pull started, for a couple of seconds after letting go, under the top bar. */
|
||||
/* A list being waited for (#247): the pull-to-refresh spinner, in the list's place. */
|
||||
.listload{display:flex;align-items:center;justify-content:center;gap:8px;padding:28px 8px;color:var(--dim);font-size:13px}
|
||||
.listload::before{content:"";width:12px;height:12px;border-radius:50%;
|
||||
border:2px solid var(--line);border-top-color:var(--accent);animation:ipxspin .8s linear infinite}
|
||||
#pullspin{position:fixed;left:50%;top:calc(58px + var(--safe-t));transform:translateX(-50%);z-index:55;
|
||||
display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;font-size:12px;
|
||||
color:var(--dim);background:var(--panel);border:1px solid var(--line);box-shadow:var(--shadow)}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import swc from '@swc/core';
|
||||
import html from '@swc/html';
|
||||
@@ -23,7 +24,7 @@ const WEB_VERSION = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.js
|
||||
// The files are one script, concatenated in this order, not modules: they share one top-level
|
||||
// scope, as the single inline script did, and code that runs at load needs what came before it.
|
||||
const PAGES = {
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'gestures', 'events', 'native'] },
|
||||
'index.html': { script: 'app.js', src: ['util', 'theme', 'feeds', 'feedpage', 'items', 'player', 'dialogs', 'signals', 'gestures', 'events', 'native'] },
|
||||
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||
'login.html': { script: 'login.js', src: ['login'] },
|
||||
};
|
||||
@@ -37,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
|
||||
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
||||
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
||||
|
||||
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
|
||||
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
|
||||
// colour, and an index of names and search terms the picker fetches when it opens. The page's
|
||||
// script is given the index's address, named by its hash, in place of __ICONS__.
|
||||
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||
const FA_STYLES = ['solid', 'regular', 'brands'];
|
||||
/// [`style/name`, label, search terms] for every icon, solid first.
|
||||
export function iconIndex() {
|
||||
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
|
||||
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
|
||||
const name = f.slice(0, -4), m = meta[name] || {};
|
||||
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
|
||||
}));
|
||||
}
|
||||
const ICONS = JSON.stringify(iconIndex());
|
||||
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
|
||||
|
||||
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
||||
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||
export function buildStyle({ minify = true } = {}) {
|
||||
@@ -60,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
|
||||
const { script, src: files } = PAGES[name];
|
||||
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
||||
.replaceAll('__HLS_JS__', HLS_URL)
|
||||
.replaceAll('__ICONS__', ICONS_URL)
|
||||
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
||||
const js = swc.transformSync(src, {
|
||||
filename: name + '.ts',
|
||||
@@ -104,13 +123,29 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
// nothing here shows any more but the classic theme.
|
||||
for (const [from, to] of [
|
||||
['logo.svg', 'logo.svg'], ['logo-dark.svg', 'logo-dark.svg'],
|
||||
...['signals', 'deadair-on', 'deadair-off'].flatMap(n => [`${n}.svg`, `${n}-dark.svg`]).map(f => [f, f]),
|
||||
['favicon.png', 'favicon.png'], ['favicon.png', 'favicon.ico'], ['favicon-dark.png', 'favicon-dark.png'],
|
||||
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||
for (const style of FA_STYLES) {
|
||||
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
|
||||
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
|
||||
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
|
||||
for (const name of Object.keys(PAGES)) {
|
||||
const { html, js, script } = buildPage(name);
|
||||
fs.writeFileSync(path.join(out, name), html);
|
||||
fs.writeFileSync(path.join(out, script), js);
|
||||
}
|
||||
// Each text file compressed once here, as hard as it goes, brotli and gzip beside it, for Caddy
|
||||
// to send in its place (#253): smaller than compressing each answer as it goes, and no work then.
|
||||
for (const f of fs.readdirSync(out).filter(f => /\.(html|js|css|svg|txt)$/.test(f))) {
|
||||
const body = fs.readFileSync(path.join(out, f));
|
||||
fs.writeFileSync(path.join(out, f + '.br'), zlib.brotliCompressSync(body, {
|
||||
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length },
|
||||
}));
|
||||
fs.writeFileSync(path.join(out, f + '.gz'), zlib.gzipSync(body, { level: 9 }));
|
||||
}
|
||||
}
|
||||
|
||||
17
web/deadair-off-dark.svg
Normal file
17
web/deadair-off-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- deadair-off.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#8fc2ea">
|
||||
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
</g>
|
||||
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 868 B |
18
web/deadair-off.svg
Normal file
18
web/deadair-off.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- DeadAir off: deadair-on.svg's waveform with the silence still in its middle, a flat line,
|
||||
its bars muted, as the page's off glyph is only outlined; the silence keeps the orange. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#2f6aa0">
|
||||
<rect x="120" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
<rect x="264" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="648" y="208" width="112" height="608" rx="56" opacity=".5"/>
|
||||
<rect x="792" y="336" width="112" height="352" rx="56" opacity=".3"/>
|
||||
</g>
|
||||
<g id="silence"><rect x="424" y="464" width="176" height="96" rx="48" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 965 B |
17
web/deadair-on-dark.svg
Normal file
17
web/deadair-on-dark.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- deadair-on.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#8fc2ea">
|
||||
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 870 B |
20
web/deadair-on.svg
Normal file
20
web/deadair-on.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- DeadAir on, drawn as logo.svg is (square, opaque, one background and flat hard-edged
|
||||
layers, nothing thin enough to vanish at 32px) from the page's own DeadAir glyph: the
|
||||
waveform closed up where its silence was, the cut marked by the needle in orange.
|
||||
deadair-off.svg is the same waveform with the silence still in it. Each <g> is a layer. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waveform" fill="#2f6aa0">
|
||||
<rect x="188" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
<rect x="332" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="580" y="208" width="112" height="608" rx="56" opacity=".85"/>
|
||||
<rect x="724" y="336" width="112" height="352" rx="56" opacity=".55"/>
|
||||
</g>
|
||||
<g id="needle"><rect x="476" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
15
web/signals-dark.svg
Normal file
15
web/signals-dark.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- signals.svg's dark appearance: the same layers in the same places, only recoloured. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1d4468"/>
|
||||
<stop offset="1" stop-color="#0c2238"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waves" fill="none" stroke="#8fc2ea" stroke-width="112" stroke-linecap="round">
|
||||
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||
</g>
|
||||
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff6a1a"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 779 B |
17
web/signals.svg
Normal file
17
web/signals.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<!-- The Signals folder's tile, drawn as logo.svg is (square, opaque, one background and flat
|
||||
hard-edged layers, nothing thin enough to vanish at 32px) from the page's own Signals
|
||||
glyph: waves going out from a point, the point in the needle's orange. Each <g> is a layer. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#dff0fb"/>
|
||||
<stop offset="1" stop-color="#9fccef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="background"><rect width="1024" height="1024" fill="url(#bg)"/></g>
|
||||
<g id="waves" fill="none" stroke="#2f6aa0" stroke-width="112" stroke-linecap="round">
|
||||
<path d="M293 252A340 340 0 0 0 293 772M731 252A340 340 0 0 1 731 772" opacity=".5"/>
|
||||
<path d="M377 351A210 210 0 0 0 377 673M647 351A210 210 0 0 1 647 673" opacity=".85"/>
|
||||
</g>
|
||||
<g id="point"><circle cx="512" cy="512" r="96" fill="#ff5500"/></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -100,13 +100,23 @@ const KIND_WORD: Record<string,string>={All:'feed',Podcasts:'podcast',Blogs:'blo
|
||||
/// categories, and a category has a page of its own, its subcategories along the top of its
|
||||
/// grid. What a feed is (Podcasts, anything with audio or video, or Blogs, the rest) holds across
|
||||
/// every page, and the search box looks through the lot. One fetch draws all of it.
|
||||
async function renderDirectory(url: string,box: HTMLElement){
|
||||
async function renderDirectory(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Each visit starts at the front, as going back to a tab does.
|
||||
dirCat=null; dirSub=null; dirShow=null;
|
||||
dirError='';
|
||||
try{ dirRows=await api(url)||[]; }catch(e){ dirRows=[]; dirError=why(e); }
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if the catalogue changed
|
||||
// (#248); with nothing kept, a spinner until it comes.
|
||||
const kept=pages.get(url);
|
||||
if(kept){ dirRows=kept; drawDirectory(box); counted(dirRows.length); }
|
||||
else listLoading(box);
|
||||
try{
|
||||
const fresh=await fetchPage(url)||[];
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
dirRows=fresh;
|
||||
}catch(e){ if(kept) return; dirRows=[]; dirError=why(e); }
|
||||
drawDirectory(box);
|
||||
return dirRows.length;
|
||||
counted(dirRows.length);
|
||||
}
|
||||
|
||||
/// `focus` is the control to put the keyboard back on, since the whole page is redrawn.
|
||||
@@ -272,10 +282,9 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
||||
syncTools(null);
|
||||
if(v===VIEWS[':directory']){
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Find a feed…';
|
||||
box.innerHTML='<div id="directory"><p class="hint">Loading…</p></div>';
|
||||
box.innerHTML='<div id="directory"></div>';
|
||||
$('#count').textContent=v.title;
|
||||
const n=await renderDirectory(v.url!,$('#directory'));
|
||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`;
|
||||
await renderDirectory(v.url!,$('#directory'),n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'feed')}`; });
|
||||
return;
|
||||
}
|
||||
$<HTMLInputElement>('#epSearch').placeholder='Search items…';
|
||||
@@ -284,19 +293,30 @@ async function renderListed(v: {title: string, icon: string, url?: string, blurb
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta"><h2>${v.title}</h2><div class="sub">${v.blurb}</div></div>
|
||||
</div>
|
||||
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`;
|
||||
<div class="childlist" id="listening"></div>`;
|
||||
$('#count').textContent=v.title;
|
||||
// The search box looks through these too, title and text, as it does a feed's items (#127).
|
||||
const n=await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box));
|
||||
if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`;
|
||||
await renderListening(S.q?`${v.url}&q=${encodeURIComponent(S.q)}`:v.url!,$('#listening',box),
|
||||
n=>{ if(VIEWS[S.feed ?? '']===v) $('#count').textContent=`${v.title}: ${plural(n,'episode')}`; });
|
||||
}
|
||||
|
||||
/// Currently Listening: episodes you started and have not finished, across every feed you
|
||||
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
|
||||
/// where you left off, not another way to browse. The one in the player pauses instead.
|
||||
async function renderListening(url: string,box: HTMLElement){
|
||||
let rows: Entry[]=[];
|
||||
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||
async function renderListening(url: string,box: HTMLElement,counted: (n: number)=>void){
|
||||
// Drawn at once from what was kept or fetched ahead, then again only if it changed (#248): it
|
||||
// took over a second to answer. With nothing kept, a spinner until it comes.
|
||||
const kept: {entries?: Entry[]}|undefined=pages.get(url);
|
||||
if(kept){ drawListening(box,kept.entries||[]); counted((kept.entries||[]).length); }
|
||||
else listLoading(box);
|
||||
let fresh: {entries?: Entry[]};
|
||||
try{ fresh=await fetchPage(url); }catch{ if(kept) return; fresh={}; }
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(fresh)) return;
|
||||
if(!box.isConnected) return;
|
||||
drawListening(box,fresh.entries||[]);
|
||||
counted((fresh.entries||[]).length);
|
||||
}
|
||||
function drawListening(box: HTMLElement,rows: Entry[]){
|
||||
box.innerHTML=rows.length?'':S.q?`<p class="hint">Nothing you are listening to mentions “${esc(S.q)}”.</p>`
|
||||
:'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||
for(const e of rows){
|
||||
@@ -315,7 +335,6 @@ async function renderListening(url: string,box: HTMLElement){
|
||||
paintListenRow(el);
|
||||
box.appendChild(el);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
/// One row's time left, progress and play button, taken from the player when it is the one in it.
|
||||
@@ -348,6 +367,8 @@ async function forget(e: Entry){
|
||||
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||
{method:'POST',body:JSON.stringify({secs:0})});
|
||||
}catch(err){ toast(why(err),true); }
|
||||
// The kept list still has it; drawn from that, it would show again until the refresh came.
|
||||
for(const k of [...pages.keys()]) if(k.startsWith(VIEWS[':listening'].url!)) pages.delete(k);
|
||||
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||
}
|
||||
|
||||
@@ -419,6 +440,13 @@ async function prefsModal(){
|
||||
<span class="hint">Comma separated words or phrases, in every feed you read. An item with
|
||||
one in its title or text is hidden from you and not downloaded for you. Each feed's
|
||||
settings can add more.</span></div>
|
||||
<div class="field"><label for="stskey">TypeSafe key</label>
|
||||
<div class="inline"><input type="password" id="stskey" autocomplete="off" spellcheck="false"
|
||||
placeholder="${S.me?.typesafe_key_set?'Saved. Paste a new one to replace it':'Paste your key'}">
|
||||
<button class="btn ico" id="stsforget" title="Forget the key" aria-label="Forget the key"${S.me?.typesafe_key_set?'':' hidden'}>${ICON.trash}</button></div>
|
||||
<span class="hint">Signals pick what matters out of everything you subscribe to, on your own
|
||||
key from <a href="https://typesafe.ai" target="_blank" rel="noopener">typesafe.ai</a>.
|
||||
The key is kept on this server and never shown again.</span></div>
|
||||
<div class="field boxed"><label for="tokname">API tokens</label>
|
||||
<div id="tokens"></div>
|
||||
<div class="inline"><input type="text" id="tokname" placeholder="What it is for">
|
||||
@@ -448,6 +476,18 @@ async function prefsModal(){
|
||||
$<HTMLInputElement>('#tokval').select();
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
const saveKey=async(typesafe_key: string|null)=>{
|
||||
try{
|
||||
await api('/api/me',{method:'PATCH',body:JSON.stringify({typesafe_key})});
|
||||
if(S.me) S.me.typesafe_key_set=!!typesafe_key;
|
||||
$<HTMLInputElement>('#stskey').value='';
|
||||
$<HTMLInputElement>('#stskey').placeholder=typesafe_key?'Saved. Paste a new one to replace it':'Paste your key';
|
||||
$<HTMLButtonElement>('#stsforget').hidden=!typesafe_key;
|
||||
toast(typesafe_key?'Saved':'Forgotten');
|
||||
}catch(err){ toast(why(err),true); }
|
||||
};
|
||||
$<HTMLInputElement>('#stskey').onchange=(e: Event)=>{ const k=(e.target as HTMLInputElement).value.trim(); if(k) saveKey(k); };
|
||||
$<HTMLButtonElement>('#stsforget').onclick=()=>saveKey(null);
|
||||
$<HTMLInputElement>('#sblock').onchange=async (e: Event)=>{
|
||||
const blocked=splitWords((e.target as HTMLInputElement).value);
|
||||
try{
|
||||
@@ -757,7 +797,7 @@ $('#feedlist').onkeydown=(ev: KeyboardEvent)=>{
|
||||
const row=(ev.target as Element).closest<HTMLElement>('[data-id]'); if(!row) return;
|
||||
const id=row.dataset.id!;
|
||||
if((ev.key==='Enter'||ev.key===' ')&&ev.target===row) row.click();
|
||||
else if(row.classList.contains('group')&&
|
||||
else if(row.matches('.group,.sigs')&&
|
||||
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||
else return;
|
||||
ev.preventDefault(); ev.stopPropagation();
|
||||
|
||||
@@ -6,7 +6,7 @@ let sse: EventSource;
|
||||
/// the app is in the background, when everything is missed (#168).
|
||||
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||
loadFeeds(true).catch(()=>{});
|
||||
if(S.feed) loadEntries().catch(()=>{});
|
||||
if(S.feed) loadEntries(false,true).catch(()=>{});
|
||||
},300); }; })();
|
||||
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
|
||||
let dropped=false;
|
||||
@@ -16,9 +16,11 @@ function connect(){
|
||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
|
||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
|
||||
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
|
||||
const preloadSoon=soon(preloadLists,2000);
|
||||
sse.onmessage=m=>{
|
||||
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||
if(ev.ev==='progress'){
|
||||
@@ -41,13 +43,19 @@ function connect(){
|
||||
if(!mine(ev.feed)) return;
|
||||
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
|
||||
refreshEntries();
|
||||
if(S.sel) loadEntries().then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
||||
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
|
||||
}
|
||||
else if(ev.ev==='download_error'){
|
||||
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||
if(bar) bar.classList.remove('live');
|
||||
toast('Download failed: '+ev.msg,true); refreshEntries();
|
||||
}
|
||||
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
|
||||
else if(ev.ev==='signal_hits'){
|
||||
if(!S.signals.length) return;
|
||||
refreshSignals();
|
||||
if(signalOf(S.feed)) 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);
|
||||
@@ -61,7 +69,7 @@ function connect(){
|
||||
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
|
||||
// The server sends a feed's new row after anything changes it (counts, error, last check),
|
||||
// only to those who subscribe; the page used to reload the whole list after each event.
|
||||
else if(ev.ev==='feed_row') patchFeed(ev.row);
|
||||
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
|
||||
// Only a scan that checked something: the scheduler scans every minute, due or not, and
|
||||
// every open page reloaded the whole list each time (#103).
|
||||
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
|
||||
@@ -73,4 +81,4 @@ function connect(){
|
||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||
}
|
||||
connect();
|
||||
loadFeeds().catch(()=>{});
|
||||
loadFeeds().then(preloadLists).catch(()=>{});
|
||||
|
||||
@@ -5,11 +5,13 @@ function renderFeed(){
|
||||
box.classList.remove('plain');
|
||||
const v=VIEWS[S.feed ?? ''];
|
||||
if(v&&v.url) return renderListed(v);
|
||||
if(S.feed===SIGNALS) return renderSignals();
|
||||
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||
const sg=signalOf(S.feed);
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=!f;
|
||||
syncTools(null);
|
||||
if(!v&&!f){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||
const name=f?(f.title||f.id):v.title;
|
||||
if(!v&&!f&&!sg){ box.innerHTML='<p class="empty">Add a feed to get started.</p>'; $('#count').textContent=''; return; }
|
||||
const name=f?(f.title||f.id):sg?sg.name:v.title;
|
||||
$<HTMLInputElement>('#epSearch').placeholder=`Search ${name}…`;
|
||||
const kids=f?S.feeds.filter(c=>c.group===f.id):[];
|
||||
if(kids.length){ box.classList.add('plain'); renderGroup(f!,kids); return; }
|
||||
@@ -36,7 +38,7 @@ function renderFeed(){
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||
</div>
|
||||
</div>` : `
|
||||
</div>` : sg ? signalHead(sg) : `
|
||||
<div class="fhead slim">
|
||||
<div class="art">${v.icon}</div>
|
||||
<div class="meta">
|
||||
@@ -69,7 +71,8 @@ function renderFeed(){
|
||||
S.sel=null;
|
||||
showDetail(null);
|
||||
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):allAction(b.dataset.a!));
|
||||
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a!,f):sg?signalAction(b.dataset.a!,sg):allAction(b.dataset.a!));
|
||||
$$('#content .tabs button').forEach(b=>b.onpointerenter=b.ontouchstart=()=>{ if(S.feed) prefetch(S.feed,b.dataset.f!); });
|
||||
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||
S.filter=b.dataset.f!; S.offset=0;
|
||||
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||
|
||||
@@ -9,7 +9,8 @@ function patchFeed(row: Feed){
|
||||
}
|
||||
|
||||
async function loadFeeds(keepSel?: boolean){
|
||||
S.feeds = await api('/api/feeds');
|
||||
// A Signal list that fails leaves the feeds to show.
|
||||
[S.feeds, S.signals] = await Promise.all([api('/api/feeds'), api('/api/signals').catch((): Signal[]=>[])]);
|
||||
// Only for a hint in a feed's settings, so once, on the page's first load, not on every reload
|
||||
// of the list (#103).
|
||||
if(!keepSel) api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
|
||||
@@ -18,7 +19,7 @@ async function loadFeeds(keepSel?: boolean){
|
||||
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||
if(!keepSel && S.feeds.length){
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||
const known = S.feed && (VIEWS[S.feed] || S.feed===SIGNALS || signalOf(S.feed) || S.feeds.some(f=>f.id===S.feed));
|
||||
selectFeed(known ? S.feed! : ':all');
|
||||
}
|
||||
}
|
||||
@@ -99,9 +100,11 @@ function renderFeeds(){
|
||||
el.innerHTML=`<span class="ico">${v.icon}</span><b>${v.title}</b>`+(id===':all'
|
||||
?`<span class="badge${unreadAll?'':' zero'}" title="${unreadAll} unread">${unreadAll>999?'999+':unreadAll}</span>`:'');
|
||||
el.onclick=()=>{ selectFeed(id); nav(false); };
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(id);
|
||||
places.appendChild(el);
|
||||
}
|
||||
list.appendChild(places);
|
||||
signalFolder(list,q);
|
||||
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
|
||||
if(!shown.length){ list.insertAdjacentHTML('beforeend','<p class="empty" style="padding:20px 8px">No feeds.</p>'); return done(); }
|
||||
|
||||
@@ -160,6 +163,8 @@ function renderFeeds(){
|
||||
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||
// Fetched as the pointer or a finger reaches it, so the click finds it here (#247).
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(f.id);
|
||||
if(kids) $('.chev',el).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||
for(const img of $$('img',el)){ const was=drawn.get(img.outerHTML)?.shift(); if(was) img.replaceWith(was); }
|
||||
list.appendChild(el);
|
||||
|
||||
118
web/src/items.ts
118
web/src/items.ts
@@ -1,16 +1,103 @@
|
||||
/* ---------------- items ---------------- */
|
||||
let loadSeq=0;
|
||||
async function loadEntries(append?: boolean){
|
||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
||||
if(!S.feed || VIEWS[S.feed]?.url) return;
|
||||
const p=new URLSearchParams({offset:String(S.offset),limit:String(LIMIT),filter:S.filter,sort:S.sort.col,dir:S.sort.dir});
|
||||
type ListPage={total: number, entries: Entry[]};
|
||||
/// The first page of each list opened or fetched ahead, by what it shows, so a list seen before is
|
||||
/// drawn the moment it is asked for and refreshed behind it (#247). The newest few dozen: a page is
|
||||
/// 50 items, about 100 KB as JSON.
|
||||
const lists=new Map<string,ListPage>();
|
||||
const LISTS_KEPT=40;
|
||||
/// Lists on their way, so a click on one already being fetched ahead waits for that answer.
|
||||
const fetching=new Map<string,Promise<ListPage>>();
|
||||
const listKey=(feed: string, filter=S.filter)=>[feed,filter,S.sort.col,S.sort.dir,S.q].join('\n');
|
||||
function listUrl(feed: string, filter=S.filter, offset=0){
|
||||
const p=new URLSearchParams({offset:String(offset),limit:String(LIMIT),filter,sort:S.sort.col,dir:S.sort.dir});
|
||||
if(S.q) p.set('q',S.q);
|
||||
const asked=performance.now(), seq=++loadSeq;
|
||||
const r: {total: number, entries: Entry[]}=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||
const sg=signalId(feed);
|
||||
return feed===':all' ? `/api/entries?${p}`
|
||||
: sg!==null ? `/api/signals/${sg}/entries?${p}`
|
||||
: `/api/feeds/${encodeURIComponent(feed)}/entries?${p}`;
|
||||
}
|
||||
function keepList(key: string, r: ListPage){
|
||||
lists.delete(key); lists.set(key,r);
|
||||
if(lists.size>LISTS_KEPT) lists.delete(lists.keys().next().value!);
|
||||
}
|
||||
/// Fetches a list's first page ahead of a click: when the page has loaded, and when the pointer
|
||||
/// reaches a feed or a filter. Not one kept already or on its way.
|
||||
function prefetch(feed: string, filter=S.filter){
|
||||
// Currently Listening and the Directory draw from their own address; a folder's page lists its
|
||||
// feeds, not items.
|
||||
const url=VIEWS[feed]?.url;
|
||||
if(url) return prefetchPage(url);
|
||||
if(S.feeds.some(c=>c.group===feed)) return;
|
||||
const key=listKey(feed,filter);
|
||||
if(lists.has(key)||fetching.has(key)) return;
|
||||
const got: Promise<ListPage>=api(listUrl(feed,filter));
|
||||
fetching.set(key,got);
|
||||
got.then(r=>keepList(key,r),()=>{}).finally(()=>fetching.delete(key));
|
||||
}
|
||||
/// Other answers a view draws from, kept by address as the lists are (#248): Currently Listening
|
||||
/// and the Directory's catalogue. A request already on its way is shared, not sent twice.
|
||||
const pages=new Map<string,any>();
|
||||
const pageFetching=new Map<string,Promise<any>>();
|
||||
function fetchPage(url: string): Promise<any>{
|
||||
const on=pageFetching.get(url); if(on) return on;
|
||||
const got=api(url).then(r=>{ pages.delete(url); pages.set(url,r); if(pages.size>LISTS_KEPT) pages.delete(pages.keys().next().value!); return r; });
|
||||
pageFetching.set(url,got);
|
||||
got.finally(()=>pageFetching.delete(url)).catch(()=>{});
|
||||
return got;
|
||||
}
|
||||
const prefetchPage=(url: string)=>{ if(!pages.has(url)) fetchPage(url).catch(()=>{}); };
|
||||
|
||||
/// A feed changed (a scan, a read elsewhere): its kept lists, and every one of All Subscriptions,
|
||||
/// are fetched again when next wanted.
|
||||
function forgetLists(feed: string){
|
||||
// A Signal holds items of any feed.
|
||||
for(const k of [...lists.keys()]) if(k.startsWith(feed+'\n')||k.startsWith(':all\n')||k.startsWith(':signal:')) lists.delete(k);
|
||||
}
|
||||
/// What is likely to be opened next, fetched two at a time once the page is idle: All Subscriptions
|
||||
/// and the feeds with something unread, most first.
|
||||
function preloadLists(){
|
||||
const want=[':all',':listening',':directory',...[...S.feeds].filter(f=>f.unread>0&&!f.group).sort((a,b)=>b.unread-a.unread).slice(0,20).map(f=>f.id)];
|
||||
const next=()=>{
|
||||
const f=want.shift(); if(!f) return;
|
||||
prefetch(f);
|
||||
const url=VIEWS[f]?.url, on=url ? pageFetching.get(url) : fetching.get(listKey(f));
|
||||
// On to the next when this one is in, failed or not: a failure here is nobody's to see.
|
||||
if(on) on.then(next,next); else next();
|
||||
};
|
||||
const go=()=>{ next(); next(); };
|
||||
'requestIdleCallback' in window ? (window as any).requestIdleCallback(go,{timeout:2000}) : setTimeout(go,300);
|
||||
}
|
||||
|
||||
/// `quiet` for a refresh nobody asked for (a live update): not a wait anyone sees, so not timed.
|
||||
async function loadEntries(append?: boolean, quiet?: boolean){
|
||||
// The Directory lists feeds, and Currently Listening draws its own episodes.
|
||||
if(!S.feed || VIEWS[S.feed]?.url || S.feed===SIGNALS) return;
|
||||
const feed=S.feed, key=listKey(feed), asked=performance.now(), seq=++loadSeq;
|
||||
// From asking to the list drawn: what someone waits for after a click, under that click's trace
|
||||
// (#246). Once, when the list first shows: from what is kept, at once.
|
||||
let timed=quiet;
|
||||
const shown=()=>{ if(!timed){ timed=true; report('list_shown',{ms:Math.round(performance.now()-asked),feed,filter:S.filter}); } };
|
||||
const kept=append ? undefined : lists.get(key);
|
||||
let spin=0;
|
||||
if(kept){ showList(kept,false,asked); shown(); }
|
||||
// Nothing to show yet: a spinner, if the answer is not back in a blink.
|
||||
else if(!append&&!quiet) spin=setTimeout(()=>{ if(seq===loadSeq) listLoading(); },120);
|
||||
let r: ListPage;
|
||||
try{
|
||||
r=await (!append&&!kept&&fetching.get(key) || api(listUrl(feed,S.filter,S.offset)));
|
||||
}finally{ clearTimeout(spin); }
|
||||
// Only the latest list counts: marking everything read reloads the All tab, and clicking
|
||||
// Unread straight after could have that answer land last and replace the Unread one.
|
||||
if(seq!==loadSeq) return;
|
||||
if(!append) keepList(key,r);
|
||||
// The kept one was this already: drawn again it only flickers.
|
||||
if(kept&&JSON.stringify(kept)===JSON.stringify(r)) return;
|
||||
showList(r,!!append,asked);
|
||||
shown();
|
||||
}
|
||||
/// A list's page into S and onto the screen.
|
||||
function showList(r: ListPage, append: boolean, asked: number){
|
||||
S.total=r.total;
|
||||
for(const e of r.entries){
|
||||
const w=readWrites.get(readKey(e));
|
||||
@@ -30,17 +117,24 @@ async function loadEntries(append?: boolean){
|
||||
S.entries = entries;
|
||||
renderEntries();
|
||||
}
|
||||
/// A list being waited for: said with a spinner, so the click is seen to have been heard.
|
||||
function listLoading(box=$('#eps')){
|
||||
if(!box) return;
|
||||
box.innerHTML='<p class="listload" role="status">Loading…</p>';
|
||||
}
|
||||
function renderEntries(){
|
||||
const box=$('#eps'); if(!box) return;
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''];
|
||||
const unread=f?f.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
const f=S.feeds.find(x=>x.id===S.feed), v=VIEWS[S.feed ?? ''], sg=signalOf(S.feed);
|
||||
const unread=f?f.unread:sg?sg.unread:S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||
$('#count').textContent=
|
||||
`${f?(f.title||f.id):v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
`${f?(f.title||f.id):sg?sg.name:v?v.title:''}: ${S.total} item${S.total===1?'':'s'}, ${unread} unread`;
|
||||
const pane=$('#list'), top=pane?pane.scrollTop:0;
|
||||
box.innerHTML='';
|
||||
if(!S.entries.length){
|
||||
// Said only for a search, where the words typed are why; an empty feed or tab speaks for itself.
|
||||
if(S.q) box.innerHTML='<p class="empty">Nothing matches that search.</p>';
|
||||
// A new Signal is empty while Jev reads, which would otherwise look broken.
|
||||
else if(sg&&S.filter==='all') box.innerHTML='<p class="empty">Nothing has come through yet. New items are read as your feeds are checked.</p>';
|
||||
return;
|
||||
}
|
||||
for(const e of S.entries) box.appendChild(epEl(e));
|
||||
@@ -176,10 +270,12 @@ const readWrites=new Map<string, {read: boolean, done?: number}>();
|
||||
const readKey=(e: Entry)=>e.feed_id+'\n'+e.guid;
|
||||
function setRead(e: Entry,read: boolean){
|
||||
e.read=read;
|
||||
// Kept lists of its feed hold their own copy of it, still as it was.
|
||||
forgetLists(e.feed_id);
|
||||
const w: {read: boolean, done?: number}={read}; readWrites.set(readKey(e),w);
|
||||
return api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||
{method:'POST',body:JSON.stringify({read})})
|
||||
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); });
|
||||
.then(row=>{ w.done=performance.now(); if(row) patchFeed(row); if(S.signals.length) refreshSignals(); });
|
||||
}
|
||||
|
||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||
|
||||
@@ -164,13 +164,13 @@ $<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)au
|
||||
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
||||
$<HTMLInputElement>('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)};
|
||||
/// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the
|
||||
/// icon's shape and aria-pressed, never a colour.
|
||||
/// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour.
|
||||
function paintDead(){
|
||||
const b=$<HTMLButtonElement>('#pdead'), f=S.feeds.find(x=>x.id===player.feed);
|
||||
b.hidden=!f||document.body.classList.contains('has-video');
|
||||
if(!f||b.hidden) return;
|
||||
b.setAttribute('aria-pressed',String(!!f.deadair));
|
||||
b.innerHTML=f.deadair?ICON.deadairOn:ICON.deadair;
|
||||
b.innerHTML=tile(f.deadair?'deadair-on':'deadair-off');
|
||||
b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show';
|
||||
}
|
||||
$<HTMLButtonElement>('#pdead').onclick=async()=>{
|
||||
|
||||
288
web/src/signals.ts
Normal file
288
web/src/signals.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/* ---------------- signals ---------------- */
|
||||
/// Signals (#254): feeds you make from your own subscriptions, holding the items TypeSafe's Jev
|
||||
/// says are about the keywords you gave. A Signal is a place in the sidebar, `:signal:<id>`.
|
||||
|
||||
/// The Signals' counts again, which nothing else brings: reading an item, here or in its feed,
|
||||
/// and Jev picking one out both change them (#258). Once a burst has passed.
|
||||
const refreshSignals=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
|
||||
api('/api/signals').then((r: Signal[])=>{ S.signals=r; renderFeeds(); }).catch(()=>{});
|
||||
},400); }; })();
|
||||
|
||||
/// The Signal's id in a place's id, or null for anything else.
|
||||
function signalId(view: string|null){
|
||||
const m=/^:signal:(\d+)$/.exec(view||'');
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
function signalOf(view: string|null){
|
||||
const id=signalId(view);
|
||||
return id===null ? undefined : S.signals.find(s=>s.id===id);
|
||||
}
|
||||
|
||||
/// The Signals folder: first in the feed list, under All Subscriptions, open or closed as an OPML's
|
||||
/// is (#255, #256). As rows under the places, a long list of them pushed the feeds down.
|
||||
const SIGNALS=':signals';
|
||||
const sigArt=()=>`<span class="fart"><div class="art">${tile('signals')}</div></span>`;
|
||||
function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
||||
const el=document.createElement('div');
|
||||
el.className='feed '+cls+(S.feed===id?' sel':'');
|
||||
el.tabIndex=0; el.dataset.id=id; el.innerHTML=html;
|
||||
el.onclick=()=>{ open(); nav(false); };
|
||||
return el;
|
||||
}
|
||||
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
|
||||
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
|
||||
/// takes nothing else either).
|
||||
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
|
||||
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
|
||||
/// Every Signal in the accent tint the folder has, icon or initials: tinted by a hash of its name,
|
||||
/// as a feed without art is, one came out a colour of its own for no reason anyone could see (#262).
|
||||
function signalTile(sg: {name: string, icon: string|null}){
|
||||
const inner=sg.icon&&ICON_ID.test(sg.icon) ? iconHTML(sg.icon) : esc(initials(sg.name));
|
||||
return `<div class="art ini sig" style="--tint:var(--accent)">${inner}</div>`;
|
||||
}
|
||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||
function signalFolder(list: HTMLElement, q: string){
|
||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||
if(q&&!mine.length) return;
|
||||
const open=expanded.has(SIGNALS)||!!q;
|
||||
const n=S.signals.length;
|
||||
list.appendChild(feedRow(SIGNALS,'sigs',
|
||||
`<button class="chev" aria-expanded="${open}" title="Show or hide your signals" aria-label="Show or hide your signals">${ICON.caret}</button>`+
|
||||
sigArt()+`<div class="txt"><b>Signals</b><small>${n?plural(n,'signal'):'What matters, out of the noise'}</small></div>`+
|
||||
// No + of its own (#265): a row like any folder's. New signal is on the folder's page.
|
||||
badgeHTML(S.signals.reduce((k,s)=>k+s.unread,0)),
|
||||
()=>selectFeed(SIGNALS)));
|
||||
const row=list.lastElementChild!;
|
||||
$('.chev',row).onclick=(ev: Event)=>{ ev.stopPropagation(); toggleGroup(SIGNALS); };
|
||||
if(!open) return;
|
||||
for(const sg of mine){
|
||||
const el=feedRow(':signal:'+sg.id,'child',
|
||||
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${signalCounts(sg)}</small></div>`+badgeHTML(sg.unread),
|
||||
()=>selectFeed(':signal:'+sg.id));
|
||||
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder's page: every Signal, as an OPML's page lists its feeds.
|
||||
function renderSignals(){
|
||||
const unread=S.signals.reduce((k,s)=>k+s.unread,0);
|
||||
$('#count').textContent=`Signals: ${plural(S.signals.length,'signal')}, ${unread} unread`;
|
||||
$<HTMLButtonElement>('#tbRemove').disabled=true;
|
||||
syncTools(null);
|
||||
const box=$('#content');
|
||||
box.classList.add('plain');
|
||||
box.innerHTML=`
|
||||
<div class="fhead slim">
|
||||
<div class="art">${tile('signals')}</div>
|
||||
<div class="meta">
|
||||
<h2>Signals</h2>
|
||||
<div class="sub">Each one picks what matters out of the noise of your ${plural(S.feeds.length,'feed')} · ${unread} unread</div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn ico" data-a="new" title="New signal" aria-label="New signal">${ICON.plus}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="childlist" id="siglist"></div>`;
|
||||
$<HTMLButtonElement>('#content .acts [data-a="new"]').onclick=()=>signalModal();
|
||||
const list=$('#siglist');
|
||||
if(!S.signals.length){
|
||||
list.innerHTML='<p class="empty">No signals yet. Make one to pull a subject out of every feed you subscribe to.</p>';
|
||||
return;
|
||||
}
|
||||
for(const sg of S.signals){
|
||||
const el=document.createElement('div');
|
||||
el.className='childrow';
|
||||
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||
`<small class="meta">${signalCounts(sg)}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
||||
list.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function signalHead(sg: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
return `
|
||||
<div class="fhead slim">
|
||||
${signalTile(sg)}
|
||||
<div class="meta">
|
||||
<h2>${esc(sg.name)}</h2>
|
||||
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
||||
${nokey?`<div class="sub" style="color:var(--warn)">No TypeSafe key yet, so nothing is being picked out. ${KEY_LINKS}</div>`:''}
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn ico" data-a="settings" title="Edit this signal" aria-label="Edit this signal">${ICON.settings}</button>
|
||||
<button class="btn ico danger" data-a="rm" title="Delete this signal" aria-label="Delete this signal">${ICON.trash}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function signalAction(a: string, sg: Signal){
|
||||
if(a==='settings') signalModal(sg);
|
||||
if(a==='rm'){
|
||||
if(!confirm(`Delete the signal ${sg.name}? The items stay in their own feeds.`)) return;
|
||||
try{
|
||||
await api(`/api/signals/${sg.id}`,{method:'DELETE'});
|
||||
toast('Deleted'); await loadFeeds(true); selectFeed(':all');
|
||||
}catch(e){ toast(why(e),true); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Making a Signal, or changing one.
|
||||
function signalModal(sg?: Signal){
|
||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||
<div class="field"><label for="sgname">Name</label>
|
||||
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||
aria-expanded="false"></button>
|
||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||
<div id="sgpicker" hidden>
|
||||
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
||||
<button type="button" class="dlink" id="sgjev" title="Pick an icon for me">Auto</button>
|
||||
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||
</div></div>
|
||||
<div class="field"><label for="sgkwin">Listen for</label>
|
||||
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
||||
<input type="text" id="sgdesc" value="${esc(sg?.description||'')}" placeholder="AI tools for writing software: coding agents, vibe coding">
|
||||
<span class="hint">Every item in the feeds you subscribe to is read, the last 30 days of them
|
||||
and each new one, and the ones about this subject are pulled out: its name, these words and
|
||||
your sentence together. The rest stays noise, in its own feed. Each item is one call on your
|
||||
TypeSafe key.${sg?' A change here reads everything again.':''}</span></div>
|
||||
${nokey?`<p class="hint" style="color:var(--warn)">Without a TypeSafe key, nothing is picked out. ${KEY_LINKS}</p>`:''}
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||
wireChips($('#sgkw'));
|
||||
let icon=sg?.icon??null;
|
||||
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
|
||||
drawPick();
|
||||
$<HTMLInputElement>('#sgname').oninput=drawPick;
|
||||
const grid=$('#sgicons');
|
||||
const drawGrid=async()=>{
|
||||
const all=await iconList();
|
||||
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
|
||||
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
|
||||
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
|
||||
: '<p class="hint">No icon matches that.</p>';
|
||||
};
|
||||
$<HTMLButtonElement>('#sgicon').onclick=()=>{
|
||||
const p=$('#sgpicker'), open=p.hidden;
|
||||
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
|
||||
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
|
||||
};
|
||||
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
|
||||
grid.onclick=(e: MouseEvent)=>{
|
||||
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
|
||||
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
||||
};
|
||||
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
||||
$<HTMLButtonElement>('#sgjev').onclick=async()=>{
|
||||
// A spinner while it is picked: what picks it is nobody's concern.
|
||||
const b=$<HTMLButtonElement>('#sgjev'); b.disabled=true; b.classList.add('busy'); b.setAttribute('aria-busy','true');
|
||||
try{
|
||||
const got=await jevIcon($<HTMLInputElement>('#sgname').value.trim(),chipWords($('#sgkw')).filter((w): w is string=>!!w),
|
||||
$<HTMLInputElement>('#sgdesc').value.trim()||null);
|
||||
if(got){ icon=got; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false'); }
|
||||
else toast('No icon matches those words');
|
||||
}catch(e){ toast(why(e),true); }
|
||||
finally{ b.disabled=false; b.classList.remove('busy'); b.removeAttribute('aria-busy'); }
|
||||
};
|
||||
$<HTMLInputElement>('#sgname').focus();
|
||||
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
||||
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
||||
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
||||
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
||||
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
|
||||
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
|
||||
// a new icon alone did that too while the dialog sent them all back each time.
|
||||
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
|
||||
try{
|
||||
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
||||
: await api('/api/signals',{method:'POST',body});
|
||||
// A new one made without an icon gets Jev's pick, behind, so making it does not wait (#263).
|
||||
if(!sg&&!icon) jevIcon(name,keywords,description)
|
||||
.then(got=>{ if(got) return api(`/api/signals/${r.id}`,{method:'PATCH',body:JSON.stringify({icon:got})}).then(refreshSignals); })
|
||||
.catch(()=>{});
|
||||
closeModal(); toast(sg?'Saved. Everything is read again':'Listening. What comes through shows up here');
|
||||
// Open, so the new one is there to see.
|
||||
if(!expanded.has(SIGNALS)) toggleGroup(SIGNALS);
|
||||
await loadFeeds(true); selectFeed(':signal:'+(sg?sg.id:r.id));
|
||||
}catch(e){ toast(why(e),true); }
|
||||
};
|
||||
}
|
||||
|
||||
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
|
||||
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
|
||||
const out: Record<string, unknown>={};
|
||||
if(now.name!==sg.name) out.name=now.name;
|
||||
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
|
||||
if(now.description!==sg.description) out.description=now.description;
|
||||
if(now.icon!==sg.icon) out.icon=now.icon;
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
|
||||
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
|
||||
const ICON_CELLS=120;
|
||||
let icons: Promise<[string,string,string][]>|null=null;
|
||||
function iconList(){
|
||||
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
|
||||
return icons!;
|
||||
}
|
||||
/// The icons with a word starting with each word typed, in their name, label or search terms. The
|
||||
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
|
||||
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
|
||||
/// cart (#261). At most `max`; nothing typed is the first `max`.
|
||||
function iconMatches(all: [string,string,string][], q: string, max: number){
|
||||
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if(!words.length) return all.slice(0,max);
|
||||
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||
const rank=([id,label,terms]: [string,string,string])=>{
|
||||
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
|
||||
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
|
||||
const w=words[0];
|
||||
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
|
||||
};
|
||||
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
||||
}
|
||||
|
||||
/// "12 items · 3 unread", as a feed's row counts its own.
|
||||
const signalCounts=(sg: Signal)=>`${plural(sg.items,'item')} · ${sg.unread} unread`;
|
||||
|
||||
/// The icons likeliest for a Signal: the best few for each word of its name, words and sentence,
|
||||
/// at most `max`. Jev picks from these, since it chooses among options and cannot name one.
|
||||
const ICON_STOP=new Set(['the','and','for','not','with','about','their','own','from','into','that','this','your']);
|
||||
function iconCandidates(all: [string,string,string][], name: string, keywords: string[], description: string|null, max=40){
|
||||
const words=[name,...keywords,description||''].join(' ').toLowerCase().split(/[^a-z0-9]+/).filter(w=>w.length>1&&!ICON_STOP.has(w));
|
||||
const out=new Map<string,string>();
|
||||
// A brand's icon stands for that brand, so it is offered only when its name is one of the
|
||||
// Signal's words: by its search terms, "disney" brought the Mandalorian, a Disney+ show (#264).
|
||||
const brandFor=(id: string, w: string)=>!id.startsWith('brands/')||id.slice(7).split('-').includes(w);
|
||||
for(const w of [...new Set(words)])
|
||||
for(const [id,label,terms] of iconMatches(all.filter(([id])=>brandFor(id,w)),w,6))
|
||||
if(out.size<max) out.set(id,`${label}: ${terms.split(' ').slice(0,8).join(', ')}`);
|
||||
return [...out];
|
||||
}
|
||||
/// Jev's pick of an icon, on the server's TypeSafe key, or null with nothing to choose from.
|
||||
async function jevIcon(name: string, keywords: string[], description: string|null): Promise<string|null>{
|
||||
const candidates=iconCandidates(await iconList(),name,keywords,description);
|
||||
if(!candidates.length) return null;
|
||||
const r=await api('/api/signals/icon',{method:'POST',body:JSON.stringify({name,keywords,description,candidates})});
|
||||
return r.icon;
|
||||
}
|
||||
|
||||
/// Where to get a TypeSafe key and where it goes, wherever a Signal says it has none: one click to
|
||||
/// each, rather than "in Settings" left to be found.
|
||||
const KEY_LINKS=`<a href="https://typesafe.ai" target="_blank" rel="noopener">Get one at typesafe.ai</a>, then
|
||||
<button type="button" class="dlink" data-keylink>add it in Settings</button>.`;
|
||||
/// Settings, open at the key's field.
|
||||
async function keySetting(){
|
||||
closeModal();
|
||||
await prefsModal();
|
||||
const k=$<HTMLInputElement>('#stskey'); k.scrollIntoView({block:'center'}); k.focus();
|
||||
}
|
||||
document.addEventListener('click',(e: MouseEvent)=>{
|
||||
if(!(e.target as Element).closest?.('[data-keylink]')) return;
|
||||
e.preventDefault(); keySetting().catch(err=>toast(why(err),true));
|
||||
});
|
||||
@@ -82,6 +82,22 @@ interface Me {
|
||||
deadair_saved: number;
|
||||
theme: {name: string, mode: string} | null;
|
||||
version: string;
|
||||
/// A TypeSafe key is saved for Signals; the key itself never comes back.
|
||||
typesafe_key_set: boolean;
|
||||
}
|
||||
|
||||
/// A Signal in your sidebar, `/api/signals` (db.rs SignalRow).
|
||||
interface Signal {
|
||||
id: number;
|
||||
name: string;
|
||||
keywords: string[];
|
||||
/// What its owner means by it, in their words (#259).
|
||||
description: string | null;
|
||||
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
||||
icon: string | null;
|
||||
/// The items it holds, read or not, and of those the unread.
|
||||
items: number;
|
||||
unread: number;
|
||||
}
|
||||
|
||||
/// A feed as the Directory lists it (web.rs PopularRow): never its address.
|
||||
@@ -101,6 +117,7 @@ interface DirFeed {
|
||||
/// The page's own state (util.ts `S`).
|
||||
interface State {
|
||||
feeds: Feed[];
|
||||
signals: Signal[];
|
||||
/// The feed or place open: a feed's id, or a view such as ':all'.
|
||||
feed: string | null;
|
||||
entries: Entry[];
|
||||
|
||||
@@ -12,6 +12,8 @@ const esc = (s: string|null|undefined) => (s??'').replace(/[&<>"']/g,c=>ESCAPED[
|
||||
// ones used, so there is no font to download and nothing is fetched from anyone else. Each takes
|
||||
// the button's own colour. To add one, copy the path from svgs/<style>/<name>.svg at the same tag.
|
||||
const fa=(box: string,body: string)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||
/// An app-icon tile (web/signals.svg, deadair-on.svg, ...) in the mode's own variant, as the logo is.
|
||||
const tile=(name: string)=>`<img class="tile onlight" src="/${name}.svg" alt=""><img class="tile ondark" src="/${name}-dark.svg" alt="">`;
|
||||
const ICON: Record<string,string>={
|
||||
share:fa('0 0 512 512','<path fill="currentColor" d="M384 192c53 0 96-43 96-96s-43-96-96-96-96 43-96 96c0 5.4 .5 10.8 1.3 16L159.6 184.1c-16.9-15-39.2-24.1-63.6-24.1-53 0-96 43-96 96s43 96 96 96c24.4 0 46.6-9.1 63.6-24.1L289.3 400c-.9 5.2-1.3 10.5-1.3 16 0 53 43 96 96 96s96-43 96-96-43-96-96-96c-24.4 0-46.6 9.1-63.6 24.1L190.7 272c.9-5.2 1.3-10.5 1.3-16s-.5-10.8-1.3-16l129.7-72.1c16.9 15 39.2 24.1 63.6 24.1z"/>'), // solid/share-nodes
|
||||
plus:fa('0 0 448 512','<path fill="currentColor" d="M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"/>'), // solid/plus
|
||||
@@ -24,10 +26,6 @@ const ICON: Record<string,string>={
|
||||
// for the outline's stroke, so the two draw the same size.
|
||||
pin:fa('-18 -18 420 548','<path fill="none" stroke="currentColor" stroke-width="36" stroke-linejoin="round" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack, outlined
|
||||
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||
// DeadAir (#171): a waveform. Off for this show, outlined with the silence still in its
|
||||
// middle; on, solid with the gap closed up. Padded like pin for the outline's stroke.
|
||||
deadair:fa('-16 -16 544 544','<g fill="none" stroke="currentColor" stroke-width="24"><rect x="14" y="200" width="44" height="112" rx="22"/><rect x="84" y="136" width="44" height="240" rx="22"/><rect x="154" y="96" width="44" height="320" rx="22"/><rect x="314" y="96" width="44" height="320" rx="22"/><rect x="384" y="136" width="44" height="240" rx="22"/><rect x="454" y="200" width="44" height="112" rx="22"/></g>'),
|
||||
deadairOn:fa('-16 -16 544 544','<g fill="currentColor"><rect x="59" y="200" width="44" height="112" rx="22"/><rect x="129" y="136" width="44" height="240" rx="22"/><rect x="199" y="96" width="44" height="320" rx="22"/><rect x="269" y="96" width="44" height="320" rx="22"/><rect x="339" y="136" width="44" height="240" rx="22"/><rect x="409" y="200" width="44" height="112" rx="22"/></g>'),
|
||||
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||
@@ -93,8 +91,9 @@ function traceparent(){
|
||||
/// a frame.
|
||||
const reported=new Map<string,number>();
|
||||
function report(ev: string, o: Record<string,any>={}){
|
||||
// A timing comes of something someone did, not a loop, so every one is sent.
|
||||
const key=ev+(o.msg||''), now=Date.now();
|
||||
if(o.msg==='signed out' || now-(reported.get(key)||0)<60_000) return;
|
||||
if(o.msg==='signed out' || (o.ms==null && now-(reported.get(key)||0)<60_000)) return;
|
||||
reported.set(key,now);
|
||||
fetch('/api/client',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json',traceparent:traceparent()},
|
||||
body:JSON.stringify({ev,...o})}).catch(()=>{});
|
||||
@@ -217,7 +216,7 @@ function nav(on: boolean){ $('#sidebar').classList.toggle('open',on); $('#scrim'
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
const S: State = {
|
||||
feeds:[],
|
||||
feeds:[], signals:[],
|
||||
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||
// you were instead of jumping to the first feed alphabetically.
|
||||
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||
|
||||
Reference in New Issue
Block a user