Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d238681ec1 | |||
| 0395717c14 | |||
| 8ce68a881a | |||
| e7c59489ee | |||
| f7f4b466dc | |||
| ec8fd5dd86 | |||
| a387012c69 | |||
| a00516687a | |||
| 38ebc7b02b | |||
| 54d827f655 | |||
| e2593eaa50 | |||
| d52ecfcbd7 | |||
| 142610e8b8 | |||
| 43acc62259 | |||
| 65fdab8b13 | |||
| 4d312a87ec | |||
| ef5bdb4244 | |||
| c7f13eea2f | |||
| 7a134b810f | |||
| 79bc006a30 | |||
| 9084b61bb6 | |||
| 0cc956b7c6 | |||
| db87bc0f42 | |||
| 527777efdf | |||
| 4a26c73b82 | |||
| 98ed9b6498 | |||
| 57ab419718 | |||
| 2799704d30 | |||
| e8fd3fe9ed | |||
| 448e557272 | |||
| 36b16c7f5d | |||
| 928cbaf8f4 | |||
| 0945f3a9f4 | |||
| e9f2832e1e | |||
| d4e00be085 | |||
| 66e40e3c71 | |||
| 4f8b3d6a1d | |||
| 8ce0a4cb27 | |||
| 549ae33f06 | |||
| 9e7c93e149 | |||
| 1724346da7 | |||
| 8c5eddd783 | |||
| 469467bf04 | |||
| 95a8633877 | |||
| 00f6293b95 | |||
| bae22e553e | |||
| 7d55d99fac | |||
| a5de4ae06c | |||
| 4ed2d59311 | |||
| 2004da3459 | |||
| c6980c355d | |||
| ce221cee18 | |||
| f57f824535 | |||
| 7490a3a9ac | |||
| 5be629427a | |||
| bf785299b0 | |||
| 5967aa1e57 | |||
| 9e238bfc75 | |||
| 10b7ccd424 | |||
| d8db785681 | |||
| f9c9c2b7cc | |||
| 3cb36ab8b7 | |||
| 65b5eacdb4 | |||
| 4769a2ebe1 | |||
| 70e0341348 | |||
| e16dace9c0 | |||
| 9f56436033 | |||
| ae900b82ca | |||
| 40c92ad08d | |||
| 7796566dfc | |||
| 162665b010 | |||
| f2fde8cc75 | |||
| a573a07ed5 | |||
| 380a552913 | |||
| bdda9b3d2e | |||
| 868dd673f3 | |||
| 2930be37ad | |||
|
|
f9225f0d21 | ||
|
|
9d1492b388 | ||
| ad15ae3dfe | |||
| aedbe89654 | |||
| 321c9e014e | |||
| 73092e6ad7 | |||
| 1a3c8a6d4f | |||
| b81d44cfb6 | |||
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb | |||
| 680b5d4773 | |||
| 84e4b428e4 | |||
| 768d02c840 | |||
| b5ff57ac0f | |||
| 35b57fa997 | |||
| 43ffafe7ac | |||
| 45cbd3a239 | |||
| 905dfa0b02 | |||
| 5f6bdbfbcb | |||
| 8849ba6bef | |||
| 1cafd8d6e3 | |||
| f09bb4a11c | |||
| 973ebdd33a | |||
| bc7491a377 | |||
| c8df148546 | |||
| c1c06229c8 | |||
| bc53e0f730 | |||
| bd8f6ab855 | |||
| 611716d8b7 | |||
| a68bfb179b | |||
| 6bf1ad6b31 | |||
| d7f8f2df1d | |||
| 4927677e66 | |||
| 484aaa1849 | |||
| c99e17bd80 | |||
| e312f11bb1 | |||
| a465fa8471 | |||
| aeb686163b | |||
| 2d158a4540 | |||
| fc425bffa6 | |||
| 42a1136e2e | |||
| 53341264a7 | |||
| 9c16408d04 | |||
| 9b2537761f | |||
| 3b00e2721a | |||
| fc09e8a6b7 | |||
| 5483355021 | |||
| e2969bcee8 | |||
| 26802d2b23 | |||
| fbba447ca6 | |||
| d7a4a0b663 | |||
| 0443177471 | |||
| 0a83c716eb | |||
| b9e0d9f3cb | |||
| 2dee3b722c | |||
| 8daa7a1991 | |||
| f89ca2aceb | |||
| 57a6fb5daa | |||
| 49dafedbbc | |||
| b83523fc92 | |||
| 8ae5c332c3 | |||
| 420f9eb9a0 | |||
| 4314b11237 | |||
| eaea520020 | |||
| b8d22904f1 | |||
| cbd16ce3f6 | |||
| 017cfd7e28 | |||
| 954173cacf |
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(rtk grep *)",
|
|
||||||
"Bash(rtk read *)",
|
|
||||||
"Bash(rtk git *)"
|
|
||||||
],
|
|
||||||
"additionalDirectories": [
|
|
||||||
"/config/.claude/skills/security-audit",
|
|
||||||
"/config/security-audit-skill",
|
|
||||||
"/config/.cargo/registry"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
141
.claude/skills/ipx-prod-check/SKILL.md
Normal file
141
.claude/skills/ipx-prod-check/SKILL.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: ipx-prod-check
|
||||||
|
description: Look for problems in production ipx (the iPX container on Tower) from its logs in Loki and its traces in Tempo, and file what is found as Gitea issues. Use when asked to check on production, look for issues or errors in ipx, see why something is slow or failing in production, review the logs, or investigate a report about the live site.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checking production ipx
|
||||||
|
|
||||||
|
Production logs one JSON object a line (`IPX_LOG_FORMAT=json`), which Alloy ships to Loki under
|
||||||
|
`{container="iPX"}`, and sends traces to Tempo tagged
|
||||||
|
`resource.deployment.environment.name="production"`. Anything without that tag is a daemon run by
|
||||||
|
hand, not production. Loki and Tempo publish no query port, so use the script beside this file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
Q=.claude/skills/ipx-prod-check/query.py
|
||||||
|
$Q logs '<LogQL>' [since] # lines, newest first (500 at most)
|
||||||
|
$Q metric '<LogQL metric>' [since] # $range becomes `since`
|
||||||
|
$Q traces '<TraceQL>' [since] # slowest first
|
||||||
|
$Q trace <trace id> # one trace as a tree, with its log lines
|
||||||
|
```
|
||||||
|
|
||||||
|
`since` is `1h`, `24h`, `7d`. Default to `24h`; widen it to see whether something is new.
|
||||||
|
|
||||||
|
## What the log carries
|
||||||
|
|
||||||
|
Every line has `timestamp`, `level`, `message` and `target`; lines inside a span have `span` (the
|
||||||
|
innermost: `{"name":"feed","feed":"x"}`). Loki's `| json` flattens it to `span_name`, `span_feed`.
|
||||||
|
Lines inside a traced span, requests and scans, also carry `trace_id` and `span_id`: give the
|
||||||
|
`trace_id` to `$Q trace` to see the whole request or scan. (From 2026-09-29 16:30 UTC; before that,
|
||||||
|
lines had no trace id, the access log's time was `ms`, and each event was logged twice, words under
|
||||||
|
`ipx::scan` and fields under `ipx::io`.)
|
||||||
|
|
||||||
|
| target | fields | what |
|
||||||
|
|---|---|---|
|
||||||
|
| `ipx::http` | `method`, `path`, `route`, `status`, `duration_ms` | one per web request; `route` is the pattern, empty for an unrouted path |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
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
|
||||||
|
updated), `download_done` (bytes), `download_error` (msg, url),
|
||||||
|
`torrent_deferred`, `reaped`, `scan_done` (feeds checked), `reap_done`, `status` (feeds, pending,
|
||||||
|
downloaded: the healthcheck's, every 30s), `error` (msg).
|
||||||
|
|
||||||
|
`error.type` is the HTTP status (`404`, `503`) or one of `dns`, `redirect_loop`, `timeout`, `tls`,
|
||||||
|
`not_a_feed`, `site_message`, `connect`, `parse`, `other`; Loki's `| json` names it `error_type`.
|
||||||
|
|
||||||
|
Filter on the text before `| json` where you can (`|= "\"ev\":\"feed_error\""`): it is much
|
||||||
|
cheaper than parsing every line. Lines before 2026-09-29 14:00 UTC are text, not JSON, and
|
||||||
|
`| json | __error__=""` drops them.
|
||||||
|
|
||||||
|
## The checks
|
||||||
|
|
||||||
|
Run these, then read the lines behind whatever stands out. Most of the time is in the reading:
|
||||||
|
a count says something happened, the lines and traces say why.
|
||||||
|
|
||||||
|
1. **Warnings and errors, grouped.** What went wrong, how often, and since when.
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (target, message) (count_over_time({container="iPX"} | json | __error__="" | level=~"WARN|ERROR" [$range]))'
|
||||||
|
```
|
||||||
|
Feed and download failures name the feed in the message; group them in the next check instead.
|
||||||
|
2. **Failing feeds and downloads.**
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"feed_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
$Q metric 'sum by (feed, error_type) (count_over_time({container="iPX"} |= "\"ev\":\"download_error\"" | json | __error__="" [$range]))' 7d
|
||||||
|
```
|
||||||
|
Tell the publisher's problems from ipx's. A 404, 410, DNS failure or 503 from the feed's own
|
||||||
|
server is the publisher (worth saying, since the feed may have moved; one issue for a feed
|
||||||
|
that has been dead for days, not for a 503 once). A parse error on a feed that loads in a
|
||||||
|
browser, a redirect loop ipx should follow, or the same failure on many feeds at once is ipx.
|
||||||
|
3. **Server errors and slow requests.**
|
||||||
|
```
|
||||||
|
$Q metric 'sum by (method, route, status) (count_over_time({container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | status >= 500 [$range]))'
|
||||||
|
$Q metric 'topk(10, quantile_over_time(0.95, {container="iPX"} |= "\"target\":\"ipx::http\"" | json | __error__="" | route != "" | route != "/api/events" | unwrap duration_ms [$range]) by (method, route))'
|
||||||
|
```
|
||||||
|
Any 5xx is worth a look. 401s are people signing in, not a problem unless one address is
|
||||||
|
hammering. For a slow route, find its traces (check 5) and see which span holds the time.
|
||||||
|
4. **Is the worker keeping up?** Scans should finish regularly, the queue should drain, and the
|
||||||
|
daemon should not be restarting on its own.
|
||||||
|
```
|
||||||
|
$Q metric 'sum(count_over_time({container="iPX"} |= "\"ev\":\"scan_done\"" [$range]))' 6h
|
||||||
|
$Q logs '{container="iPX"} |= "\"ev\":\"status\"" | json | line_format "{{.timestamp}} pending={{.pending}} downloaded={{.downloaded}}"' 6h
|
||||||
|
$Q logs '{container="iPX"} |= "daemon started"' 7d
|
||||||
|
```
|
||||||
|
A `daemon started` not matched by a deploy (see `git log` and the image's build time) is a
|
||||||
|
crash or an OOM kill: check `docker inspect iPX -f '{{.State.OOMKilled}} {{.RestartCount}}'`
|
||||||
|
and the lines just before it. A pending count that only grows means downloads are not
|
||||||
|
keeping up or not running.
|
||||||
|
|
||||||
|
Since 2026-10-02 the daemon sleeps until the next feed is due (at most 10 minutes) instead of
|
||||||
|
scanning every minute (#114), so expect tens of scans in 6 hours, not 360, nearly all with
|
||||||
|
`feeds` above 0; none at all for over 10 minutes means the worker is stuck. And `pending` is
|
||||||
|
the real queue (#113): files a scan will download on its own. Back-catalogue files are
|
||||||
|
`held`, listed but not counted, so it is usually 0 or a handful.
|
||||||
|
5. **Slow and failed traces.**
|
||||||
|
```
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && duration > 5s}'
|
||||||
|
$Q traces '{resource.deployment.environment.name="production" && status = error}'
|
||||||
|
$Q trace <id>
|
||||||
|
```
|
||||||
|
Scans (`scan`) are long by nature, since they fetch many feeds one after another: look for one
|
||||||
|
`feed` or `fetch` span holding most of it, or a `download` far slower than its size explains.
|
||||||
|
A web request over a second is worth a look; the trace shows whether the time is in the
|
||||||
|
handler or a scan it waited on.
|
||||||
|
|
||||||
|
Also check the container itself, since Loki cannot see a daemon that is not running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker ps --filter name=iPX --format '{{.Status}}'
|
||||||
|
docker logs --since 10m iPX 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## What to do with what you find
|
||||||
|
|
||||||
|
Follow the repository's rules in CLAUDE.md: **every problem found gets a Gitea issue**, with a
|
||||||
|
closed stdin and a timeout on `tea`. Before filing, list the open issues and do not file one
|
||||||
|
twice; comment on the existing issue with the new evidence instead.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues list $R --state open
|
||||||
|
t issues create $R -t "<what is wrong, as the user would notice it>" -L bug -d "<what, where, since when, how often, the query or trace id that shows it>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Put in each issue what would let someone pick it up cold: the LogQL or TraceQL that shows it, a
|
||||||
|
trace id, the first time it was seen and how often. A publisher's dead feed is worth one issue
|
||||||
|
saying so (the user may want to unsubscribe or find its new address); a single 503 is not.
|
||||||
|
|
||||||
|
Finish with a short report to the user: what is healthy, what is wrong (with the issue numbers),
|
||||||
|
and anything you could not tell from logs and traces alone. Do not fix things unless asked; the
|
||||||
|
check is for finding them.
|
||||||
|
|
||||||
|
## When the checks come back empty
|
||||||
|
|
||||||
|
Check that there is data before concluding all is well: `$Q metric 'sum(count_over_time({container="iPX"} [1h]))' 1h`
|
||||||
|
should be in the hundreds or more. Nothing at all means Alloy is not shipping (it can take a few
|
||||||
|
minutes to pick up a container after a deploy), or the container is down.
|
||||||
89
.claude/skills/ipx-prod-check/query.py
Executable file
89
.claude/skills/ipx-prod-check/query.py
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ask production's Loki or Tempo a question, from anywhere that can run docker on Tower.
|
||||||
|
|
||||||
|
query.py logs '<LogQL log query>' [since] lines, newest first
|
||||||
|
query.py metric '<LogQL metric query>' [since] one value per series; $range is `since`
|
||||||
|
query.py traces '<TraceQL query>' [since] matching traces, slowest first
|
||||||
|
query.py trace <trace id> one trace's spans, as a tree
|
||||||
|
|
||||||
|
`since` is 1h, 24h, 7d and the like (default 24h). Loki and Tempo publish no query port on the
|
||||||
|
host, so each call runs a throwaway alpine container on the monitoring project's network.
|
||||||
|
"""
|
||||||
|
import json, subprocess, sys, time, urllib.parse
|
||||||
|
|
||||||
|
NET = "monitoring_default"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
out = subprocess.run(["docker", "run", "--rm", "--network", NET, "alpine", "wget", "-qO-", url],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
if out.returncode:
|
||||||
|
sys.exit(f"query failed: {out.stderr.strip() or out.stdout.strip()}\n{url}")
|
||||||
|
return json.loads(out.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds(since):
|
||||||
|
return int(since[:-1]) * {"m": 60, "h": 3600, "d": 86400}[since[-1]]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
kind, q = sys.argv[1], sys.argv[2]
|
||||||
|
since = sys.argv[3] if len(sys.argv) > 3 else "24h"
|
||||||
|
now = time.time()
|
||||||
|
start = now - seconds(since)
|
||||||
|
enc = urllib.parse.quote
|
||||||
|
if kind == "logs":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query_range?query={enc(q)}&limit=500"
|
||||||
|
f"&start={int(start * 1e9)}&end={int(now * 1e9)}&direction=backward")
|
||||||
|
lines = [(ts, line) for s in r["data"]["result"] for ts, line in s["values"]]
|
||||||
|
for ts, line in sorted(lines, reverse=True):
|
||||||
|
print(line)
|
||||||
|
print(f"-- {len(lines)} line(s){' (limit reached)' if len(lines) >= 500 else ''}", file=sys.stderr)
|
||||||
|
elif kind == "metric":
|
||||||
|
r = fetch(f"http://loki:3100/loki/api/v1/query?query={enc(q.replace('$range', since))}&time={int(now * 1e9)}")
|
||||||
|
rows = sorted(r["data"]["result"], key=lambda s: -float(s["value"][1]))
|
||||||
|
for s in rows:
|
||||||
|
labels = {k: v for k, v in s["metric"].items() if k not in ("container", "compose_project", "service_name")}
|
||||||
|
print(f"{s['value'][1]:>12} {json.dumps(labels) if labels else ''}")
|
||||||
|
print(f"-- {len(rows)} series", file=sys.stderr)
|
||||||
|
elif kind == "traces":
|
||||||
|
r = fetch(f"http://tempo:3200/api/search?q={enc(q)}&start={int(start)}&end={int(now)}&limit=100")
|
||||||
|
traces = sorted(r.get("traces", []), key=lambda t: -t.get("durationMs", 0))
|
||||||
|
for t in traces:
|
||||||
|
when = time.strftime("%m-%d %H:%M:%S", time.gmtime(int(t["startTimeUnixNano"]) / 1e9))
|
||||||
|
print(f"{t.get('durationMs', 0):>8}ms {when}Z {t['traceID']} {t.get('rootTraceName', '')}")
|
||||||
|
print(f"-- {len(traces)} trace(s)", file=sys.stderr)
|
||||||
|
elif kind == "trace":
|
||||||
|
r = fetch(f"http://tempo:3200/api/traces/{q}")
|
||||||
|
spans = [sp for b in r.get("batches", r.get("resourceSpans", []))
|
||||||
|
for ss in b.get("scopeSpans", b.get("instrumentationLibrarySpans", [])) for sp in ss["spans"]]
|
||||||
|
kids = {}
|
||||||
|
for sp in spans:
|
||||||
|
kids.setdefault(sp.get("parentSpanId", ""), []).append(sp)
|
||||||
|
|
||||||
|
def show(sp, depth):
|
||||||
|
ms = (int(sp["endTimeUnixNano"]) - int(sp["startTimeUnixNano"])) / 1e6
|
||||||
|
attrs = {a["key"]: next(iter(a["value"].values()), None) for a in sp.get("attributes", [])
|
||||||
|
if not a["key"].startswith(("code.", "thread.")) and a["key"] not in ("busy_ns", "idle_ns", "target")}
|
||||||
|
err = " ERROR" if sp.get("status", {}).get("code") in (2, "STATUS_CODE_ERROR") else ""
|
||||||
|
print(f"{' ' * depth}{sp['name']} {ms:.0f}ms{err} {json.dumps(attrs) if attrs else ''}")
|
||||||
|
for e in sp.get("events", []):
|
||||||
|
msg = next((a["value"].get("stringValue") for a in e.get("attributes", []) if a["key"] == "message"), e.get("name"))
|
||||||
|
# A scan logs a skip for every feed not due; they bury what happened.
|
||||||
|
if '"ev":"feed_skip"' in (msg or ""):
|
||||||
|
continue
|
||||||
|
print(f"{' ' * depth} - {msg}")
|
||||||
|
for k in sorted(kids.get(sp["spanId"], []), key=lambda s: int(s["startTimeUnixNano"])):
|
||||||
|
show(k, depth + 1)
|
||||||
|
|
||||||
|
ids = {sp["spanId"] for sp in spans}
|
||||||
|
for root in [sp for sp in spans if sp.get("parentSpanId", "") not in ids]:
|
||||||
|
show(root, 0)
|
||||||
|
else:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,3 +2,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/test-results
|
/test-results
|
||||||
/playwright-report
|
/playwright-report
|
||||||
|
/web/dist
|
||||||
|
|
||||||
|
.claude/settings.local.json
|
||||||
|
__pycache__/
|
||||||
|
|||||||
385
CHANGELOG.md
385
CHANGELOG.md
@@ -5,11 +5,358 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
The long form, with what was wrong before and how it was found, is in
|
|
||||||
[docs/history.md](docs/history.md).
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Artwork is kept on disk once shown and loads from iPX, not from each publisher's server: fast after the first time, and still there when the publisher's server is not. The admin page sets how much is kept (500 MB by default).
|
||||||
|
- `ipx add --list --category News <url>` puts a feed in the Directory for anyone to subscribe to,
|
||||||
|
and it stays there when its last subscriber leaves. Run for a feed already in the catalogue,
|
||||||
|
it lists it or sets its category.
|
||||||
|
- Feeds nobody subscribes to that are dead for a month or quiet for a year are removed, so the
|
||||||
|
Directory lists feeds worth taking.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- An item published without a title shows its opening words, in plain text rather than bold, instead of "(untitled)"; one with no text either shows its file's name, or its show and date. Opened, it starts with its text.
|
||||||
|
- A feed that has moved for good (a permanent redirect) is followed to its new address, which iPX then reads from, and says so in the log as `feed_moved`. A temporary redirect changes nothing.
|
||||||
|
- The daemon sleeps until the next feed is due, at most ten minutes, instead of looking every minute; refreshing or adding a feed still wakes it at once.
|
||||||
|
- A pinned feed's pin sits on the corner of its artwork, as a failing feed's mark does, instead of before its name.
|
||||||
|
- The Directory lists feeds nobody subscribes to yet; Popular still lists what people subscribe to.
|
||||||
|
A feed nobody subscribes to is checked once a day, and at once when someone subscribes. A
|
||||||
|
listed feed downloads nothing until someone subscribes.
|
||||||
|
- The Directory loads faster: it asked the database three questions per feed.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The download queue, in `ipx status`, `/api/status` and the dashboard, counts only what iPX will download on its own: older episodes beyond a show's limit, and files of feeds that do not download automatically, are listed but no longer counted as waiting.
|
||||||
|
- Artwork published on http is stored on https when its host serves it there, so the page loads it directly; the rest still comes through iPX.
|
||||||
|
- An iPhone adding the site to its home screen finds the icon at the first address it tries.
|
||||||
|
- A feed whose server hangs no longer holds up every scan: a feed gets 30 seconds, and connecting anywhere 10.
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-09-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `IPX_LOG_FORMAT=json` logs one JSON object a line, with a request's method, route, status and time
|
||||||
|
and a scan's or download's details as fields, for Loki and the like.
|
||||||
|
- A Grafana dashboard for iPX, from its log in Loki and its traces in Tempo (`grafana/dashboard.py`).
|
||||||
|
- With `OTEL_EXPORTER_OTLP_ENDPOINT` set, the daemon sends traces of its scans, downloads and web
|
||||||
|
requests to a collector such as Tempo.
|
||||||
|
- A feed that has no artwork of its own shows its website's icon instead.
|
||||||
|
- The refresh button turns while its feed is being checked, and the check-every-feed buttons
|
||||||
|
while any of yours is.
|
||||||
|
- Adding a website's address subscribes to the feed that site links, instead of failing on every scan.
|
||||||
|
- `/api/status` gives the number of feeds, items waiting to download and files downloaded, and
|
||||||
|
the version, for a dashboard such as Homepage.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The start-up line about the web UI being reachable off the machine says what guards it, and is a warning only when there is no Cloudflare Access in front of it.
|
||||||
|
- The feed list updates just the feed that changed, as a scan checks it or you read an item, instead of reloading the whole list.
|
||||||
|
- A scan fetches several feeds at once, so a refresh no longer waits on every site in turn.
|
||||||
|
- An open page reloads the feed list only when a scan has checked something, not every minute.
|
||||||
|
- Adding an address checks it first: a feed is added, a web page adds the feed it links, and anything else is refused with the reason, instead of being added and failing on every check.
|
||||||
|
- A feed that was failing when its last subscriber left is forgotten, rather than kept with its error for good. One that worked, or has files on disk, is kept as before.
|
||||||
|
- A feed that keeps failing is checked less and less often, waiting as long as it has been failing, up to once a day; it goes back to its schedule as soon as it works. Refreshing it still checks it at once.
|
||||||
|
- A scan's trace shows the time a feed spends on its artwork and in the database after the fetch.
|
||||||
|
- The JSON log carries each line's `trace_id` and `span_id`, logs each scan event once instead of
|
||||||
|
twice, names a failure's kind in `error.type` (and its HTTP status in
|
||||||
|
`http.response.status_code`), and calls a request's time `duration_ms` instead of `ms`.
|
||||||
|
- `ipx list` shows each feed's id on a line of its own, labelled, under its title.
|
||||||
|
- Each browser keeps its own theme, so a phone and a desktop can differ. A browser that has not
|
||||||
|
chosen one yet starts from the theme your account had.
|
||||||
|
- The Modern theme takes its colours from the new logo: its navy, the blue of its bars and the
|
||||||
|
orange of its needle.
|
||||||
|
- The logo follows the page: the dark version in a dark theme, the light one in a light theme.
|
||||||
|
- The browser tab's icon follows the page's light or dark mode too.
|
||||||
|
- On a phone, the top bar leaves out the logo, giving its room to the search box.
|
||||||
|
- Between releases, the version on the logo says so: 0.9.1-dev, not 0.9.0.
|
||||||
|
- The logo's needle is a brighter, redder orange that stands apart from the blue bars.
|
||||||
|
- The logo is in the top bar beside the add-feed button, in place of the name at the top of the
|
||||||
|
feed list. Hovering it shows iPX's version.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- `ipx copy-db`, the one-off move from SQLite to Postgres. Going back needs only the old `state.db`.
|
||||||
|
- Starting up no longer looks for files downloaded twice before 0.6.0 read WordPress's player
|
||||||
|
links correctly; that clean-up has run.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Adding a site without `https://`, such as `cnn.com`, works; it failed on every check.
|
||||||
|
- A limit on new downloads per check means a show's newest episodes: with it set to 3, ipx no longer works back through the show's history three at a time. Set to 0, it still takes the whole back catalogue.
|
||||||
|
- With no limit on new downloads per check (`max_new_per_check = 0`), downloads work on Postgres; they failed.
|
||||||
|
- Checking a feed with a long history is much quicker: items already stored are no longer written again on every check.
|
||||||
|
- A scan no longer asks a feed's website for its icon every time; it asks again when the artwork changes or you refresh the feed.
|
||||||
|
- The feed list loads several times faster: it was asking the database six questions per feed.
|
||||||
|
- Artwork a feed offers only over plain http shows on the https site too.
|
||||||
|
- A failing feed is easy to spot in any theme: a mark on its artwork, what is wrong in place of
|
||||||
|
its counts, and its artwork greyed out once it has failed for a day.
|
||||||
|
- A feed whose own artwork is missing shows its website's icon instead of nothing.
|
||||||
|
- The log has no terminal colour codes when it is not going to a terminal, as in `docker logs`.
|
||||||
|
- A feed whose website names an icon that is missing shows the site's `/favicon.ico` instead of
|
||||||
|
no artwork.
|
||||||
|
- A website's icon standing in for a feed's artwork follows the site when it changes, and a
|
||||||
|
feed that drops its own artwork gets the site's instead. Refreshing a feed checks again.
|
||||||
|
- The read button shows whether an item is read, as the pin beside it shows whether it is
|
||||||
|
pinned: a tick when read, an envelope when not. It used to show the opposite.
|
||||||
|
- In Directory and Popular, Subscribe is a plus again, not the tick that marks a feed you have.
|
||||||
|
- A feed's refresh button looks like the buttons beside it, instead of standing out filled in.
|
||||||
|
- Refreshing reads your feeds in full, instead of only asking each one whether it changed, so
|
||||||
|
a feed that has not changed is still read again.
|
||||||
|
- A web page with some non-ASCII characters no longer hides the feed it links, or crashes looking for it.
|
||||||
|
- Pulling the item list down to check for new items shows a spinner for a couple of seconds, and
|
||||||
|
a second pull meanwhile does nothing, instead of no sign at all that the check started.
|
||||||
|
- Settings no longer lists the server's download folder, which only an admin can change, on
|
||||||
|
the admin page.
|
||||||
|
- On the Unread tab, a swipe back (or k) goes to the item you just read, instead of past it or
|
||||||
|
back to the list. The items read on the way leave the Unread tab once you close the reader.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-09-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A new logo, on every page, in the browser tab and on the home screen: the old radio's screen,
|
||||||
|
its tuning scale and orange needle, drawn flat to Apple's app icon guidelines, with a dark
|
||||||
|
version. The 2004 icon is still at /icon.png.
|
||||||
|
- Words that hide items. Settings has a list for every feed you read, and each feed's settings a
|
||||||
|
list of its own; an item with one of those words or phrases in its title or text is hidden
|
||||||
|
from you and is not downloaded on your account. Other people's lists never hide anything from
|
||||||
|
you.
|
||||||
|
- Share buttons for a feed, an item and a file. On a phone they open the system's share sheet;
|
||||||
|
elsewhere they copy the link. A link that looks like it carries your own access to a paid
|
||||||
|
feed asks before it goes anywhere.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- iPodderX is now called iPX, on every page and in the tab. The repository is rays/ipx.
|
||||||
|
- On a phone, iPodderX looks like an iOS app: filled round buttons and capsules instead of
|
||||||
|
outlined boxes, no rules between the bars and the page, the feed's name as a large title, a
|
||||||
|
segmented control for the tabs, taller rows with 17px text, and dialogs as sheets from the
|
||||||
|
bottom. Tapping a text box no longer zooms the page in. High contrast, and Increase Contrast,
|
||||||
|
keep their outlines.
|
||||||
|
- The Glass theme's panels catch the light: a bright rim along the top and left edges, a fainter
|
||||||
|
one along the far edges, and a soft sheen from the top-left corner.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A new tab or home-screen icon shows up as soon as it is deployed, instead of a day later.
|
||||||
|
- Opened from the home screen on an iPhone, the top bar, the reader's back button and the top
|
||||||
|
of the feed list keep clear of the Dynamic Island and the status bar instead of sitting under
|
||||||
|
them.
|
||||||
|
- Swiping to the next or previous item takes a longer swipe, a quarter of the screen, so a
|
||||||
|
diagonal scroll no longer jumps ahead by accident. The item follows your finger and slides
|
||||||
|
across as it changes.
|
||||||
|
- Swiping between items on a phone no longer shows the item list through the reader. The next
|
||||||
|
or previous item sits beside the one you are reading and comes across with it, the end of the
|
||||||
|
list says so, and swiping right from the first item draws the reader off the list, dimmed
|
||||||
|
until it is uncovered. A swipe from the screen's left edge stays with the page, rather than
|
||||||
|
Safari taking it as Back.
|
||||||
|
|
||||||
|
## [0.8.4] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A Glass theme, light and dark, with frosted see-through panels after Apple's Liquid Glass. It
|
||||||
|
goes solid when the system asks for less transparency or more contrast.
|
||||||
|
- The page can hand playback to a native app. Opened inside an iOS or Android shell, an episode
|
||||||
|
plays through the host's own player instead of the page's, so it keeps going when the screen
|
||||||
|
locks and the car can control it; the player bar, the row buttons and the keyboard shortcuts
|
||||||
|
work as they always did. Video still plays in the page. In a browser nothing changes.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- On a phone, the bottom bar keeps clear of the home indicator, so the seek bar and the times are
|
||||||
|
no longer cut off when the page has the whole screen: in a native shell, or added to the iOS
|
||||||
|
home screen. In landscape the bars keep clear of the notch as well.
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||||
|
says where the token is kept instead: `[web] token` in config.toml.
|
||||||
|
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||||
|
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||||
|
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||||
|
docs/sso.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||||
|
now on have it.
|
||||||
|
|
||||||
|
## [0.8.2] - 2026-09-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Four themes, each in light and dark: Catppuccin (Latte and Mocha), Gruvbox, Solarized, and High
|
||||||
|
contrast, black and white with every colour at 7:1 or more.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The themes in Settings are listed in alphabetical order.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Links in Classic, and hints and headings in Modern's dark half, are dark or light enough to
|
||||||
|
read comfortably. A failed-action message is easier to read in every theme.
|
||||||
|
|
||||||
|
## [0.8.1] - 2026-09-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- A feed being checked shows a small spinner in place of its unread count (and its folder's),
|
||||||
|
instead of toasts: no more "Scanning…" or "1 new" pop-ups, and none at all for feeds you do not read. A
|
||||||
|
"Downloaded" toast is only for a file on your screen.
|
||||||
|
- "Check every feed" checks every feed you subscribe to, not every feed on the server.
|
||||||
|
- The Classic theme is listed in Settings as just "Classic".
|
||||||
|
- Settings and the keyboard shortcuts close from an X in their top corner, not a button at the bottom.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Unread counts, the unread dot and the Gone and Error tags are readable in every theme: several
|
||||||
|
light themes (Flat Remix, Paper, Adwaita, Nordic, Modern) drew them below AA contrast.
|
||||||
|
- The sign-in page follows the system's light or dark setting, and fits a phone's screen instead of
|
||||||
|
drawing at desktop width.
|
||||||
|
- In Classic, a selected item's play and delete buttons are white on the blue, not grey.
|
||||||
|
- In Nordic's dark half, button and toolbar borders show.
|
||||||
|
- A zero unread count on the selected feed no longer disappears into the selection.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ipx can keep its data in Postgres: set `IPX_DATABASE_URL` to a `postgres://` URL. Without it,
|
||||||
|
it is the SQLite `state.db` as before. `ipx copy-db <state.db>` moves an existing database
|
||||||
|
across, everything in one go.
|
||||||
|
- The catalogue of feeds and the server settings the admin page edits are kept in the database
|
||||||
|
rather than config.toml, which keeps where things are, the torrent settings and who may sign
|
||||||
|
in. The first start takes them from config.toml and trims it, keeping the original as
|
||||||
|
`config.toml.pre-database`; feeds added to config.toml after that are ignored, with a warning.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The database is reached through SeaORM, which is what lets it be SQLite or Postgres; on SQLite
|
||||||
|
nothing you see changes. On Postgres, sorting by title or feed follows the language's order (an
|
||||||
|
accented letter beside the plain one) rather than raw bytes. A database from before 0.7 has to
|
||||||
|
be opened by a 0.7 release first, which brings its tables up to date.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Six more themes in Settings: Dracula, Material, Adwaita, Flat Remix, Paper and Nordic, beside
|
||||||
|
Classic and Modern (the existing dark and light). Each that comes both ways has its own Light,
|
||||||
|
Dark or Auto setting; Classic and Paper come one way only, so that setting is hidden for them.
|
||||||
|
A theme chosen before this carries over.
|
||||||
|
- Your theme is kept on your account rather than in the browser, so it follows you to another
|
||||||
|
browser or computer, and the page arrives in it with no flash of the default. The theme a
|
||||||
|
browser already had is saved to your account the first time you load the page.
|
||||||
|
- Pin a feed to the top of the feed list with the pin on its page, a feed from inside an OPML or
|
||||||
|
Patreon folder included, which comes out of the folder while pinned. Pins are yours alone.
|
||||||
|
- Touch gestures: pull the item list down from its top to check the feed for new items, and
|
||||||
|
swipe the item you are reading left for the next one and right for the one before, or back
|
||||||
|
to the list from the first.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The server's settings, the accounts and the log are on their own admin page, /admin, reached by
|
||||||
|
the wrench in the header. Only an admin is sent the page, its script, or the link to it.
|
||||||
|
Settings is now yours alone: your theme and your subscriptions.
|
||||||
|
- A feed that fails to check gets a red exclamation mark in the feed list, in the margin where a
|
||||||
|
folder's triangle sits, and its page says why, in place of a pop-up per failure that everyone
|
||||||
|
saw during a scan of every feed. A folder holding a failing feed has its triangle turn red.
|
||||||
|
- The theme is chosen in Settings only; the button beside the iPodderX name is gone.
|
||||||
|
- Add a feed asks only for the feed; Popular and Directory in the sidebar are where you browse.
|
||||||
|
- On a phone, an item's files, with play and delete, sit above its show notes rather than below
|
||||||
|
them, where long notes left them looking missing.
|
||||||
|
- On a phone, an item with no files goes straight to its text, without a box saying "No files".
|
||||||
|
- The page is served minified, about a quarter smaller. Its script is now TypeScript in
|
||||||
|
`web/src`, type-checked, and built with swc; building ipx needs node.
|
||||||
|
- The script is its own file, `/app.js`, rather than inside the page. Your browser keeps it
|
||||||
|
between visits and fetches it again only when an update changes it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Switching tabs straight after marking everything read no longer shows the previous tab's
|
||||||
|
items: of two lists asked for at once, only the later one is shown.
|
||||||
|
- ipx has a favicon: the logo, squared up, also at /favicon.ico for browsers that ask there on
|
||||||
|
their own, and on white for an iPhone's home screen.
|
||||||
|
- The file icon of an item not yet downloaded sits level with the rest of its row, instead of
|
||||||
|
higher than a downloaded one's.
|
||||||
|
- Images in posts from sites that refuse images to other sites' pages, such as Jeff Geerling's,
|
||||||
|
now show: ipx asks for them without saying it is the page showing them.
|
||||||
|
- While an episode plays, its play buttons in the files pane, its row and the toolbar show
|
||||||
|
pause, as the player bar's does, and pause it when pressed.
|
||||||
|
- An item you open stays read. A list refresh that crossed with marking it read could put its
|
||||||
|
unread dot back until the next refresh.
|
||||||
|
- On the Unread tab, the item you were reading leaves the list as soon as you move to the next
|
||||||
|
one, rather than a few read items lingering until a refresh cleared them.
|
||||||
|
- The Log button no longer shows for a moment on every load for anyone but an admin; the server
|
||||||
|
leaves it out of their page.
|
||||||
|
- An image or link in a post given relative to the post, such as The Observation Deck's, now
|
||||||
|
points at the post's site rather than at ipx, and shows.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-09-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Time left, and when an episode counts as finished, go by the length your player measured
|
||||||
|
rather than the feed's, which can be minutes out: one episode said 0:08 left with 2:33 to play.
|
||||||
|
- A player left open in another tab or on another device no longer saves its older place over
|
||||||
|
where you have got to since, which could drop an episode out of Currently Listening.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Each episode in Currently Listening has a cross that takes it off the list. It forgets where you
|
||||||
|
got to, so playing it again starts from the beginning.
|
||||||
|
- An admin can give a feed a Directory category in its settings (`category` in config.toml), for
|
||||||
|
the blogs and other feeds that name none of their own. A feed's own iTunes category still wins.
|
||||||
|
- Keyboard shortcuts after Feedly's: j and k through items, Shift-J and Shift-K through feeds,
|
||||||
|
g and a letter to go to a place, o to play, s to pin, and more. Press ? for the whole list.
|
||||||
|
- Directory can be filtered to Podcasts or Blogs, and by each show's own iTunes category as a row
|
||||||
|
of chips, the narrower one where a show gives two (Games, not Leisure). The two combine, and
|
||||||
|
both filter in place.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Currently Listening marks the episode in the player with the EQ bars, as the item list does,
|
||||||
|
and its progress and time left move as it plays. Each row says how much is left.
|
||||||
|
- Directory shows each feed as its cover art in a grid, title and subscriber count underneath,
|
||||||
|
instead of a list. Popular and the Add a feed dialog keep their rows.
|
||||||
|
- The pages are set in Inter, served by ipx itself. Classic keeps Lucida Grande.
|
||||||
|
- Keeping an item is now pinning it: a thumbtack in place of the flag, and Pin, Pinned and Unpin
|
||||||
|
in place of Keep, Kept and Stop keeping. A pinned item is still never deleted.
|
||||||
|
- Currently Listening is its own place in the feed list, below Popular, instead of a section at
|
||||||
|
the bottom of the Popular page.
|
||||||
|
- The first scan after upgrading fetches every feed in full once, on its usual schedule, so each
|
||||||
|
picks up its category without waiting for the publisher to change something.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An episode that fails to load, or is paused before it has, no longer forgets where you left off
|
||||||
|
in it, and so no longer drops out of Currently Listening.
|
||||||
|
- Currently Listening lists the episodes you have started. It left out anything marked read, and
|
||||||
|
opening an episode marks it read, so it usually showed nothing. An episode now leaves the list
|
||||||
|
once 90% of it has played.
|
||||||
|
- A WordPress post that embeds the file it encloses no longer lists, and downloads, that file
|
||||||
|
twice. Items that already had it twice are folded into one at startup, and the spare copy
|
||||||
|
deleted.
|
||||||
|
- The pinned column's heading lines up with the pins under it, and every heading sits a pixel
|
||||||
|
further right, over its column.
|
||||||
|
- The feeds left behind by an OPML subscription removed before ipx retired them are cleared at
|
||||||
|
startup: forgotten if nothing was downloaded, kept as orphaned if something was. Feeds from it
|
||||||
|
that have since been given their own settings stay as they are, with their items. Removing an
|
||||||
|
OPML or Patreon subscription no longer deletes the items of a feed inside it that has its own
|
||||||
|
settings.
|
||||||
|
- Titles that arrive as HTML, such as The Verge's, no longer show their entities as text:
|
||||||
|
"Meta’s" reads "Meta’s". Titles already stored are corrected the next time their feed
|
||||||
|
changes.
|
||||||
|
|
||||||
## [0.5.5] - 2026-09-14
|
## [0.5.5] - 2026-09-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -372,14 +719,24 @@ The long form, with what was wrong before and how it was found, is in
|
|||||||
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
- Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout.
|
||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.5...main
|
[unreleased]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...main
|
||||||
[0.5.5]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...v0.5.5
|
[0.9.1]: https://git.sdf1.net/rays/ipx/compare/v0.9.0...v0.9.1
|
||||||
[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4
|
[0.9.0]: https://git.sdf1.net/rays/ipx/compare/v0.8.4...v0.9.0
|
||||||
[0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3
|
[0.8.4]: https://git.sdf1.net/rays/ipx/compare/v0.8.3...v0.8.4
|
||||||
[0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2
|
[0.8.3]: https://git.sdf1.net/rays/ipx/compare/v0.8.2...v0.8.3
|
||||||
[0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1
|
[0.8.2]: https://git.sdf1.net/rays/ipx/compare/v0.8.1...v0.8.2
|
||||||
[0.5.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.4.0...v0.5.0
|
[0.8.1]: https://git.sdf1.net/rays/ipx/compare/v0.8.0...v0.8.1
|
||||||
[0.4.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.3.0...v0.4.0
|
[0.8.0]: https://git.sdf1.net/rays/ipx/compare/v0.7.0...v0.8.0
|
||||||
[0.3.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.2.0...v0.3.0
|
[0.7.0]: https://git.sdf1.net/rays/ipx/compare/v0.6.1...v0.7.0
|
||||||
[0.2.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.1.0...v0.2.0
|
[0.6.1]: https://git.sdf1.net/rays/ipx/compare/v0.6.0...v0.6.1
|
||||||
[0.1.0]: https://git.sdf1.net/rays/ipodderx-rs/releases/tag/v0.1.0
|
[0.6.0]: https://git.sdf1.net/rays/ipx/compare/v0.5.5...v0.6.0
|
||||||
|
[0.5.5]: https://git.sdf1.net/rays/ipx/compare/v0.5.4...v0.5.5
|
||||||
|
[0.5.4]: https://git.sdf1.net/rays/ipx/compare/v0.5.3...v0.5.4
|
||||||
|
[0.5.3]: https://git.sdf1.net/rays/ipx/compare/v0.5.2...v0.5.3
|
||||||
|
[0.5.2]: https://git.sdf1.net/rays/ipx/compare/v0.5.1...v0.5.2
|
||||||
|
[0.5.1]: https://git.sdf1.net/rays/ipx/compare/v0.5.0...v0.5.1
|
||||||
|
[0.5.0]: https://git.sdf1.net/rays/ipx/compare/v0.4.0...v0.5.0
|
||||||
|
[0.4.0]: https://git.sdf1.net/rays/ipx/compare/v0.3.0...v0.4.0
|
||||||
|
[0.3.0]: https://git.sdf1.net/rays/ipx/compare/v0.2.0...v0.3.0
|
||||||
|
[0.2.0]: https://git.sdf1.net/rays/ipx/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://git.sdf1.net/rays/ipx/releases/tag/v0.1.0
|
||||||
|
|||||||
137
CLAUDE.md
137
CLAUDE.md
@@ -1,4 +1,4 @@
|
|||||||
# Working on ipodderx-rs
|
# Working on ipx
|
||||||
|
|
||||||
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
Notes for whoever picks this up next. Read [docs/architecture.md](docs/architecture.md) for how the
|
||||||
thing is built; this file is about working on it without repeating mistakes that have already been
|
thing is built; this file is about working on it without repeating mistakes that have already been
|
||||||
@@ -6,29 +6,50 @@ made here.
|
|||||||
|
|
||||||
## Where things are
|
## Where things are
|
||||||
|
|
||||||
Production is the `iPodderX` container on Tower (192.168.1.130), the `ipodderx` service of the
|
Production is the `iPX` container on Tower (192.168.1.130), the `ipx` service of the
|
||||||
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
Arcane project `content`: `/mnt/fast/arcane/projects/content/compose.yaml`. That file is what runs;
|
||||||
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
`docker-compose.yml` in this repo is a copy, and editing it changes nothing in production.
|
||||||
|
|
||||||
| | Host | In the container |
|
| | Host | In the container |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Image | `192.168.1.130:5000/ipodderx:latest` | |
|
| Image | `192.168.1.130:5000/ipx:latest` | |
|
||||||
| Config | `/mnt/fast/appdata/ipodderx/config.toml` | `/config/config.toml` |
|
| Config | `/mnt/fast/appdata/ipx/config.toml`: bind address, token, trusted proxies, torrent, paths. The feeds and server settings are in the database | `/config/config.toml` |
|
||||||
| Database | `/mnt/user/ipodderx/state.db` | `/data/state.db` |
|
| Database | Postgres 18, database `ipodderx`, login `ipodderx`, on the `postgres` container of the Arcane project `databases` (`192.168.1.130:5433`). The URL is in `ipx.env` beside the compose file (`/mnt/fast/arcane/projects/content/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 | |
|
||||||
| Downloads | `/mnt/user/ipodderx/downloads` | `/downloads` |
|
| 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` |
|
||||||
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
| Web UI | `192.168.1.130:8099`, also `ipodderx.sdf1.net` via a Cloudflare tunnel | `0.0.0.0:8099` |
|
||||||
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway |
|
| Sign-in via the tunnel | Cloudflare Access app `ipodderx`, with Authentik as its identity provider; see [docs/sso.md](docs/sso.md) | trusts `Cf-Access-Authenticated-User-Email` from `192.168.16.1`, the `content_default` gateway, and only with a valid `Cf-Access-Jwt-Assertion` (`access_team`, `access_aud`) |
|
||||||
|
|
||||||
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipodderx-rs/issues, not in a
|
Work to do lives in the Gitea issues at https://git.sdf1.net/rays/ipx/issues, not in a
|
||||||
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipodderx-rs`.
|
`TODO.md`. `/src/tea` is logged in: `/src/tea issues list --login git.sdf1.net --repo rays/ipx`.
|
||||||
|
|
||||||
|
**Every problem found gets an issue, before it is fixed.** A bug, a gap or a security hole turned
|
||||||
|
up along the way (reading logs, a review, a test that fails for another reason) is filed as soon
|
||||||
|
as it is found, even if it is fixed a minute later, so there is a record of what was wrong and
|
||||||
|
when. Name the issue in the commit that fixes it (`(#40)` in the subject). Once the fix is on
|
||||||
|
`main` and pushed, comment on the issue with what changed and the commit, then close it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
R="--login git.sdf1.net --repo rays/ipx"
|
||||||
|
t() { timeout 30 /src/tea "$@" < /dev/null; }
|
||||||
|
t issues create $R -t "Web token printed in the startup log" -L bug -d "What is wrong, where, how it was found."
|
||||||
|
t comment $R 40 "Fixed in 3625cf4: the startup line says where the token is kept, not what it is. Deployed in 0.8.3."
|
||||||
|
t issues close $R 40
|
||||||
|
```
|
||||||
|
|
||||||
|
**Give tea a closed stdin and a timeout**, as `t` does. Without a terminal, `tea comment` waits on
|
||||||
|
stdin and never exits; a script closing seven issues sat hung for a day on the second (#39).
|
||||||
|
|
||||||
|
Labels: `bug` for something wrong, `enhancement` for something missing. A problem found and left
|
||||||
|
for later stays open, and that is how it gets picked up again.
|
||||||
|
|
||||||
Deploying a change is: build and push the image, then pull it and recreate the container.
|
Deploying a change is: build and push the image, then pull it and recreate the container.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker buildx build --tag 192.168.1.130:5000/ipodderx:latest . --push
|
docker buildx build --tag 192.168.1.130:5000/ipx:latest . --push
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml pull ipx
|
||||||
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipodderx
|
docker compose -f /mnt/fast/arcane/projects/content/compose.yaml up -d ipx
|
||||||
docker logs --tail 20 iPodderX
|
docker logs --tail 20 iPX
|
||||||
```
|
```
|
||||||
|
|
||||||
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
**Name the service.** A bare `up -d` recreates every container in `content`, beets and immich
|
||||||
@@ -56,23 +77,41 @@ container restarts on its own after a reboot.
|
|||||||
|
|
||||||
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
Before the container, ipx ran by hand in code-server, with its files in `/config/.config/ipx/` and
|
||||||
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
`/config/.local/share/ipx/`. Those are still there and the container does not read them. If you run
|
||||||
a daemon by hand for testing, stop it with **`pkill -x ipx`, never `pkill -f ipx`**. `-f` matches
|
a daemon by hand for testing, **stop it by its own PID**: start it with `& echo $! > pid` and
|
||||||
the shell running the command and kills the session (exit 144). This has happened more than once.
|
`kill $(cat pid)`. Never `pkill -x ipx`: Tower sees the container's processes, so it kills
|
||||||
|
production's daemon as well (issue #38). Never `pkill -f ipx` either: `-f` matches the shell
|
||||||
|
running the command and kills the session (exit 144), which has happened more than once.
|
||||||
|
|
||||||
## Before you touch the page
|
## Before you touch the page
|
||||||
|
|
||||||
`web/index.html` is `include_str!`d into the binary, so **every page change needs a rebuild** before
|
There are three pages: the app (`web/index.html`), the admin page (`web/admin.html`, sent to
|
||||||
it is visible. It is one file: markup, CSS and script.
|
admins only) and sign-in (`web/login.html`). The app and admin pages share one stylesheet,
|
||||||
|
`web/app.css`, and their script is TypeScript in `web/src/`; `web/build.mjs` lists which files
|
||||||
|
make up each page's script. `build.rs` runs
|
||||||
|
`web/build.mjs`, which uses swc to strip the types and minify the script into `app.js` (and
|
||||||
|
`login.js`), and minifies the page, and the results are `include_str!`d into the binary. The page
|
||||||
|
loads its script as `/app.js?v=<hash of its contents>`, and `/app.css` the same way: the page is
|
||||||
|
served `no-cache` and the script and stylesheet `immutable`, so a browser keeps them until a
|
||||||
|
deploy changes them and their names. So **every page change needs a
|
||||||
|
rebuild** before it is visible, and building needs node and `npm ci` run once.
|
||||||
|
|
||||||
|
The files in `web/src` are not modules. They are one script split up, concatenated in the order
|
||||||
|
`web/build.mjs` lists them, sharing one top-level scope as the single inline script did; a new
|
||||||
|
file goes into that list. Top-level names are kept as they are, because markup calls some by
|
||||||
|
name (`onclick="closeModal()"`) and the browser tests reach others through `page.evaluate`.
|
||||||
|
|
||||||
After any edit to it:
|
After any edit to it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
npx tsc -p .
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
```
|
```
|
||||||
|
|
||||||
That loads the script against a stub DOM and checks every selector it wires at load actually
|
The first type-checks `web/src` (loosely: `strict` is off, and `$` returns `any`). The second
|
||||||
exists. It exists because a patch once anchored on a deleted function, `String.replace` silently
|
builds the page as shipped and runs its script against a stub DOM, checking every selector it
|
||||||
matched nothing, and the whole UI died with a `ReferenceError` while every server-side test passed.
|
wires at load actually exists. That check exists because a patch once anchored on a deleted
|
||||||
|
function, `String.replace` silently matched nothing, and the whole UI died with a
|
||||||
|
`ReferenceError` while every server-side test passed.
|
||||||
|
|
||||||
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
Patching that file by guessing an anchor string has failed repeatedly. Read the exact block first
|
||||||
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
(`sed -n 'START,ENDp'`), match it verbatim, and assert the replacement happened rather than hoping.
|
||||||
@@ -80,9 +119,12 @@ Patching that file by guessing an anchor string has failed repeatedly. Read the
|
|||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo test # ~51 tests: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # ~80 tests: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
|
npx tsc -p . # type-checks web/src
|
||||||
node tests/page-smoke.js
|
node tests/page-smoke.js
|
||||||
npx playwright test # 16 browser tests against a real daemon on fixture feeds
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
|
node tests/contrast.js # every theme's palette against WCAG AA
|
||||||
|
npx playwright test # 40 browser tests against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
Things about the browser suite that have cost time:
|
Things about the browser suite that have cost time:
|
||||||
@@ -108,10 +150,18 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
subscriber. Two feeds publishing the same URL means only the first one scanned shows it.
|
||||||
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
* **Read state lives in `entry_state`, per user, and nowhere else.** `entries` had `read`, `flagged`
|
||||||
and `position` columns from before accounts; two bugs came from queries still reading them
|
and `position` columns from before accounts; two bugs came from queries still reading them
|
||||||
(retention, and the entry pruner), and `migrate()` now drops them.
|
(retention, and the entry pruner), and they were dropped in 0.5.
|
||||||
* **The catalogue is config.toml; the subscriptions are in the database.** A feed exists once;
|
* **The catalogue and the server settings are in the database, not config.toml** (issue #18):
|
||||||
|
tables `catalogue` (each feed's `config::Feed` as JSON) and `settings` (`general`:
|
||||||
|
`config::Stored`). ipx still runs from one in-memory `Config`, config.toml for where things are
|
||||||
|
and who gets in, the database for the rest (`assemble_config`); a change goes through
|
||||||
|
`Ctx::store_cfg`, never a write to the file. The first start on a database without them imports
|
||||||
|
config.toml's and trims the file, keeping `config.toml.pre-database`. A feed exists once;
|
||||||
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
`subscriptions(user_id, feed_id)` says who wants it and with what settings. OPML children are
|
||||||
derived and never written to config.
|
derived and never in the catalogue.
|
||||||
|
* **Postgres connections ask for no notices** (`client_min_messages=warning`, `db::url_for`).
|
||||||
|
Postgres sends one for every `CREATE ... IF NOT EXISTS` on something existing, sqlx logs each,
|
||||||
|
and tracing-subscriber's per-layer filters then dropped the next line ipx logged.
|
||||||
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
* **One fetch serves everyone**, so scan policy is a union of subscribers' wants (`merge_policy`).
|
||||||
Anyone wanting an item is enough to fetch it.
|
Anyone wanting an item is enough to fetch it.
|
||||||
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
* **The UI hiding a control is not enforcement.** Admin-only actions check `user.is_admin` in the
|
||||||
@@ -120,13 +170,23 @@ Non-trivial logic leaves one runnable check behind. Pure functions (`merge_polic
|
|||||||
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
watch the shutdown channel itself; the daemon ignored SIGTERM for exactly this reason.
|
||||||
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
* Only one daemon per socket. Removing the socket file defeats the guard and you get two daemons
|
||||||
fighting over the database, with the stale one still holding the port.
|
fighting over the database, with the stale one still holding the port.
|
||||||
|
* **The Grafana dashboard reads the log's fields** (`grafana/dashboard.py`). Production logs JSON
|
||||||
|
(`IPX_LOG_FORMAT=json`); the access log's `method`, `path`, `route`, `status`, `duration_ms` and
|
||||||
|
the events' `ev`, `feed`, `new`, `bytes`, `msg` (`log_event` in ipc.rs) are what the panels query.
|
||||||
|
Rename one and its panels go blank without an error; regenerate the dashboard to match.
|
||||||
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
* `/api/settings` answering `200` does **not** mean the daemon is well — the web server is a
|
||||||
different task. `ipx status` checks the control socket and the database; to see the worker
|
different task. `ipx status` checks the control socket and the database; to see the worker
|
||||||
getting through its jobs, watch for `scan complete` in the log.
|
getting through its jobs, watch for `scan complete` in the log.
|
||||||
* **Every `ipx` command runs `migrate()` when it opens the database**, the healthcheck's
|
* **The database goes through SeaORM, and the entities in `src/entity.rs` are the schema.**
|
||||||
`ipx status` included. A migration that rewrites a big table (`DROP COLUMN`) takes seconds on
|
`Db::open` creates any missing table or index from them (`create_missing`), on every `ipx`
|
||||||
production, and a command run meanwhile fails with `migrating schema`. It changes nothing; wait
|
command, the healthcheck's `ipx status` included, so it must never write when nothing is
|
||||||
for `daemon started` in the log. Copy `state.db` aside before deploying one.
|
missing: SeaORM's experimental schema sync dropped and remade an index on every open, the
|
||||||
|
write lock that took made `ipx status` time out behind a busy daemon, and it was removed for
|
||||||
|
it. A new column on an existing table needs its own `ALTER`; nothing adds one for you.
|
||||||
|
* **SQL written by hand in `db.rs` has to run on SQLite and Postgres both** (issue #18): `$1`
|
||||||
|
parameters, bound only if used; `ON CONFLICT`, not `INSERT OR IGNORE`; yes/no columns tested
|
||||||
|
as themselves (`NOT coalesce(s.read, false)`) and written as `true`/`false`, never compared to
|
||||||
|
1; no `rowid`, `GLOB` or `UPDATE OR IGNORE`. `Args` in `db.rs` builds the parameters.
|
||||||
|
|
||||||
## House style
|
## House style
|
||||||
|
|
||||||
@@ -137,23 +197,24 @@ addressed to the person using it.
|
|||||||
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
Every change gets one line under `## [Unreleased]` in [CHANGELOG.md](CHANGELOG.md), in its
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) group: Added, Changed, Deprecated,
|
||||||
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
Removed, Fixed or Security. Say it the way someone using ipx would notice it. When there is more to
|
||||||
say, such as what was wrong before or what it cost to find out, write it up at the top of
|
say, such as what was wrong before or what it cost to find out, it goes in the commit message's
|
||||||
[docs/history.md](docs/history.md), dated. That record has been more useful than the git log more
|
body, where `git log` and `git blame` find it beside the change. (There was a long-form
|
||||||
than once.
|
`docs/history.md` until 0.7.0; it grew too large to be useful and was removed. It is in git.)
|
||||||
|
|
||||||
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
Cutting a release: rename `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` and open a new empty
|
||||||
`[Unreleased]` above it, bump `version` in `Cargo.toml`, tag the commit `vX.Y.Z`, and update the
|
`[Unreleased]` above it, set `version` in `Cargo.toml` to `X.Y.Z` (dropping `-dev`), tag the commit
|
||||||
compare links at the bottom of the changelog.
|
`vX.Y.Z`, and update the compare links at the bottom of the changelog. Then, in the next commit,
|
||||||
|
set `version` to the next patch with `-dev` (after 0.9.0, `0.9.1-dev`), so a build between releases says so
|
||||||
|
in the logo's tooltip instead of claiming to be the last release. The release that follows can
|
||||||
|
still be a minor or major one; `-dev` only says the work comes after `X.Y.Z`.
|
||||||
|
|
||||||
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
Deliberate simplifications get a `ponytail:` comment naming the ceiling and the upgrade path, e.g.
|
||||||
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
`// ponytail: global connection mutex, move to a pool if feed count makes it contend`.
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* They are the open issues in Gitea, not a list here: a limitation known and left in place is an
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
issue left open.
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
1040
Cargo.lock
generated
1040
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.5"
|
version = "0.9.2-dev"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,13 +12,17 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
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"] }
|
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_sdk = { version = "0.33", default-features = false, features = ["trace"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
quick-xml = "0.42.0"
|
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", "stream", "json", "charset", "system-proxy"] }
|
||||||
rss = "2.1.1"
|
rss = "2.1.1"
|
||||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
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"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
|
||||||
@@ -26,5 +30,6 @@ toml = "1.1.5"
|
|||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
tower-http = { version = "0.7.1", features = ["fs"] }
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
tracing-opentelemetry = { version = "0.34", default-features = false }
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||||
url = "2.5.8"
|
url = "2.5.8"
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
# Build. rusqlite is bundled (compiles SQLite from source) and librqbit needs a C
|
||||||
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
# toolchain, so the builder needs cc. TLS is rustls throughout, so no OpenSSL headers.
|
||||||
|
# build.rs builds the web pages from TypeScript with swc, which needs node.
|
||||||
FROM rust:1-slim-bookworm AS build
|
FROM rust:1-slim-bookworm AS build
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential nodejs npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
|
# swc only: Playwright and TypeScript are for testing and type-checking, not for building.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
# Dependencies first, so editing the source does not rebuild librqbit every time.
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
RUN mkdir src && echo 'fn main(){}' > src/main.rs \
|
||||||
&& cargo build --release --locked \
|
&& cargo build --release --locked \
|
||||||
&& rm -rf src
|
&& rm -rf src
|
||||||
|
|
||||||
|
COPY build.rs ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
# cargo skips a rebuild if mtimes look untouched; make sure it does not.
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -1,4 +1,4 @@
|
|||||||
# ipodderx-rs
|
# ipx
|
||||||
|
|
||||||
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
A self-hosted podcatcher for a household. It checks your feeds, downloads the episodes, and serves
|
||||||
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
a web UI modelled on the 2004 Mac app **iPodderX**, for any number of people sharing one copy of
|
||||||
@@ -12,16 +12,16 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
|
- **The web UI.** It has a toolbar, and a feed list that opens with Directory, Popular and All
|
||||||
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
Subscriptions. Items sit in a sortable table with a Files pane, and there is a player bar. It
|
||||||
comes in Dark, Light and Classic themes, and works on a phone.
|
comes in Dark, Light and Classic themes, and works on a phone.
|
||||||
- **Several people, one copy.** Each person has their own subscriptions and their own read, kept
|
- **Several people, one copy.** Each person has their own subscriptions and their own read, pinned
|
||||||
and playback state. There is one file on disk per episode, however many people want it. People
|
and playback state. There is one file on disk per episode, however many people want it. People
|
||||||
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
sign in with a password or through a proxy (Cloudflare Zero Trust or Authentik), and admins
|
||||||
manage accounts and settings.
|
manage accounts and settings.
|
||||||
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
- **Scanning.** Feeds are checked on a schedule, globally or per feed, and a feed's own TTL is
|
||||||
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a cap
|
honoured. Keyword, explicit-content and media-type filters decide what is downloaded, with a limit
|
||||||
on new downloads per scan.
|
on how many of a feed's newest episodes are downloaded.
|
||||||
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
- **Downloads.** Files come over HTTP or BitTorrent and are filed into a folder per feed.
|
||||||
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
Retention deletes the oldest files to stay under a disk quota or an age limit, and never touches
|
||||||
an item someone has kept.
|
an item someone has pinned.
|
||||||
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
|
- **OPML.** You can import and export your own subscriptions. You can also subscribe to an OPML
|
||||||
URL, which keeps a whole list in step as a folder.
|
URL, which keeps a whole list in step as a folder.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ behind iPodderX (2004-2008, Ray Slakinski & August Trometer).
|
|||||||
With Docker:
|
With Docker:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t ipodderx .
|
docker build -t ipx .
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -63,7 +63,6 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
| [docs/sso.md](docs/sso.md) | Signing in through Cloudflare Zero Trust or Authentik |
|
||||||
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
| [docs/architecture.md](docs/architecture.md) | How it works: modules, schema, control socket, HTTP API |
|
||||||
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
| [CHANGELOG.md](CHANGELOG.md) | What changed, by release |
|
||||||
| [docs/history.md](docs/history.md) | How it was built, with what was wrong and why |
|
|
||||||
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
| [CLAUDE.md](CLAUDE.md) | Notes for working on the code, including how production is deployed |
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
@@ -71,6 +70,7 @@ The UI is plain HTTP, so put TLS in front of it if it is reachable from outside
|
|||||||
```sh
|
```sh
|
||||||
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
cargo test # the engine: parsing, filters, retention, schedules, SQL, per-user state
|
||||||
node tests/page-smoke.js # the page script loads without throwing
|
node tests/page-smoke.js # the page script loads without throwing
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
14
build.rs
Normal file
14
build.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
//! Builds web/index.html and web/login.html from their TypeScript (web/build.mjs) into OUT_DIR,
|
||||||
|
//! where src/web.rs include_str!s them. Needs node and `npm ci` run first.
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=web");
|
||||||
|
println!("cargo:rerun-if-changed=package-lock.json");
|
||||||
|
let out = std::env::var("OUT_DIR").unwrap();
|
||||||
|
let status = Command::new("node")
|
||||||
|
.args(["web/build.mjs", &out])
|
||||||
|
.status()
|
||||||
|
.expect("building the web pages needs node on PATH (and `npm ci` run once)");
|
||||||
|
assert!(status.success(), "web/build.mjs failed; run `node web/build.mjs` to see why");
|
||||||
|
}
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
services:
|
services:
|
||||||
ipodderx:
|
ipx:
|
||||||
image: 192.168.1.130:5000/ipodderx:latest
|
image: 192.168.1.130:5000/ipx:latest
|
||||||
container_name: iPodderX
|
container_name: iPX
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PUID: "99"
|
PUID: "99"
|
||||||
PGID: "100"
|
PGID: "100"
|
||||||
TZ: "America/Toronto"
|
TZ: "America/Toronto"
|
||||||
IPX_LOG: "ipx=info"
|
IPX_LOG: "ipx=info"
|
||||||
|
# One JSON object a line, which Loki (through Alloy) and the Grafana dashboard read.
|
||||||
|
IPX_LOG_FORMAT: "json"
|
||||||
|
# Traces to Tempo, in the monitoring project; ipx sends none without it.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.1.130:4318"
|
||||||
|
# What the dashboard filters on, so a daemon run by hand for testing stays out of it.
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES: "deployment.environment.name=production"
|
||||||
|
# IPX_DATABASE_URL=postgres://... to use Postgres; without it, /data/state.db (SQLite).
|
||||||
|
env_file:
|
||||||
|
- ipx.env # relative: Arcane resolves it inside its own container
|
||||||
ports:
|
ports:
|
||||||
- "8099:8099" # web UI
|
- "8099:8099" # web UI
|
||||||
- "6881:6881/tcp" # BitTorrent peers
|
- "6881:6881/tcp" # BitTorrent peers
|
||||||
- "6881:6881/udp" # DHT
|
- "6881:6881/udp" # DHT
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
|
- /mnt/fast/appdata/ipx:/config # config.toml, and the web token
|
||||||
- /mnt/user/ipodderx/:/data # state.db
|
- /mnt/user/ipx/:/data # state.db
|
||||||
- /mnt/user/ipodderx/downloads:/downloads
|
- /mnt/user/ipx/downloads:/downloads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "ipx", "status"]
|
test: ["CMD", "ipx", "status"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ it to a running daemon.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
| `src/main.rs` | CLI, dispatch, scan loop, download policy | `iPXAgent.py` |
|
||||||
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
| `src/config.rs` | TOML load/save, `General`/`Feed`/`Web`, intervals, slugs | `iPXSettings.py`, `feeds.plist` |
|
||||||
| `src/db.rs` | SQLite schema, migrations, every query | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
| `src/db.rs` | Every query, through SeaORM; creates missing tables | `.ipxd` plists, `history.dat`, `qmcache.dat` |
|
||||||
|
| `src/entity.rs` | The tables, as SeaORM entities: the schema | — |
|
||||||
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
| `src/feed.rs` | Conditional GET, RSS/Atom/OPML parsing | `FeedData.__getFeed/__getEntries` |
|
||||||
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
| `src/download.rs` | Streaming download, naming, type sniffing, placement | `iPXDownloader.getFile` |
|
||||||
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
| `src/torrent.rs` | librqbit session, seeding limits, stall abort | vendored BitTorrent 4.2.1 |
|
||||||
@@ -19,9 +20,15 @@ it to a running daemon.
|
|||||||
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
| `src/auth.rs` | Argon2id hashing, session tokens, header names | — |
|
||||||
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
| `src/web.rs` | axum: HTTP API, auth, SSE, media streaming | — |
|
||||||
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
| `src/logbuf.rs` | Ring buffer behind the UI's Log view | — |
|
||||||
| `web/index.html` | The whole front end, `include_str!`d into the binary | — |
|
| `web/index.html` | The app's markup | — |
|
||||||
|
| `web/admin.html` | The admin page's markup: server settings, accounts, the log. Sent to admins only | — |
|
||||||
|
| `web/app.css` | The stylesheet both pages share | — |
|
||||||
|
| `web/src/*.ts` | The page's script, one scope split across files, type-checked by `npx tsc` | — |
|
||||||
|
| `web/build.mjs` | swc: strips the types into `app.js`/`login.js`, named in the page by a hash of their contents, and minifies | — |
|
||||||
|
| `build.rs` | Runs `web/build.mjs` into `OUT_DIR`, where `web.rs` `include_str!`s the result | — |
|
||||||
|
|
||||||
The page is compiled in, so **editing `web/index.html` needs a rebuild**.
|
The page is compiled in, so **editing `web/index.html` or `web/src` needs a rebuild**, and a
|
||||||
|
build needs node and `npm ci` run once.
|
||||||
|
|
||||||
## A scan
|
## A scan
|
||||||
|
|
||||||
@@ -59,14 +66,14 @@ entry_state user_id, feed_id, guid, read, flagged, position
|
|||||||
```
|
```
|
||||||
|
|
||||||
Read state is `entry_state` alone. `entries` had `read`, `flagged` and `position` columns from
|
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 `migrate()` drops them from an
|
before accounts; two bugs came from queries still reading them, and they were dropped in 0.5.
|
||||||
older database.
|
|
||||||
|
|
||||||
Schema changes: add the table or column to `SCHEMA`. `CREATE TABLE IF NOT EXISTS` leaves a table
|
Schema changes: the tables are the entities in `src/entity.rs`, and `Db::open` creates whatever
|
||||||
that already exists alone, so a new column on one also goes in `migrate()`'s `wanted` list, and a
|
table or index a database is missing from them (`db::create_missing`), with `IF NOT EXISTS`. It
|
||||||
retired one in its `retired` list; both are checked with `PRAGMA table_info`. Columns from before
|
never alters a table that exists, so a new column on one needs its own `ALTER` in
|
||||||
0.3.0, the oldest version an upgrade may start from, need no entry. `Db::memory()` runs the same
|
`create_missing`, or `sea-orm-migration` once there are several. `Db::memory()` builds its
|
||||||
path as `Db::open`, so a migration cannot pass the tests while missing in production.
|
database the same way, so the tests run on the schema production gets. A database from before
|
||||||
|
0.7 takes its last columns from the old `migrate()`, so it upgrades through a 0.7 release first.
|
||||||
|
|
||||||
## Control socket
|
## Control socket
|
||||||
|
|
||||||
@@ -119,7 +126,7 @@ else a `401`.
|
|||||||
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
|
||||||
| `POST /api/fetch` | |
|
| `POST /api/fetch` | |
|
||||||
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
|
||||||
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
|
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with an OPML's feeds in place of the OPML and everyone counted (id, title, art, count, whether it is yours, the feed's iTunes category, whether it carries audio or video; never a URL, never a private feed); subscribe by id |
|
||||||
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
|
||||||
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
|
||||||
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
| `GET /api/events` | SSE, the same broadcast the socket carries |
|
||||||
@@ -134,6 +141,7 @@ before they reach the page.
|
|||||||
```sh
|
```sh
|
||||||
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
cargo test # parsing, filters, retention, schedules, SQL, per-user isolation
|
||||||
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
node tests/page-smoke.js # the page script loads and every selector it wires at load exists
|
||||||
|
node tests/native-bridge.js # the page hands playback to a native shell
|
||||||
npx playwright test # a real browser against a real daemon on fixture feeds
|
npx playwright test # a real browser against a real daemon on fixture feeds
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ To kill it, match the binary exactly:
|
|||||||
pkill -x ipx
|
pkill -x ipx
|
||||||
```
|
```
|
||||||
|
|
||||||
`pkill -f ipx` matches the shell running the command too, and kills your own session.
|
`pkill -f ipx` matches the shell running the command too, and kills your own session. On a machine
|
||||||
|
that also runs ipx in a container, `pkill -x ipx` stops that one as well, since the host sees a
|
||||||
|
container's processes: stop the one you started by its PID instead (`kill <pid>`).
|
||||||
|
|
||||||
## Talking to it directly
|
## Talking to it directly
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
One TOML file, read at startup and re-read whenever the web UI writes to it — most changes take
|
Two places. **config.toml** holds what ipx needs before it reaches its database, and what decides
|
||||||
effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
who gets in: where things are (`download_dir`, `socket`, `organize`), `[torrent]` and `[web]`.
|
||||||
|
**The database** holds the catalogue of feeds (`[feeds.<id>]` below) and the server settings the
|
||||||
|
admin page edits (`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`,
|
||||||
|
`media_types`, `art_cache_mb`). Change those in the web UI, or with `ipx add`, `ipx rm` and `ipx import`; they
|
||||||
|
take effect without a restart.
|
||||||
|
|
||||||
|
The first time ipx meets a database that holds no catalogue, it takes the feeds and those
|
||||||
|
settings from config.toml, then rewrites config.toml without them, keeping the original beside it
|
||||||
|
as `config.toml.pre-database`. After that, feeds or those settings written into config.toml are
|
||||||
|
ignored, with a warning in the log saying so. The sections below describe them as they were
|
||||||
|
written in config.toml, which is still how a fresh install begins.
|
||||||
|
|
||||||
|
config.toml's default location is `$XDG_CONFIG_HOME/ipx/config.toml`
|
||||||
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
(`~/.config/ipx/config.toml`), overridden with `--config` or `$IPX_CONFIG`.
|
||||||
|
|
||||||
| What | Where | Override |
|
| What | Where | Override |
|
||||||
@@ -11,8 +23,9 @@ effect without a restart. Default location `$XDG_CONFIG_HOME/ipx/config.toml`
|
|||||||
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
| Control socket | `$XDG_RUNTIME_DIR/ipx.sock` | `[general] socket` |
|
||||||
| Downloads | `[general] download_dir` | — |
|
| Downloads | `[general] download_dir` | — |
|
||||||
|
|
||||||
`~` is expanded in paths. The database is SQLite in WAL mode; back it up by copying `state.db`
|
`~` is expanded in paths. The database is SQLite in WAL mode unless `IPX_DATABASE_URL` names a
|
||||||
while the daemon is stopped, or with `sqlite3 state.db .backup`.
|
Postgres database instead. Back SQLite up by copying `state.db` while the daemon is stopped, or
|
||||||
|
with `sqlite3 state.db .backup`; back Postgres up with `pg_dump`.
|
||||||
|
|
||||||
## `[general]`
|
## `[general]`
|
||||||
|
|
||||||
@@ -26,18 +39,30 @@ max_total_gb = 50 # 0 = unlimited
|
|||||||
max_age_days = 30 # 0 = keep forever
|
max_age_days = 30 # 0 = keep forever
|
||||||
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
max_new_per_check = 3 # per feed, per scan. 0 = unlimited
|
||||||
media_types = ["audio", "video"]
|
media_types = ["audio", "video"]
|
||||||
|
art_cache_mb = 500 # artwork kept on disk. 0 = none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`schedule`, `max_total_gb`, `max_age_days`, `max_new_per_check`, `media_types` and `art_cache_mb` move into the
|
||||||
|
database as described above; `download_dir`, `socket` and `organize` stay in config.toml.
|
||||||
|
|
||||||
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
* **`schedule`** — how often feeds are re-checked. A feed's own `<ttl>` still wins when it asks to
|
||||||
be polled *less* often, and a per-feed `schedule` overrides both. Admin-only from the UI.
|
be polled *less* often, and a per-feed `schedule` overrides both. 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. Admin-only from the UI.
|
||||||
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
* **`organize`** — `feed` files downloads under the feed's folder; `date` under `YYYY-MM-DD`.
|
||||||
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
* **`max_total_gb`** — the reaper deletes to get back under this, oldest first, keeping a 50 MB
|
||||||
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
pad. Kept items are never deleted, and a file only counts as read once every subscriber has
|
||||||
read it. `0` disables it entirely.
|
read it. `0` disables it entirely.
|
||||||
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
* **`max_age_days`** — items older than this with no file on disk are pruned from the database.
|
||||||
Kept ones stay. `0` disables it.
|
Kept ones stay. `0` disables it.
|
||||||
* **`max_new_per_check`** — the cap that stops a new subscription pulling a whole back catalogue.
|
* **`art_cache_mb`** — how much show and episode artwork iPX keeps on disk, in `art/` beside the
|
||||||
`0` means unlimited, which is rarely what you want: subscribing to an OPML of 80 feeds with no cap
|
database, so the page loads it from iPX rather than from every publisher's server. Over this,
|
||||||
|
what has gone longest unshown goes first. `0` keeps none: artwork still comes through iPX, fetched
|
||||||
|
each time. 500 by default.
|
||||||
|
* **`max_new_per_check`** — how many of a feed's newest episodes are downloaded; older ones stay
|
||||||
|
listed to download by hand. It stops a new subscription pulling a whole back catalogue.
|
||||||
|
`0` means every episode, for an archive; set it on the feeds you want archived, since on the
|
||||||
|
global default it applies to every feed: subscribing to an OPML of 80 feeds with no limit
|
||||||
fetched 216 files and 22 GB in one scan.
|
fetched 216 files and 22 GB in one scan.
|
||||||
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
* **`media_types`** — top-level MIME types taken automatically. Anything else is still listed and
|
||||||
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
can be fetched by hand; blog feeds put each article's header image in an `<enclosure>`, and
|
||||||
@@ -66,6 +91,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
|||||||
token = "" # generated and saved on first run
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -77,6 +104,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
@@ -88,8 +118,9 @@ itself carry a credential. Put TLS in front of it if that matters.
|
|||||||
|
|
||||||
## `[feeds.<id>]`
|
## `[feeds.<id>]`
|
||||||
|
|
||||||
The table key is the feed id: stable, human-readable, and used in paths and the API. `ipx add`
|
Kept in the database once ipx has moved them in: a feed's settings are changed in the web UI, and
|
||||||
derives it from the feed title.
|
feeds come and go with `ipx add`, `ipx rm` and `ipx import`. The table key is the feed id: stable,
|
||||||
|
human-readable, and used in paths and the API. `ipx add` derives it from the feed title.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[feeds.atp]
|
[feeds.atp]
|
||||||
@@ -97,6 +128,8 @@ url = "https://atp.fm/rss"
|
|||||||
folder = "Accidental Tech Podcast" # default: the feed title
|
folder = "Accidental Tech Podcast" # default: the feed title
|
||||||
schedule = "every 6h" # overrides [general] for this feed
|
schedule = "every 6h" # overrides [general] for this feed
|
||||||
media_types = ["audio"] # overrides [general] for this feed
|
media_types = ["audio"] # overrides [general] for this feed
|
||||||
|
category = "Technology" # the Directory's, if the feed names none
|
||||||
|
listed = true # in the Directory with no subscribers
|
||||||
username = "ray" # HTTP basic auth
|
username = "ray" # HTTP basic auth
|
||||||
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
password_env = "IPX_ATP_PASS" # preferred over a literal `password`
|
||||||
```
|
```
|
||||||
@@ -106,8 +139,14 @@ With more than one account, **`keywords`, `auto_download`, `allow_explicit` and
|
|||||||
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
config.toml are the fallback for a feed nobody has claimed. The keys above describe the feed itself
|
||||||
and are the same for everyone. See [users.md](users.md).
|
and are the same for everyone. See [users.md](users.md).
|
||||||
|
|
||||||
Feeds derived from a subscribed OPML are **not** written here: the OPML is the source of truth and
|
**The Directory** lists every feed in the catalogue, whether or not anyone subscribes to it yet.
|
||||||
they are re-derived on every scan. Editing one in the UI promotes it to a real config entry.
|
To put one there for others to find, `ipx add --list --category News <url>`: it subscribes no one,
|
||||||
|
and a listed feed stays when its last subscriber leaves, where another is dropped. A feed nobody
|
||||||
|
subscribes to is checked once a day. One that is dead (failing for 30 days) or quiet (nothing
|
||||||
|
new in a year), with nobody subscribed and no file on disk, is removed from the catalogue.
|
||||||
|
|
||||||
|
Feeds derived from a subscribed OPML are **not** in the catalogue: the OPML is the source of truth
|
||||||
|
and they are re-derived on every scan. Editing one in the UI promotes it to a catalogue entry.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -115,6 +154,10 @@ they are re-derived on every scan. Editing one in the UI promotes it to a real c
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `IPX_CONFIG` | Config file path |
|
| `IPX_CONFIG` | Config file path |
|
||||||
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
| `IPX_DATA_DIR` | Directory holding `state.db` |
|
||||||
|
| `IPX_DATABASE_URL` | A `postgres://user:password@host:port/database` URL: use that database instead of `state.db` |
|
||||||
|
| `IPX_TEST_DATABASE_URL` | For `cargo test`: run the database tests on this Postgres database too, each in a schema of its own |
|
||||||
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
| `IPX_LOG` | What reaches stderr (`ipx=debug`, `ipx::scan=debug`, …) |
|
||||||
|
| `IPX_LOG_FORMAT` | `json` for one JSON object a line, with each request's and event's fields as its own (for Loki and the like); text otherwise |
|
||||||
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
| `IPX_UI_LOG` | What the in-process log buffer captures for the UI's Log view |
|
||||||
|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | An OTLP/HTTP collector, such as Tempo at `http://host:4318`: the daemon sends it traces of scans, downloads and web requests. The other `OTEL_EXPORTER_OTLP_*` variables apply too |
|
||||||
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
| `http_proxy` / `https_proxy` | Honoured for feed and enclosure fetches |
|
||||||
|
|||||||
1670
docs/history.md
1670
docs/history.md
File diff suppressed because it is too large
Load Diff
58
docs/sso.md
58
docs/sso.md
@@ -31,7 +31,7 @@ request it forwards through the tunnel, and ipx signs that person in.
|
|||||||
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
| Access application | Zero Trust → Access → Applications → `ipodderx` | Domain `ipodderx.sdf1.net`; identity providers: Authentik only, with instant auth; session 730h; policy *Require Login* allows a list of email addresses |
|
||||||
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
| Tunnel route | Zero Trust → Networks → Tunnels → `rays-unraid` → Public hostnames | `ipodderx.sdf1.net` → HTTP `192.168.1.130:8099` |
|
||||||
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
| DNS | `sdf1.net` | `ipodderx` CNAME to the tunnel, proxied |
|
||||||
| ipx | `/mnt/fast/appdata/ipodderx/config.toml`, `[web]` | below |
|
| ipx | `/mnt/fast/appdata/ipx/config.toml`, `[web]` | below |
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[web]
|
[web]
|
||||||
@@ -39,13 +39,19 @@ enabled = true
|
|||||||
bind = "0.0.0.0:8099"
|
bind = "0.0.0.0:8099"
|
||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
|
access_team = "rays-sdf1.cloudflareaccess.com"
|
||||||
|
access_aud = "8bfe73dfbc8c548d1cb5dc11c6db6887bcaf4f5144840396f83a620a140e1c4f"
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "/cdn-cgi/access/logout"
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The last two turn on the token check described under [Verifying Cloudflare's token](#verifying-cloudflares-token),
|
||||||
|
on since 2026-09-19. Both can be read without the dashboard: a request to the site while signed
|
||||||
|
out is sent to `https://<team domain>/cdn-cgi/access/login/ipodderx.sdf1.net?kid=<AUD tag>&...`.
|
||||||
|
|
||||||
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
Restart ipx after editing it: `docker compose -f /mnt/fast/arcane/projects/content/compose.yaml
|
||||||
restart ipodderx`.
|
restart ipx`.
|
||||||
|
|
||||||
### What was missing
|
### What was missing
|
||||||
|
|
||||||
@@ -87,7 +93,7 @@ ordinary user with no feeds. An account made before the proxy can be given the n
|
|||||||
send:
|
send:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker exec iPodderX ipx user rename <old name> <email address>
|
docker exec iPX ipx user rename <old name> <email address>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signing out
|
### Signing out
|
||||||
@@ -103,11 +109,15 @@ feeds.
|
|||||||
|
|
||||||
### The tile in Authentik's library
|
### The tile in Authentik's library
|
||||||
|
|
||||||
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
|
Authentik's library lists Authentik's own applications, and ipx signs in through the one
|
||||||
called `Cloudflare Access`, so ipodderx needs a bookmark of its own to show up there. It is
|
called `Cloudflare Access`, so ipx needs a bookmark of its own to show up there. It is
|
||||||
Applications → Applications → `ipodderx`: no provider, launch URL `https://ipodderx.sdf1.net`, and
|
Applications → Applications → `iPX` (slug `ipodderx`): no provider, launch URL
|
||||||
the iPodderX icon. Like Outline's, it has no policy bindings, so everyone in Authentik sees the
|
`https://ipodderx.sdf1.net`, and web/logo.svg at 512px as its icon, uploaded again when the logo
|
||||||
tile. Who actually gets in is still up to the Access policy.
|
changes. The library keeps each person's list of tiles in a cache that an edit to the application
|
||||||
|
does not clear, so after one, clear it (System → Policies → Clear cache, or
|
||||||
|
`POST /api/v3/policies/all/cache_clear/`) or the old name and icon stay up. It has no policy
|
||||||
|
bindings, so everyone in Authentik sees the tile. Who actually gets in is still up to the Access
|
||||||
|
policy.
|
||||||
|
|
||||||
### Check it
|
### Check it
|
||||||
|
|
||||||
@@ -172,9 +182,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||||
|
|
||||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
@@ -192,7 +226,7 @@ echo -n 'newsecret' | ipx user passwd sam # change a password
|
|||||||
ipx user rm sam # remove the account
|
ipx user rm sam # remove the account
|
||||||
```
|
```
|
||||||
|
|
||||||
In the container, put `docker exec iPodderX` in front, and `docker exec -i iPodderX` for the ones
|
In the container, put `docker exec iPX` in front, and `docker exec -i iPX` for the ones
|
||||||
that read a password.
|
that read a password.
|
||||||
|
|
||||||
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
Set `auto_create_users = false` once everyone who should have an account has one. After that the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ fetch, one parse and one file.
|
|||||||
|
|
||||||
| Yours alone | The same for everyone |
|
| Yours alone | The same for everyone |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Read, kept, playback position | The feed's URL |
|
| Read, pinned, playback position | The feed's URL |
|
||||||
| Which feeds you see at all | Its download folder |
|
| Which feeds you see at all | Its download folder |
|
||||||
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
| Keywords, auto-download, explicit, per-scan cap | When it is scanned |
|
||||||
| | The file on disk |
|
| | The file on disk |
|
||||||
@@ -34,10 +34,10 @@ is shared.
|
|||||||
|
|
||||||
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
Deleting a file deletes everyone's copy. A feed with other subscribers labels the button **Delete
|
||||||
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
for everyone** and names them in the confirmation, and the server has the last word: if anyone else
|
||||||
has kept the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
has pinned the item or not played it yet, `DELETE /api/enclosures/{id}` answers `409` with the
|
||||||
reason, and only `?force=true` goes through.
|
reason, and only `?force=true` goes through.
|
||||||
|
|
||||||
Retention follows the same rule: an item anyone kept keeps its file, and it counts as read only once
|
Retention follows the same rule: an item anyone pinned keeps its file, and it counts as read only once
|
||||||
every subscriber has read it.
|
every subscriber has read it.
|
||||||
|
|
||||||
## Signing in
|
## Signing in
|
||||||
@@ -72,7 +72,9 @@ re-subscribing does not pull the back catalogue again.
|
|||||||
|
|
||||||
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
|
||||||
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
|
||||||
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
|
included. Directory shows every one of them A to Z as a grid of cover art. Above it, a filter
|
||||||
|
picks Podcasts (anything with audio or video) or Blogs (the rest), and chips pick the category
|
||||||
|
each show gives itself in iTunes; the two combine. Your own feeds are marked Subscribed.
|
||||||
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is listed
|
||||||
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
as the feeds inside it, one by one, and never the OPML itself, so you can take just the shows you
|
||||||
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
want. Anything that looks private is left out: a login configured for the feed, credentials in its URL,
|
||||||
|
|||||||
174
grafana/dashboard.py
Normal file
174
grafana/dashboard.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""The iPX dashboard in Grafana, from Loki (the container's log, shipped by Alloy) and Tempo.
|
||||||
|
|
||||||
|
python3 grafana/dashboard.py > /mnt/fast/arcane/projects/monitoring/grafana-provisioning/dashboards/ipx.json
|
||||||
|
|
||||||
|
Grafana reads that file on its own within a minute; edits made in Grafana are refused. The panels
|
||||||
|
read the fields of ipx's JSON log (IPX_LOG_FORMAT=json): renaming a field in web.rs access_log or
|
||||||
|
ipc.rs log_event has to be matched here. `dashboard.py queries` prints each
|
||||||
|
query, to try against Loki.
|
||||||
|
"""
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
LOKI = {"type": "loki", "uid": "${loki}"}
|
||||||
|
TEMPO = {"type": "tempo", "uid": "${tempo}"}
|
||||||
|
SEL = '{container="iPX"}'
|
||||||
|
# ipx logs one JSON object a line (IPX_LOG_FORMAT=json). Each scan and download event carries its
|
||||||
|
# fields (ev, feed, new, bytes, msg, error.type, ...); each request its method, path, route, status
|
||||||
|
# and duration_ms. Events are logged under ipx::scan, the healthcheck's status under ipx::io, so
|
||||||
|
# the filter is on the ev field, not the target.
|
||||||
|
EV = SEL + ' |= "\\"ev\\":\\"" | json | __error__="" | ev != ""'
|
||||||
|
HTTP = SEL + ' |= "\\"target\\":\\"ipx::http\\"" | json | __error__="" | path != "/api/events"'
|
||||||
|
BAD = SEL + ' | json | __error__="" | level =~ "WARN|ERROR"'
|
||||||
|
TEXT = ' | line_format "{{.level}} {{.target}}: {{.message}}"'
|
||||||
|
TRACES = '{resource.service.name="ipx" && resource.deployment.environment.name="production"'
|
||||||
|
|
||||||
|
|
||||||
|
def status(field):
|
||||||
|
return f'max(max_over_time({EV} | ev = "status" | unwrap {field} [10m]))'
|
||||||
|
|
||||||
|
|
||||||
|
QUERIES = {}
|
||||||
|
panels, y = [], 0
|
||||||
|
pid = 0
|
||||||
|
|
||||||
|
|
||||||
|
def panel(kind, title, w, h, x, targets, **extra):
|
||||||
|
global pid
|
||||||
|
pid += 1
|
||||||
|
p = {"id": pid, "type": kind, "title": title, "gridPos": {"x": x, "y": y, "w": w, "h": h},
|
||||||
|
"datasource": targets[0].get("datasource", LOKI), "targets": targets}
|
||||||
|
p.update(extra)
|
||||||
|
panels.append(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def loki(expr, ref="A", legend=None, instant=False, kind=None):
|
||||||
|
QUERIES[expr] = instant
|
||||||
|
t = {"refId": ref, "datasource": LOKI, "expr": expr, "queryType": "instant" if instant else "range"}
|
||||||
|
if legend:
|
||||||
|
t["legendFormat"] = legend
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def row(title):
|
||||||
|
global y, pid
|
||||||
|
pid += 1
|
||||||
|
panels.append({"id": pid, "type": "row", "title": title, "collapsed": False,
|
||||||
|
"gridPos": {"x": 0, "y": y, "w": 24, "h": 1}, "panels": []})
|
||||||
|
y += 1
|
||||||
|
|
||||||
|
|
||||||
|
def stat(title, expr, x, unit="short", color="blue", thresholds=None, desc=None):
|
||||||
|
steps = thresholds or [{"color": color, "value": None}]
|
||||||
|
return panel("stat", title, 4, 4, x, [loki(expr, instant=True)], description=desc or "",
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "color": {"mode": "thresholds"},
|
||||||
|
"thresholds": {"mode": "absolute", "steps": steps}}, "overrides": []},
|
||||||
|
options={"reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": False},
|
||||||
|
"colorMode": "value", "graphMode": "none", "textMode": "value"})
|
||||||
|
|
||||||
|
|
||||||
|
def ts(title, targets, x, w=12, h=8, unit="short", bars=False, stack=False, desc=""):
|
||||||
|
custom = {"drawStyle": "bars" if bars else "line", "fillOpacity": 60 if bars else 10,
|
||||||
|
"lineWidth": 1, "showPoints": "never", "stacking": {"mode": "normal" if stack else "none"}}
|
||||||
|
return panel("timeseries", title, w, h, x, targets, description=desc,
|
||||||
|
fieldConfig={"defaults": {"unit": unit, "custom": custom}, "overrides": []},
|
||||||
|
options={"legend": {"displayMode": "list", "placement": "bottom"},
|
||||||
|
"tooltip": {"mode": "multi", "sort": "desc"}})
|
||||||
|
|
||||||
|
|
||||||
|
def table(title, targets, x, w=12, h=8, rename=None, sort=None, desc=""):
|
||||||
|
return panel("table", title, w, h, x, targets, description=desc,
|
||||||
|
transformations=[{"id": "labelsToFields", "options": {"mode": "columns"}},
|
||||||
|
{"id": "organize", "options": {
|
||||||
|
"excludeByName": {"Time": True, "container": True, "compose_project": True,
|
||||||
|
"service_name": True},
|
||||||
|
"renameByName": rename or {}}}],
|
||||||
|
options={"showHeader": True, "sortBy": sort or []},
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Now
|
||||||
|
row("Now")
|
||||||
|
stat("Feeds", status("feeds"), 0, desc="From the healthcheck's status answer, every 30 seconds.")
|
||||||
|
stat("Waiting to download", status("pending"), 4)
|
||||||
|
stat("Downloaded", status("downloaded"), 8, color="green")
|
||||||
|
stat("Feed failures", f'sum(count_over_time({EV} | ev="feed_error" [$__range])) or vector(0)', 12,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}],
|
||||||
|
desc="Failed feed checks in the time range.")
|
||||||
|
stat("Download failures", f'sum(count_over_time({EV} | ev="download_error" [$__range])) or vector(0)', 16,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}])
|
||||||
|
stat("Warnings and errors", f'sum(count_over_time({BAD} [$__range])) or vector(0)', 20,
|
||||||
|
thresholds=[{"color": "green", "value": None}, {"color": "orange", "value": 1}, {"color": "red", "value": 50}])
|
||||||
|
y += 4
|
||||||
|
|
||||||
|
# ---- Scans
|
||||||
|
row("Scans and downloads")
|
||||||
|
ts("New items found", [loki(f'sum(sum_over_time({EV} | ev="feed_done" | unwrap new [$__interval]))', legend="new items")],
|
||||||
|
0, bars=True)
|
||||||
|
ts("Downloads", [loki(f'sum(count_over_time({EV} | ev="download_done" [$__interval]))', legend="saved"),
|
||||||
|
loki(f'sum(count_over_time({EV} | ev="download_error" [$__interval]))', ref="B", legend="failed")],
|
||||||
|
12, bars=True)
|
||||||
|
y += 8
|
||||||
|
ts("Bytes downloaded", [loki(f'sum(sum_over_time({EV} | ev="download_done" | unwrap bytes [$__interval]))', legend="bytes")],
|
||||||
|
0, unit="bytes", bars=True)
|
||||||
|
ts("Feeds checked per scan", [loki(f'sum(sum_over_time({EV} | ev="scan_done" | unwrap feeds [$__interval]))', legend="feeds checked")],
|
||||||
|
12, bars=True, desc="Feeds that were due and fetched; the rest were skipped as not due.")
|
||||||
|
y += 8
|
||||||
|
table("Failing feeds", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="feed_error" [$__range]))', instant=True)],
|
||||||
|
0, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
table("Failed downloads", [loki(f'sum by (feed, msg) (count_over_time({EV} | ev="download_error" [$__range]))', instant=True)],
|
||||||
|
12, rename={"feed": "Feed", "msg": "Error", "Value": "Failures"}, sort=[{"displayName": "Failures", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- Web
|
||||||
|
row("Web")
|
||||||
|
ts("Requests by status", [loki(f'sum by (status) (count_over_time({HTTP} [$__interval]))', legend="{{status}}")],
|
||||||
|
0, bars=True, stack=True, desc="The event stream the page keeps open is left out.")
|
||||||
|
ts("Response time", [loki(f'quantile_over_time(0.5, {HTTP} | unwrap duration_ms [$__interval]) by ()', legend="median"),
|
||||||
|
loki(f'quantile_over_time(0.95, {HTTP} | unwrap duration_ms [$__interval]) by ()', ref="B", legend="95th percentile"),
|
||||||
|
loki(f'max_over_time({HTTP} | unwrap duration_ms [$__interval]) by ()', ref="C", legend="slowest")],
|
||||||
|
12, unit="ms")
|
||||||
|
y += 8
|
||||||
|
table("Slowest routes", [loki(f'topk(15, avg_over_time({HTTP} | route != "" | unwrap duration_ms [$__range]) by (method, route))', instant=True)],
|
||||||
|
0, rename={"method": "Method", "route": "Route", "Value": "Average ms"}, sort=[{"displayName": "Average ms", "desc": True}])
|
||||||
|
table("Busiest routes", [loki(f'topk(15, sum by (method, route) (count_over_time({HTTP} | route != "" [$__range])))', instant=True)],
|
||||||
|
12, rename={"method": "Method", "route": "Route", "Value": "Requests"}, sort=[{"displayName": "Requests", "desc": True}])
|
||||||
|
y += 8
|
||||||
|
|
||||||
|
# ---- Traces
|
||||||
|
row("Traces")
|
||||||
|
for x, title, q in [(0, "Recent traces", TRACES + "}"),
|
||||||
|
(12, "Slow traces (over 2s)", TRACES + " && duration > 2s}")]:
|
||||||
|
panel("table", title, 12, 10, x,
|
||||||
|
[{"refId": "A", "datasource": TEMPO, "queryType": "traceql", "query": q, "limit": 50,
|
||||||
|
"tableType": "traces"}],
|
||||||
|
fieldConfig={"defaults": {}, "overrides": []})
|
||||||
|
y += 10
|
||||||
|
|
||||||
|
# ---- Log
|
||||||
|
row("Log")
|
||||||
|
panel("logs", "Warnings and errors", 24, 10, 0, [loki(BAD + TEXT)],
|
||||||
|
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||||
|
y += 10
|
||||||
|
panel("logs", "Log", 24, 12, 0,
|
||||||
|
[loki(SEL + ' | json | __error__="" | path != "/api/events" | ev != "feed_skip" | ev != "status"'
|
||||||
|
' | message != "-> {\\"cmd\\":\\"status\\"}"' + TEXT)],
|
||||||
|
description="Without the event stream's requests, not-due skips and healthcheck status calls.",
|
||||||
|
options={"showTime": True, "wrapLogMessage": True, "sortOrder": "Descending", "enableLogDetails": True})
|
||||||
|
|
||||||
|
dash = {
|
||||||
|
"uid": "ipx", "title": "iPX", "tags": ["ipx"], "timezone": "browser", "schemaVersion": 39,
|
||||||
|
"time": {"from": "now-24h", "to": "now"}, "refresh": "1m", "editable": True,
|
||||||
|
"templating": {"list": [
|
||||||
|
{"name": "loki", "label": "Logs", "type": "datasource", "query": "loki", "current": {}, "hide": 0},
|
||||||
|
{"name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo", "current": {}, "hide": 0},
|
||||||
|
]},
|
||||||
|
"links": [{"title": "iPX", "type": "link", "url": "https://ipodderx.sdf1.net", "targetBlank": True}],
|
||||||
|
"panels": panels,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sys.argv[1:] == ["queries"]:
|
||||||
|
for q, instant in QUERIES.items():
|
||||||
|
print(json.dumps([q, instant]))
|
||||||
|
else:
|
||||||
|
print(json.dumps(dash, indent=2))
|
||||||
887
package-lock.json
generated
887
package-lock.json
generated
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
@@ -25,6 +30,847 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.28"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/core-darwin-x64": "1.16.2",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/core-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/core-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.16.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html/-/html-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/html-darwin-arm64": "1.16.2",
|
||||||
|
"@swc/html-darwin-x64": "1.16.2",
|
||||||
|
"@swc/html-linux-arm-gnueabihf": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-arm64-musl": "1.16.2",
|
||||||
|
"@swc/html-linux-ppc64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-s390x-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-gnu": "1.16.2",
|
||||||
|
"@swc/html-linux-x64-musl": "1.16.2",
|
||||||
|
"@swc/html-win32-arm64-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-ia32-msvc": "1.16.2",
|
||||||
|
"@swc/html-win32-x64-msvc": "1.16.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-arm64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-darwin-x64": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-arm64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-ppc64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-s390x-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-gnu": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-linux-x64-musl": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-arm64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-ia32-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/html-win32-x64-msvc": {
|
||||||
|
"version": "1.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz",
|
||||||
|
"integrity": "sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
|
||||||
|
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
@@ -53,6 +899,41 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -1,13 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-ui-tests",
|
"name": "ipx-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Browser tests for the ipx web UI. The Rust tests cover the server; these cover the page.",
|
"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": {
|
"scripts": {
|
||||||
|
"build": "node web/build.mjs",
|
||||||
|
"typecheck": "tsc -p .",
|
||||||
|
"smoke": "node tests/page-smoke.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed"
|
||||||
"smoke": "node tests/page-smoke.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.0"
|
"@playwright/test": "^1.56.0",
|
||||||
|
"typescript": "^7.0.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/core": "^1.16.2",
|
||||||
|
"@swc/html": "^1.16.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ module.exports = defineConfig({
|
|||||||
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
IPX_CONFIG: `${setup.root}/config/config.toml`,
|
||||||
IPX_DATA_DIR: `${setup.root}/data`,
|
IPX_DATA_DIR: `${setup.root}/data`,
|
||||||
IPX_LOG: 'ipx=info',
|
IPX_LOG: 'ipx=info',
|
||||||
|
// Blank, whatever the shell running the suite has: the test daemon sends no traces to
|
||||||
|
// production's Tempo and never opens production's database.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: '',
|
||||||
|
IPX_DATABASE_URL: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
215
src/access.rs
Normal file
215
src/access.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||||
|
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||||
|
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||||
|
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||||
|
//! takes the network out of the question.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use jsonwebtoken::jwk::JwkSet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
|
||||||
|
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||||
|
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||||
|
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||||
|
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Keys {
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cache {
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
fetched: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keys {
|
||||||
|
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||||
|
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||||
|
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||||
|
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||||
|
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||||
|
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||||
|
match fetch(client, team).await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<JwkSet>>,
|
||||||
|
{
|
||||||
|
let kid = decode_header(token).ok()?.kid?;
|
||||||
|
let key = match self.key(&kid) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
if !self.may_refetch() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match fetch().await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.key(&kid)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||||
|
// is looked at, rather than checked with the public key as if it were a secret.
|
||||||
|
let mut v = Validation::new(Algorithm::RS256);
|
||||||
|
v.set_audience(&[aud]);
|
||||||
|
v.set_issuer(&[format!("https://{team}")]);
|
||||||
|
v.validate_nbf = true;
|
||||||
|
match decode::<Claims>(token, &key, &v) {
|
||||||
|
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||||
|
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||||
|
fn may_refetch(&self) -> bool {
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||||
|
fn store(&self, set: JwkSet) {
|
||||||
|
let keys = set
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||||
|
.collect();
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
c.keys = keys;
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||||
|
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||||
|
client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {url}"))?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("reading the signing keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||||
|
|
||||||
|
const TEAM: &str = "team.cloudflareaccess.com";
|
||||||
|
const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
|
fn jwks() -> JwkSet {
|
||||||
|
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
|
let mut h = Header::new(alg);
|
||||||
|
h.kid = Some("k1".into());
|
||||||
|
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||||
|
encode(&h, &claims, &k).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||||
|
let now = get_current_timestamp() as i64;
|
||||||
|
serde_json::json!({
|
||||||
|
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||||
|
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||||
|
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||||
|
|
||||||
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(jwks());
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||||
|
|
||||||
|
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||||
|
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||||
|
|
||||||
|
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||||
|
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||||
|
|
||||||
|
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||||
|
|
||||||
|
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||||
|
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||||
|
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||||
|
ok.split('.').nth(1).unwrap());
|
||||||
|
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||||
|
"a key not cached yet is fetched");
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
// A made-up key id straight after: not fetched again.
|
||||||
|
let mut h = Header::new(Algorithm::RS256);
|
||||||
|
h.kid = Some("nobody".into());
|
||||||
|
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/art.rs
Normal file
92
src/art.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
||||||
|
//! of each publisher's server for every visit, and still there when a publisher is not.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub fn dir() -> PathBuf {
|
||||||
|
crate::config::data_dir().join("art")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
|
||||||
|
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
|
||||||
|
// only makes the cache miss once and refill; a collision among a few thousand images is about
|
||||||
|
// one in 10^12. Move to a SHA if either ever matters.
|
||||||
|
pub fn path(url: &str) -> PathBuf {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
url.hash(&mut h);
|
||||||
|
dir().join(format!("{:016x}", h.finish()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
|
||||||
|
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
||||||
|
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
||||||
|
let body = std::fs::read(file).ok()?;
|
||||||
|
if let Ok(f) = std::fs::File::options().write(true).open(file) {
|
||||||
|
let _ = f.set_modified(std::time::SystemTime::now());
|
||||||
|
}
|
||||||
|
Some((kind, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
|
||||||
|
/// never reads half of one.
|
||||||
|
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
||||||
|
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
||||||
|
let tmp = file.with_extension("part");
|
||||||
|
std::fs::write(&tmp, body)?;
|
||||||
|
std::fs::write(file.with_extension("type"), kind)?;
|
||||||
|
std::fs::rename(&tmp, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
||||||
|
/// Returns how many went.
|
||||||
|
pub fn trim(dir: &Path, limit: u64) -> usize {
|
||||||
|
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
||||||
|
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_none())
|
||||||
|
.filter_map(|p| {
|
||||||
|
let m = p.metadata().ok()?;
|
||||||
|
Some((m.modified().ok()?, m.len(), p))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
|
||||||
|
kept.sort();
|
||||||
|
let mut gone = 0;
|
||||||
|
for (_, n, p) in kept {
|
||||||
|
if total <= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(p.with_extension("type"));
|
||||||
|
if std::fs::remove_file(&p).is_ok() {
|
||||||
|
total -= n;
|
||||||
|
gone += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gone
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_least_recently_used_go_first_until_it_fits() {
|
||||||
|
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
|
||||||
|
let f = d.join(name);
|
||||||
|
write(&f, "image/png", &[0; 1000]).unwrap();
|
||||||
|
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
||||||
|
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
||||||
|
}
|
||||||
|
// Shown just now: no longer the oldest.
|
||||||
|
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
|
||||||
|
assert_eq!(trim(&d, 2000), 1);
|
||||||
|
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
|
||||||
|
assert!(d.join("old").exists() && d.join("new").exists());
|
||||||
|
assert_eq!(trim(&d, 0), 2);
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/config.rs
169
src/config.rs
@@ -39,6 +39,9 @@ pub struct General {
|
|||||||
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
/// image in an <enclosure>, so taking everything filled the disk with artwork and
|
||||||
/// counted it as episodes. Empty means take anything.
|
/// counted it as episodes. Empty means take anything.
|
||||||
pub media_types: Vec<String>,
|
pub media_types: Vec<String>,
|
||||||
|
/// How much artwork iPX keeps on disk, in MB, so the page loads it from iPX rather than from
|
||||||
|
/// every publisher; the least recently shown goes first. 0 keeps none.
|
||||||
|
pub art_cache_mb: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||||
@@ -78,6 +81,14 @@ pub struct Web {
|
|||||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +107,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +120,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -115,6 +134,10 @@ pub struct Feed {
|
|||||||
/// Download folder name; defaults to the sanitized feed title.
|
/// Download folder name; defaults to the sanitized feed title.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub folder: Option<String>,
|
pub folder: Option<String>,
|
||||||
|
/// The Directory's category for a feed that names none of its own, as most blogs do not.
|
||||||
|
/// The feed's own iTunes category wins where there is one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub category: Option<String>,
|
||||||
/// Every whitespace-separated word of a keyword must appear in the
|
/// Every whitespace-separated word of a keyword must appear in the
|
||||||
/// url/title/description/categories for an enclosure to be taken.
|
/// url/title/description/categories for an enclosure to be taken.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
@@ -143,6 +166,10 @@ pub struct Feed {
|
|||||||
/// Preferred over `password`: name of an env var holding the password.
|
/// Preferred over `password`: name of an env var holding the password.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub password_env: Option<String>,
|
pub password_env: Option<String>,
|
||||||
|
/// Put in the Directory by an admin (`ipx add --list`): listed with no subscribers, and kept
|
||||||
|
/// in the catalogue when the last one leaves, where anyone else's feed is dropped.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub listed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn yes() -> bool {
|
fn yes() -> bool {
|
||||||
@@ -160,6 +187,7 @@ impl Default for General {
|
|||||||
max_age_days: 0,
|
max_age_days: 0,
|
||||||
max_new_per_check: 3,
|
max_new_per_check: 3,
|
||||||
media_types: vec!["audio".into(), "video".into()],
|
media_types: vec!["audio".into(), "video".into()],
|
||||||
|
art_cache_mb: 500,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,21 +285,96 @@ impl Config {
|
|||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, path: &Path) -> Result<()> {
|
}
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)
|
/// What the database keeps of the configuration (issue #18): the server settings the admin page
|
||||||
.with_context(|| format!("creating {}", dir.display()))?;
|
/// edits, and, beside them in `Db::stored_config`, the catalogue of feeds. The rest -- where
|
||||||
|
/// things are, who may sign in, the torrent session -- is needed before the database is reached,
|
||||||
|
/// or decides who gets in, and stays in config.toml.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
|
||||||
|
pub struct Stored {
|
||||||
|
pub schedule: String,
|
||||||
|
pub max_total_gb: f64,
|
||||||
|
pub max_age_days: u64,
|
||||||
|
pub max_new_per_check: usize,
|
||||||
|
pub media_types: Vec<String>,
|
||||||
|
/// Settings saved before it existed have none: they get the default.
|
||||||
|
#[serde(default = "art_cache_mb")]
|
||||||
|
pub art_cache_mb: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn art_cache_mb() -> u64 {
|
||||||
|
General::default().art_cache_mb
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[general]` keys that live in the database once it holds the configuration.
|
||||||
|
const STORED_KEYS: [&str; 6] =
|
||||||
|
["schedule", "max_total_gb", "max_age_days", "max_new_per_check", "media_types", "art_cache_mb"];
|
||||||
|
|
||||||
|
impl Stored {
|
||||||
|
pub fn of(cfg: &Config) -> Self {
|
||||||
|
let g = &cfg.general;
|
||||||
|
Self {
|
||||||
|
schedule: g.schedule.clone(),
|
||||||
|
max_total_gb: g.max_total_gb,
|
||||||
|
max_age_days: g.max_age_days,
|
||||||
|
max_new_per_check: g.max_new_per_check,
|
||||||
|
media_types: g.media_types.clone(),
|
||||||
|
art_cache_mb: g.art_cache_mb,
|
||||||
}
|
}
|
||||||
let text = toml::to_string_pretty(self)?;
|
|
||||||
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
|
||||||
// Passwords may live in here.
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn apply(self, cfg: &mut Config) {
|
||||||
|
let g = &mut cfg.general;
|
||||||
|
g.schedule = self.schedule;
|
||||||
|
g.max_total_gb = self.max_total_gb;
|
||||||
|
g.max_age_days = self.max_age_days;
|
||||||
|
g.max_new_per_check = self.max_new_per_check;
|
||||||
|
g.media_types = self.media_types;
|
||||||
|
g.art_cache_mb = self.art_cache_mb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// config.toml as it is kept once the database holds the feeds and server settings: the same
|
||||||
|
/// file without `[feeds]` or the `[general]` keys in `Stored`.
|
||||||
|
pub fn save_bootstrap(&self, path: &Path) -> Result<()> {
|
||||||
|
let mut v = toml::Value::try_from(self)?;
|
||||||
|
if let Some(t) = v.as_table_mut() {
|
||||||
|
t.remove("feeds");
|
||||||
|
if let Some(g) = t.get_mut("general").and_then(|g| g.as_table_mut()) {
|
||||||
|
for k in STORED_KEYS {
|
||||||
|
g.remove(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_private(path, &toml::to_string_pretty(&v)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether config.toml still lists feeds or server settings, which the database now holds:
|
||||||
|
/// an edit there would otherwise go unnoticed.
|
||||||
|
pub fn file_holds_stored(path: &Path) -> bool {
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else { return false };
|
||||||
|
let Ok(v) = text.parse::<toml::Table>() else { return false };
|
||||||
|
v.get("feeds").and_then(|f| f.as_table()).is_some_and(|f| !f.is_empty())
|
||||||
|
|| v.get("general")
|
||||||
|
.and_then(|g| g.as_table())
|
||||||
|
.is_some_and(|g| STORED_KEYS.iter().any(|k| g.contains_key(*k)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a config file readable by its owner alone: feed passwords have lived in it.
|
||||||
|
fn write_private(path: &Path, text: &str) -> Result<()> {
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
/// `$IPX_CONFIG`, else `$XDG_CONFIG_HOME/ipx/config.toml`.
|
||||||
@@ -366,6 +469,36 @@ fn expand_tilde(p: &Path) -> PathBuf {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_file_kept_beside_the_database_has_no_feeds_or_server_settings() {
|
||||||
|
let cfg: Config = toml::from_str(
|
||||||
|
r#"
|
||||||
|
[general]
|
||||||
|
download_dir = "/downloads"
|
||||||
|
schedule = "every 2h"
|
||||||
|
max_new_per_check = 7
|
||||||
|
media_types = ["audio"]
|
||||||
|
[web]
|
||||||
|
bind = "0.0.0.0:8099"
|
||||||
|
token = "t"
|
||||||
|
[feeds.show]
|
||||||
|
url = "http://x/show.xml"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let path = std::env::temp_dir().join(format!("ipx-bootstrap-{}.toml", std::process::id()));
|
||||||
|
std::fs::write(&path, toml::to_string(&cfg).unwrap()).unwrap();
|
||||||
|
assert!(Config::file_holds_stored(&path), "a whole config.toml holds them");
|
||||||
|
cfg.save_bootstrap(&path).unwrap();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(!Config::file_holds_stored(&path), "{text}");
|
||||||
|
let back: Config = toml::from_str(&text).unwrap();
|
||||||
|
assert!(back.feeds.is_empty());
|
||||||
|
assert_eq!(back.web.token, "t", "who may sign in stays in the file");
|
||||||
|
assert_eq!(back.general.download_dir, PathBuf::from("/downloads"), "where things are, too");
|
||||||
|
std::fs::remove_file(&path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_a_config_and_applies_defaults() {
|
fn parses_a_config_and_applies_defaults() {
|
||||||
let cfg: Config = toml::from_str(
|
let cfg: Config = toml::from_str(
|
||||||
@@ -456,6 +589,12 @@ mod tests {
|
|||||||
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
assert!(wanted_media(Some("image/jpeg"), &["image/jpeg".to_string()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_saved_before_the_art_cache_keep_the_default() {
|
||||||
|
let old = r#"{"schedule":"every 1h","max_total_gb":0.0,"max_age_days":0,"max_new_per_check":3,"media_types":["audio"]}"#;
|
||||||
|
assert_eq!(serde_json::from_str::<Stored>(old).unwrap().art_cache_mb, 500);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slugs_are_readable_and_unique() {
|
fn slugs_are_readable_and_unique() {
|
||||||
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
assert_eq!(slug("Accidental Tech Podcast"), "accidental-tech-podcast");
|
||||||
@@ -469,7 +608,7 @@ mod tests {
|
|||||||
taken.insert("the-daily".to_string(), Feed {
|
taken.insert("the-daily".to_string(), Feed {
|
||||||
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
url: "u".into(), folder: None, group: None, media_types: None, schedule: None, keywords: vec![], allow_explicit: false,
|
||||||
auto_download: true, max_new_per_check: None, username: None,
|
auto_download: true, max_new_per_check: None, username: None,
|
||||||
password: None, password_env: None,
|
password: None, password_env: None, category: None, listed: false,
|
||||||
});
|
});
|
||||||
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
assert_eq!(unique_slug("The Daily", &taken), "the-daily-2");
|
||||||
}
|
}
|
||||||
@@ -489,6 +628,8 @@ mod tests {
|
|||||||
username: Some("ray".into()),
|
username: Some("ray".into()),
|
||||||
password: Some("literal".into()),
|
password: Some("literal".into()),
|
||||||
password_env: None,
|
password_env: None,
|
||||||
|
category: None,
|
||||||
|
listed: false,
|
||||||
};
|
};
|
||||||
assert_eq!(f.password().as_deref(), Some("literal"));
|
assert_eq!(f.password().as_deref(), Some("literal"));
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,25 @@ pub fn matches_keywords(keywords: &[String], haystacks: &[&str]) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether any of these words or phrases appears, as whole words, in the haystacks. Whole words,
|
||||||
|
/// unlike `matches_keywords`, because a block hides things: "ai" should not hide everything that
|
||||||
|
/// "said" something. Case and punctuation are ignored, so "A.I." is not caught by "ai", but
|
||||||
|
/// "Trump's" is by "trump".
|
||||||
|
pub fn blocked(words: &[String], haystacks: &[&str]) -> bool {
|
||||||
|
// Letters and digits only, each run of anything else one space, padded so a phrase matches
|
||||||
|
// at either end: " new york " is in " the new york times ", " york " is not in " yorkshire ".
|
||||||
|
let norm = |s: &str| {
|
||||||
|
let mut out = String::from(" ");
|
||||||
|
for w in s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()) {
|
||||||
|
out.push_str(&w.to_lowercase());
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let hay = norm(&haystacks.join(" "));
|
||||||
|
words.iter().map(|w| norm(w)).any(|w| w.len() > 1 && hay.contains(&w))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -387,7 +406,7 @@ mod tests {
|
|||||||
let mut f = crate::config::Feed {
|
let mut f = crate::config::Feed {
|
||||||
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None, media_types: None,
|
||||||
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
|
||||||
max_new_per_check: None, username: None, password: None, password_env: None,
|
max_new_per_check: None, username: None, password: None, password_env: None, category: None, listed: false,
|
||||||
};
|
};
|
||||||
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
|
||||||
|
|
||||||
@@ -404,4 +423,16 @@ mod tests {
|
|||||||
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
assert!(!matches_keywords(&kws, &["Just a dive"]), "half a keyword is not a match");
|
||||||
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
assert!(matches_keywords(&[], &["anything"]), "no keywords means take everything");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocking_matches_whole_words_and_phrases() {
|
||||||
|
let words = vec!["AI".to_string(), "new york".to_string()];
|
||||||
|
assert!(blocked(&words, &["What AI means now"]));
|
||||||
|
assert!(blocked(&words, &["<p>ai, again</p>"]), "markup and punctuation are not words");
|
||||||
|
assert!(!blocked(&words, &["He said so", "Portrait"]), "not inside another word");
|
||||||
|
assert!(blocked(&words, &["Live from", "New York."]), "a phrase spans runs of space");
|
||||||
|
assert!(!blocked(&words, &["New Yorkshire"]));
|
||||||
|
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
|
||||||
|
assert!(!blocked(&[], &["anything"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
329
src/entity.rs
Normal file
329
src/entity.rs
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
||||||
|
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
||||||
|
//! `db::create_missing`). Times are Unix seconds.
|
||||||
|
|
||||||
|
pub mod feeds {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "feeds")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// The channel's first <itunes:category>, for the Directory.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub category: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub etag: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_modified: Option<String>,
|
||||||
|
pub last_checked: Option<i64>,
|
||||||
|
pub ttl_mins: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
||||||
|
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
||||||
|
/// later) from a feed that has been down for a day.
|
||||||
|
pub error_since: Option<i64>,
|
||||||
|
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub orphaned: bool,
|
||||||
|
/// The OPML subscription this feed came from.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub group_id: Option<String>,
|
||||||
|
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
||||||
|
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
||||||
|
/// are re-derived instead. Customising one promotes it to config.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub managed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod entries {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[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,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub link: Option<String>,
|
||||||
|
pub published: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub first_seen: i64,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
pub episode: Option<i64>,
|
||||||
|
pub season: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod enclosures {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "enclosures")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub guid: String,
|
||||||
|
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
||||||
|
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
||||||
|
#[sea_orm(unique, column_type = "Text")]
|
||||||
|
pub url: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub mime: Option<String>,
|
||||||
|
pub length: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
|
||||||
|
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
|
||||||
|
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
|
||||||
|
/// why), then 'downloading', 'done', 'error', 'reaped'.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub state: String,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub bytes_done: i64,
|
||||||
|
pub downloaded_at: Option<i64>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod users {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "users")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i64,
|
||||||
|
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
||||||
|
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
||||||
|
/// check, and leaving it empty is not the same as leaving it unset.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub pass_hash: Option<String>,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub is_admin: bool,
|
||||||
|
/// For whoever maintains the server. NULL where it is not known.
|
||||||
|
pub created: Option<i64>,
|
||||||
|
pub last_login: Option<i64>,
|
||||||
|
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub theme_mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table of one person's rows, gone when they are.
|
||||||
|
macro_rules! owned_by_user {
|
||||||
|
() => {
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
User,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
||||||
|
/// part that is not. NULL in a column means: follow the feed's own setting.
|
||||||
|
pub mod subscriptions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "subscriptions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings; NULL follows the feed.
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub keywords: Option<String>,
|
||||||
|
pub auto_download: Option<bool>,
|
||||||
|
pub allow_explicit: Option<bool>,
|
||||||
|
pub max_new_per_check: Option<i64>,
|
||||||
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
||||||
|
/// nobody has touched has no row at all, which is what unread means.
|
||||||
|
pub mod entry_state {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "entry_state")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_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,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub read: bool,
|
||||||
|
#[sea_orm(default_value = false)]
|
||||||
|
pub flagged: bool,
|
||||||
|
#[sea_orm(default_value = 0)]
|
||||||
|
pub position: i64,
|
||||||
|
/// The length this person's player measured, beside the position it is measured against.
|
||||||
|
pub duration: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
||||||
|
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
||||||
|
/// every feed they read.
|
||||||
|
pub mod blocklists {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "blocklists")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_id: i64,
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub feed_id: String,
|
||||||
|
/// JSON array of strings.
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub words: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
||||||
|
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
||||||
|
pub mod hidden {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "hidden")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub user_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,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod sessions {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "sessions")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: i64,
|
||||||
|
pub seen: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
owned_by_user!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
||||||
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
||||||
|
pub mod catalogue {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "catalogue")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub id: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub spec: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
||||||
|
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
||||||
|
/// configuration has moved in (issue #18).
|
||||||
|
pub mod settings {
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
||||||
|
pub name: String,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
}
|
||||||
541
src/feed.rs
541
src/feed.rs
@@ -11,6 +11,10 @@ pub struct ParsedFeed {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
|
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||||
|
pub site: Option<String>,
|
||||||
|
/// The channel's first `<itunes:category>`, for the Directory's chips.
|
||||||
|
pub category: Option<String>,
|
||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,42 +53,72 @@ pub enum Fetched {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conditional GET. reqwest handles gzip and redirects; the original's hand-rolled
|
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||||
/// CONNECT/socket.ssl proxy path is gone -- `system-proxy` reads http_proxy/https_proxy.
|
/// with no limit one hung server held every scan for as long as it did. Dreamwidth answered 504
|
||||||
|
/// after 60-67 s for a day, and each scan took 70-80 s instead of 15 (#108). A feed is small;
|
||||||
|
/// downloads, which are not, have no such limit.
|
||||||
|
pub const FEED_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||||
|
/// each hop is seen. The second value is where the feed now is when every hop said so for good
|
||||||
|
/// (301 or 308): a publisher that moved its feed, which the catalogue should follow rather
|
||||||
|
/// than be redirected on every read. A temporary redirect (302, 307) moves nothing.
|
||||||
|
/// `system-proxy` reads http_proxy/https_proxy.
|
||||||
|
#[tracing::instrument(skip_all, fields(url = %cfg.url))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
cfg: &FeedCfg,
|
cfg: &FeedCfg,
|
||||||
etag: Option<&str>,
|
etag: Option<&str>,
|
||||||
last_modified: Option<&str>,
|
last_modified: Option<&str>,
|
||||||
) -> Result<Fetched> {
|
) -> Result<(Fetched, Option<String>)> {
|
||||||
let mut req = client.get(&cfg.url);
|
let start = reqwest::Url::parse(&cfg.url).context("the feed's address")?;
|
||||||
if let Some(tag) = etag {
|
let mut url = start.clone();
|
||||||
req = req.header(IF_NONE_MATCH, tag);
|
let mut permanent = true;
|
||||||
}
|
for _ in 0..10 {
|
||||||
if let Some(lm) = last_modified {
|
let mut req = client.get(url.clone()).timeout(FEED_TIMEOUT);
|
||||||
req = req.header(IF_MODIFIED_SINCE, lm);
|
if let Some(tag) = etag {
|
||||||
}
|
req = req.header(IF_NONE_MATCH, tag);
|
||||||
if let Some(user) = &cfg.username {
|
}
|
||||||
req = req.basic_auth(user, cfg.password());
|
if let Some(lm) = last_modified {
|
||||||
}
|
req = req.header(IF_MODIFIED_SINCE, lm);
|
||||||
|
}
|
||||||
|
// The feed's own host only: a redirect elsewhere must not be handed the password.
|
||||||
|
if let Some(user) = &cfg.username
|
||||||
|
&& url.host_str() == start.host_str()
|
||||||
|
{
|
||||||
|
req = req.basic_auth(user, cfg.password());
|
||||||
|
}
|
||||||
|
|
||||||
let resp = req.send().await.context("connecting")?;
|
let resp = req.send().await.context("connecting")?;
|
||||||
if resp.status() == StatusCode::NOT_MODIFIED {
|
let status = resp.status();
|
||||||
return Ok(Fetched::NotModified);
|
if status.is_redirection() && status != StatusCode::NOT_MODIFIED {
|
||||||
|
let to = resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| anyhow!("HTTP {status} without a Location to go to"))?;
|
||||||
|
url = url.join(to).with_context(|| format!("redirected to {to:?}, which is not an address"))?;
|
||||||
|
permanent &= matches!(status, StatusCode::MOVED_PERMANENTLY | StatusCode::PERMANENT_REDIRECT);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let moved = (permanent && url != start).then(|| url.to_string());
|
||||||
|
if status == StatusCode::NOT_MODIFIED {
|
||||||
|
return Ok((Fetched::NotModified, moved));
|
||||||
|
}
|
||||||
|
if !status.is_success() {
|
||||||
|
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
||||||
|
return Err(anyhow!("HTTP {status}"));
|
||||||
|
}
|
||||||
|
let header = |h: reqwest::header::HeaderName| {
|
||||||
|
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
||||||
|
};
|
||||||
|
let etag = header(ETAG);
|
||||||
|
let last_modified = header(LAST_MODIFIED);
|
||||||
|
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
||||||
|
return Ok((Fetched::Body { bytes, etag, last_modified }, moved));
|
||||||
}
|
}
|
||||||
let status = resp.status();
|
// Worded as reqwest worded it, which failure_kind reads as a redirect loop.
|
||||||
if !status.is_success() {
|
Err(anyhow!("error following redirect for url ({url}): too many redirects"))
|
||||||
// The original surfaced 401/407 specially; the code is enough for a UI to switch on.
|
|
||||||
return Err(anyhow!("HTTP {status}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let header = |h: reqwest::header::HeaderName| {
|
|
||||||
resp.headers().get(&h).and_then(|v| v.to_str().ok()).map(str::to_owned)
|
|
||||||
};
|
|
||||||
let etag = header(ETAG);
|
|
||||||
let last_modified = header(LAST_MODIFIED);
|
|
||||||
let bytes = resp.bytes().await.context("reading body")?.to_vec();
|
|
||||||
Ok(Fetched::Body { bytes, etag, last_modified })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||||
@@ -94,6 +128,37 @@ pub struct Failure {
|
|||||||
pub new_url: Option<String>,
|
pub new_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A failure's kind, for the log's `error.type` (#91): the HTTP status where there is one, as
|
||||||
|
/// OpenTelemetry names an HTTP error, and otherwise a word for what went wrong. Matches the
|
||||||
|
/// same wording as `explain_failure`; a message it does not know is "other", never a wrong kind.
|
||||||
|
pub fn failure_kind(msg: &str) -> (String, Option<u16>) {
|
||||||
|
let low = msg.to_ascii_lowercase();
|
||||||
|
let code = low.split("http ").skip(1).find_map(|r| r.get(..3)?.parse::<u16>().ok());
|
||||||
|
if let Some(c) = code.filter(|c| (100..600).contains(c)) {
|
||||||
|
return (c.to_string(), Some(c));
|
||||||
|
}
|
||||||
|
let kind = if low.contains("dns error") || low.contains("failed to lookup address") || low.contains("no address associated") {
|
||||||
|
"dns"
|
||||||
|
} else if low.contains("too many redirects") {
|
||||||
|
"redirect_loop"
|
||||||
|
} else if low.contains("timed out") || low.contains("timeout") {
|
||||||
|
"timeout"
|
||||||
|
} else if low.contains("certificate") || low.contains("tls") {
|
||||||
|
"tls"
|
||||||
|
} else if low.contains("got a web page") {
|
||||||
|
"not_a_feed"
|
||||||
|
} else if low.contains("the site sent ") {
|
||||||
|
"site_message"
|
||||||
|
} else if low.contains("connect") {
|
||||||
|
"connect"
|
||||||
|
} else if low.contains("pars") {
|
||||||
|
"parse"
|
||||||
|
} else {
|
||||||
|
"other"
|
||||||
|
};
|
||||||
|
(kind.into(), None)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
/// Reads a `last_error` the same way `set_feed_error` received it (`format!("{e:#}")` on the
|
||||||
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
/// anyhow chain from `fetch` or `parse`) and says what it means, for the errors worth telling
|
||||||
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
/// someone about. Everything else -- a timeout, a 5xx, a 429, a feed that is simply garbled --
|
||||||
@@ -183,11 +248,20 @@ pub fn is_patreon_creator(url: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
/// What was typed into Add feed, as a URL. A bare Patreon token is taken as its creator's
|
||||||
/// feed, since the token alone says whose it is.
|
/// feed, since the token alone says whose it is. An address with no scheme is https: 'cnn.com'
|
||||||
|
/// was stored as typed and every check failed with "relative URL without a base" (#101).
|
||||||
pub fn expand_input(input: &str) -> String {
|
pub fn expand_input(input: &str) -> String {
|
||||||
let s = input.trim();
|
let s = input.trim();
|
||||||
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
let token = s.len() >= 20 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||||
if token { format!("https://www.patreon.com/rss?auth={s}") } else { s.to_owned() }
|
if token {
|
||||||
|
format!("https://www.patreon.com/rss?auth={s}")
|
||||||
|
} else if let Some(rest) = s.strip_prefix("//") {
|
||||||
|
format!("https://{rest}")
|
||||||
|
} else if !s.contains("://") {
|
||||||
|
format!("https://{s}")
|
||||||
|
} else {
|
||||||
|
s.to_owned()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
/// Whether two URLs are the same feed. One Patreon show has several spellings -- by the
|
||||||
@@ -208,7 +282,7 @@ pub async fn patreon_shows(
|
|||||||
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
) -> Result<(Option<String>, Vec<(String, String)>)> {
|
||||||
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
// The creator feed names its campaign by number in its self link, a few hundred bytes in.
|
||||||
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
// The whole feed runs to megabytes and Patreon ignores Range, so read until it turns up.
|
||||||
let mut resp = client.get(url).send().await.context("connecting")?;
|
let mut resp = client.get(url).timeout(FEED_TIMEOUT).send().await.context("connecting")?;
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
return Err(anyhow!("Patreon refused the feed: HTTP {}", resp.status()));
|
||||||
}
|
}
|
||||||
@@ -328,7 +402,9 @@ fn plain_text(bytes: &[u8]) -> Option<String> {
|
|||||||
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
/// Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and Daily Kos.
|
||||||
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
||||||
let text = String::from_utf8_lossy(bytes);
|
let text = String::from_utf8_lossy(bytes);
|
||||||
let lower = text.to_lowercase();
|
// ASCII only: to_lowercase changes some characters' length (U+0130 grows a byte), and the
|
||||||
|
// offsets found in the lowered copy then sliced the original off a char boundary.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
let mut pos = 0;
|
let mut pos = 0;
|
||||||
while let Some(rel) = lower[pos..].find("<link") {
|
while let Some(rel) = lower[pos..].find("<link") {
|
||||||
let start = pos + rel;
|
let start = pos + rel;
|
||||||
@@ -347,6 +423,124 @@ fn alternate_feed_link(bytes: &[u8]) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What someone asked to add, as a feed: the address itself when it is a feed or an OPML list,
|
||||||
|
/// otherwise the feed its web page links as its own, otherwise an error and nothing is added.
|
||||||
|
/// A page that linked no feed used to be added as it was and failed on every check, called a
|
||||||
|
/// feed that moved (#102); cnn.com is one.
|
||||||
|
pub async fn find_feed(client: &reqwest::Client, url: &str) -> Result<String> {
|
||||||
|
let read = |u: String| async move {
|
||||||
|
let resp = client
|
||||||
|
.get(&u)
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("connecting")?;
|
||||||
|
anyhow::ensure!(resp.status().is_success(), "HTTP {}", resp.status());
|
||||||
|
let base = resp.url().clone();
|
||||||
|
anyhow::Ok((base, resp.bytes().await.context("reading")?))
|
||||||
|
};
|
||||||
|
let is_feed = |b: &[u8]| is_opml(b) || parse(b).is_ok();
|
||||||
|
let (base, body) = read(url.to_owned()).await.with_context(|| format!("could not read {url}"))?;
|
||||||
|
if is_feed(&body) {
|
||||||
|
return Ok(url.to_owned());
|
||||||
|
}
|
||||||
|
if !looks_like_html(&body) {
|
||||||
|
let why = parse(&body).err().map(|e| format!("{e:#}")).unwrap_or_default();
|
||||||
|
anyhow::bail!("{url} is not a feed: {why}");
|
||||||
|
}
|
||||||
|
let Some(href) = alternate_feed_link(&body) else {
|
||||||
|
anyhow::bail!("{url} is a web page that links no feed, so there is nothing to subscribe to");
|
||||||
|
};
|
||||||
|
let linked = base.join(&href).with_context(|| format!("{url} links {href} as its feed, which is not an address"))?;
|
||||||
|
let (_, feed) = read(linked.to_string()).await.with_context(|| format!("{url} links {linked} as its feed, but"))?;
|
||||||
|
anyhow::ensure!(is_feed(&feed), "{url} links {linked} as its feed, but that is not a feed either");
|
||||||
|
Ok(linked.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork for a feed that has none: the icon its site's page names, or else the site's
|
||||||
|
/// `/favicon.ico`. None if neither is there.
|
||||||
|
#[tracing::instrument(skip_all, fields(site = site))]
|
||||||
|
pub async fn site_icon(client: &reqwest::Client, site: &str) -> Option<String> {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let resp = client.get(site).timeout(timeout).send().await.ok()?;
|
||||||
|
// Relative to where the page ended up, not where it was asked for: a site that redirects
|
||||||
|
// to /en/ would otherwise have its icon looked for in the wrong place.
|
||||||
|
let base = resp.url().clone();
|
||||||
|
// The icon a page names can be gone: antirez.com names /images/favicon.png, a 404, while its
|
||||||
|
// /favicon.ico is there. Stored unchecked, it was a broken image that was never looked up again.
|
||||||
|
if resp.status().is_success()
|
||||||
|
&& let Ok(page) = resp.bytes().await
|
||||||
|
&& let Some(href) = page_icon(&page)
|
||||||
|
&& let Ok(url) = base.join(&href)
|
||||||
|
&& is_image(client, url.as_str()).await
|
||||||
|
{
|
||||||
|
return Some(url.into());
|
||||||
|
}
|
||||||
|
let ico = base.join("/favicon.ico").ok()?;
|
||||||
|
is_image(client, ico.as_str()).await.then(|| ico.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork's address on https when its host serves it there, else as it was. The page is https
|
||||||
|
/// and must not load http; the host is asked once per `known` (one feed's read), and an http
|
||||||
|
/// address it does not serve on https stays, for /api/art to fetch (#90). Four of the five
|
||||||
|
/// hosts the catalogue had on http served the same image on https; The Secret Cabal's CDN
|
||||||
|
/// presents another name's certificate (#110).
|
||||||
|
pub async fn prefer_https(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
known: &mut std::collections::HashMap<String, bool>,
|
||||||
|
) -> String {
|
||||||
|
let Some(rest) = url.strip_prefix("http://") else { return url.to_owned() };
|
||||||
|
let host = rest.split('/').next().unwrap_or("").to_owned();
|
||||||
|
let secure = format!("https://{rest}");
|
||||||
|
let ok = match known.get(&host) {
|
||||||
|
Some(ok) => *ok,
|
||||||
|
None => {
|
||||||
|
let ok = is_image(client, &secure).await;
|
||||||
|
known.insert(host, ok);
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if ok { secure } else { url.to_owned() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `url` answers with an image. A site with no favicon often answers 200 with its home
|
||||||
|
/// page, which is not an icon.
|
||||||
|
pub async fn is_image(client: &reqwest::Client, url: &str) -> bool {
|
||||||
|
let timeout = std::time::Duration::from_secs(20);
|
||||||
|
let Ok(resp) = client.get(url).timeout(timeout).send().await else { return false };
|
||||||
|
resp.status().is_success()
|
||||||
|
&& resp
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|t| t.starts_with("image/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The icon a web page names in its `<link>` tags, the larger apple-touch-icon first: a plain
|
||||||
|
/// `icon` is often 16 pixels, which blurs at the size the list draws artwork.
|
||||||
|
fn page_icon(bytes: &[u8]) -> Option<String> {
|
||||||
|
let text = String::from_utf8_lossy(bytes);
|
||||||
|
// ASCII only, so byte offsets in the lowered copy stay valid in the original.
|
||||||
|
let lower = text.to_ascii_lowercase();
|
||||||
|
let (mut touch, mut icon) = (None, None);
|
||||||
|
let mut pos = 0;
|
||||||
|
while let Some(at) = lower[pos..].find("<link") {
|
||||||
|
let start = pos + at;
|
||||||
|
let Some(end) = lower[start..].find('>').map(|e| start + e) else { break };
|
||||||
|
pos = end + 1;
|
||||||
|
let tag = &text[start..end];
|
||||||
|
let Some(rel) = tag_attr(tag, "rel").map(|r| r.to_ascii_lowercase()) else { continue };
|
||||||
|
let rels: Vec<&str> = rel.split_whitespace().collect();
|
||||||
|
if touch.is_none() && rels.iter().any(|r| r.starts_with("apple-touch-icon")) {
|
||||||
|
touch = tag_attr(tag, "href");
|
||||||
|
} else if icon.is_none() && rels.contains(&"icon") {
|
||||||
|
icon = tag_attr(tag, "href");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
touch.or(icon).filter(|h| !h.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
/// The value of one attribute in an HTML/XML start tag, however it is quoted.
|
||||||
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
fn tag_attr(tag: &str, name: &str) -> Option<String> {
|
||||||
let key = format!("{name}=");
|
let key = format!("{name}=");
|
||||||
@@ -478,7 +672,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter_map(|(idx, item)| {
|
.filter_map(|(idx, item)| {
|
||||||
// Straight from the XML, so an item with several keeps all of them. Falls
|
// Straight from the XML, so an item with several keeps all of them. Falls
|
||||||
// back to the parsed one if the scan and the parser disagree on item count.
|
// back to the parsed one if the scan and the parser disagree on item count.
|
||||||
let enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
let mut enclosures: Vec<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
|
||||||
item.enclosure()
|
item.enclosure()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|e| Enclosure {
|
.map(|e| Enclosure {
|
||||||
@@ -489,6 +683,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let guid = pick_guid(
|
let guid = pick_guid(
|
||||||
item.guid().map(|g| g.value()),
|
item.guid().map(|g| g.value()),
|
||||||
@@ -505,7 +700,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(item.title()),
|
title: title_text(item.title()),
|
||||||
link: non_empty(item.link()),
|
link: non_empty(item.link()),
|
||||||
published: item.pub_date().and_then(parse_date),
|
published: item.pub_date().and_then(parse_date),
|
||||||
// Content wins over description, as __getEntries preferred entry.content.
|
// Content wins over description, as __getEntries preferred entry.content.
|
||||||
@@ -527,7 +722,7 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(ch.title())),
|
title: title_text(Some(ch.title())),
|
||||||
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
ttl_mins: ch.ttl().and_then(|t| t.trim().parse().ok()),
|
||||||
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
// itunes:image is the square artwork; <image><url> is the older, often smaller one.
|
||||||
image: ch
|
image: ch
|
||||||
@@ -535,6 +730,15 @@ fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed {
|
|||||||
.and_then(|i| i.image())
|
.and_then(|i| i.image())
|
||||||
.map(str::to_owned)
|
.map(str::to_owned)
|
||||||
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
.or_else(|| ch.image().map(|i| i.url().to_owned())),
|
||||||
|
site: non_empty(Some(ch.link().trim())),
|
||||||
|
// Only the iTunes one: Apple's list is fixed, while a plain <category> is freeform and
|
||||||
|
// would fill the Directory with one-off tags. The subcategory where there is one: Apple
|
||||||
|
// files every tabletop and gaming show under Leisure, which says little; Games says it.
|
||||||
|
category: ch
|
||||||
|
.itunes_ext()
|
||||||
|
.and_then(|i| i.categories().first())
|
||||||
|
.map(|c| c.subcategory().filter(|s| !s.text().trim().is_empty()).unwrap_or(c))
|
||||||
|
.and_then(|c| non_empty(Some(c.text().trim()))),
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,7 +749,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|e| {
|
.filter_map(|e| {
|
||||||
// Atom carries enclosures as <link rel="enclosure">.
|
// Atom carries enclosures as <link rel="enclosure">.
|
||||||
let enclosures: Vec<Enclosure> = e
|
let mut enclosures: Vec<Enclosure> = e
|
||||||
.links()
|
.links()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.rel() == "enclosure")
|
.filter(|l| l.rel() == "enclosure")
|
||||||
@@ -556,6 +760,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
})
|
})
|
||||||
.filter(|e| !e.url.is_empty())
|
.filter(|e| !e.url.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
drop_player_repeats(&mut enclosures);
|
||||||
|
|
||||||
let alt = e
|
let alt = e
|
||||||
.links()
|
.links()
|
||||||
@@ -572,7 +777,7 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
|
|
||||||
Some(Entry {
|
Some(Entry {
|
||||||
guid,
|
guid,
|
||||||
title: non_empty(Some(e.title().as_str())),
|
title: title_text(Some(e.title().as_str())),
|
||||||
link: alt.map(str::to_owned),
|
link: alt.map(str::to_owned),
|
||||||
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
published: e.published().or(Some(e.updated())).map(|d| d.timestamp()),
|
||||||
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
description: body(e.content().and_then(|c| c.value()), e.summary().map(|s| s.as_str())),
|
||||||
@@ -588,9 +793,16 @@ fn from_atom(feed: atom_syndication::Feed) -> ParsedFeed {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
ParsedFeed {
|
ParsedFeed {
|
||||||
title: non_empty(Some(feed.title().as_str())),
|
title: title_text(Some(feed.title().as_str())),
|
||||||
ttl_mins: None,
|
ttl_mins: None,
|
||||||
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
image: feed.logo().or_else(|| feed.icon()).map(str::to_owned),
|
||||||
|
site: feed
|
||||||
|
.links()
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.rel() == "alternate")
|
||||||
|
.map(|l| l.href().trim().to_owned())
|
||||||
|
.filter(|h| !h.is_empty()),
|
||||||
|
category: None,
|
||||||
entries,
|
entries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -620,6 +832,64 @@ fn non_empty(s: Option<&str>) -> Option<String> {
|
|||||||
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
s.map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// WordPress numbers each audio player on a page by adding `?_=N` to its file's URL, so a post
|
||||||
|
/// that embeds the file it encloses lists the same file twice: Rands in Repose's "The Promotion
|
||||||
|
/// Paradox" was downloaded twice and offered two play buttons for one mp3. The first stays.
|
||||||
|
fn drop_player_repeats(encs: &mut Vec<Enclosure>) {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
encs.retain(|e| seen.insert(same_file_key(&e.url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An enclosure URL without WordPress's player number, for telling repeats of one file apart
|
||||||
|
/// from different files.
|
||||||
|
pub fn same_file_key(url: &str) -> String {
|
||||||
|
let Ok(mut u) = url::Url::parse(url) else { return url.to_owned() };
|
||||||
|
let kept: Vec<(String, String)> = u
|
||||||
|
.query_pairs()
|
||||||
|
.filter(|(k, v)| !(k == "_" && !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit())))
|
||||||
|
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||||
|
.collect();
|
||||||
|
if kept.is_empty() {
|
||||||
|
u.set_query(None);
|
||||||
|
} else {
|
||||||
|
u.query_pairs_mut().clear().extend_pairs(kept);
|
||||||
|
}
|
||||||
|
u.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A title as plain text. An Atom title of `type="html"`, or an RSS one in CDATA, comes through
|
||||||
|
/// the XML parser with its HTML entities intact: The Verge's "Meta’s" reached the page as
|
||||||
|
/// typed. Decoded one entity at a time, so an `&` that starts none, as in "Q&A", stays as it is
|
||||||
|
/// instead of failing the whole title.
|
||||||
|
fn title_text(s: Option<&str>) -> Option<String> {
|
||||||
|
let s = non_empty(s)?;
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut rest = s.as_str();
|
||||||
|
while let Some(at) = rest.find('&') {
|
||||||
|
out.push_str(&rest[..at]);
|
||||||
|
rest = &rest[at..];
|
||||||
|
let len = 1 + rest[1..]
|
||||||
|
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '#'))
|
||||||
|
.unwrap_or(rest.len() - 1);
|
||||||
|
let decoded = rest[len..]
|
||||||
|
.starts_with(';')
|
||||||
|
.then(|| quick_xml::escape::unescape_with(&rest[..=len], quick_xml::escape::resolve_html5_entity).ok())
|
||||||
|
.flatten();
|
||||||
|
match decoded {
|
||||||
|
Some(v) => {
|
||||||
|
out.push_str(&v);
|
||||||
|
rest = &rest[len + 1..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push('&');
|
||||||
|
rest = &rest[1..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
non_empty(Some(&out))
|
||||||
|
}
|
||||||
|
|
||||||
/// An item's show notes: its full body when that is whole, else its description.
|
/// An item's show notes: its full body when that is whole, else its description.
|
||||||
///
|
///
|
||||||
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
/// libsyn served Daily Meditation Podcast's `content:encoded` cut at the `>` inside a class name
|
||||||
@@ -627,10 +897,51 @@ fn non_empty(s: Option<&str>) -> Option<String> {
|
|||||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -699,6 +1010,54 @@ fn parse_date(s: &str) -> Option<i64> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// A server answering by path: /old moves for good to /new, /tmp for now, /chain for good
|
||||||
|
/// to /tmp, /new is the feed.
|
||||||
|
async fn redirecting_server() -> String {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((mut sock, _)) = listener.accept().await else { return };
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
let req = String::from_utf8_lossy(&buf[..n]);
|
||||||
|
let path = req.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
||||||
|
let body = "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>T</title></channel></rss>";
|
||||||
|
let resp = match path.as_str() {
|
||||||
|
"/old" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/tmp" => "HTTP/1.1 302 Found\r\nLocation: /new\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/chain" => "HTTP/1.1 308 Permanent Redirect\r\nLocation: /tmp\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
"/loop" => "HTTP/1.1 301 Moved Permanently\r\nLocation: /loop\r\nContent-Length: 0\r\n\r\n".to_owned(),
|
||||||
|
_ => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}", body.len()),
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(resp.as_bytes()).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_feed_that_moved_for_good_says_where_and_one_moved_for_now_does_not() {
|
||||||
|
let base = redirecting_server().await;
|
||||||
|
let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()).build().unwrap();
|
||||||
|
let get = |path: &str| {
|
||||||
|
let cfg: crate::config::Feed = serde_json::from_value(serde_json::json!({ "url": format!("{base}{path}") })).unwrap();
|
||||||
|
let client = client.clone();
|
||||||
|
async move { super::fetch(&client, &cfg, None, None).await }
|
||||||
|
};
|
||||||
|
let (got, moved) = get("/old").await.unwrap();
|
||||||
|
assert!(matches!(got, super::Fetched::Body { .. }));
|
||||||
|
assert_eq!(moved, Some(format!("{base}/new")));
|
||||||
|
assert_eq!(get("/tmp").await.unwrap().1, None); // 302: for now
|
||||||
|
assert_eq!(get("/chain").await.unwrap().1, None); // 308 then 302: not for good
|
||||||
|
assert_eq!(get("/new").await.unwrap().1, None); // never moved
|
||||||
|
let looped = get("/loop").await.err().unwrap().to_string();
|
||||||
|
assert_eq!(super::failure_kind(&looped).0, "redirect_loop");
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -708,6 +1067,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
assert_eq!(feed.title.as_deref(), Some("Test Cast"));
|
||||||
assert_eq!(feed.ttl_mins, Some(45));
|
assert_eq!(feed.ttl_mins, Some(45));
|
||||||
|
assert_eq!(feed.category.as_deref(), Some("Podcasting"), "the first, by its subcategory");
|
||||||
assert_eq!(feed.entries.len(), 3);
|
assert_eq!(feed.entries.len(), 3);
|
||||||
|
|
||||||
let ep = &feed.entries[0];
|
let ep = &feed.entries[0];
|
||||||
@@ -732,18 +1092,73 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_wordpress_lists_twice_is_one_enclosure() {
|
||||||
|
let xml = br#"<?xml version="1.0"?><rss version="2.0"><channel><title>R</title>
|
||||||
|
<item><title>The Promotion Paradox</title><guid>p</guid>
|
||||||
|
<enclosure url="https://x/ep.mp3" length="1" type="audio/mpeg"/>
|
||||||
|
<enclosure url="https://x/ep.mp3?_=2" length="1" type="audio/mpeg"/>
|
||||||
|
<enclosure url="https://x/other.mp3?_=3&key=k" length="1" type="audio/mpeg"/>
|
||||||
|
</item></channel></rss>"#;
|
||||||
|
let urls: Vec<String> =
|
||||||
|
parse(xml).unwrap().entries[0].enclosures.iter().map(|e| e.url.clone()).collect();
|
||||||
|
assert_eq!(urls, ["https://x/ep.mp3", "https://x/other.mp3?_=3&key=k"], "the repeat goes, a different file stays");
|
||||||
|
assert_eq!(same_file_key("https://x/a.mp3?key=k&_=2"), same_file_key("https://x/a.mp3?key=k"));
|
||||||
|
assert_ne!(same_file_key("https://x/a.mp3?_=x"), same_file_key("https://x/a.mp3"), "only a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn titles_are_read_as_text_not_html() {
|
||||||
|
// The Verge: an Atom title of type="html", its entity inside CDATA.
|
||||||
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom"><title type="text">V</title><id>v</id>
|
||||||
|
<updated>2026-09-15T00:00:00Z</updated>
|
||||||
|
<entry><title type="html"><![CDATA[Meta’s new One]]></title><id>e1</id>
|
||||||
|
<updated>2026-09-15T00:00:00Z</updated></entry></feed>"#;
|
||||||
|
assert_eq!(parse(xml).unwrap().entries[0].title.as_deref(), Some("Meta\u{2019}s new One"));
|
||||||
|
// HTML names as well as numbers; a bare `&` and an unknown name are left as they are.
|
||||||
|
assert_eq!(
|
||||||
|
title_text(Some("Peña & “Q&A” &bogus; AT&T;")).as_deref(),
|
||||||
|
Some("Pe\u{f1}a & \u{201c}Q&A\u{201d} &bogus; AT&T;")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
fn a_body_cut_off_mid_tag_gives_way_to_the_description() {
|
||||||
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
// How libsyn served Daily Meditation Podcast #3477: content:encoded began inside a tag.
|
||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||||
|
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||||
|
);
|
||||||
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
|
for d in ["This week we talk about tape, drums and a very long list.",
|
||||||
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]"] {
|
||||||
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
|
}
|
||||||
|
let long = "word ".repeat(80);
|
||||||
|
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
@@ -760,6 +1175,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_named_by_kind_for_the_log() {
|
||||||
|
let k = |m: &str| failure_kind(m);
|
||||||
|
assert_eq!(k("HTTP 404 Not Found"), ("404".into(), Some(404)));
|
||||||
|
assert_eq!(k("HTTP 503 Service Unavailable"), ("503".into(), Some(503)));
|
||||||
|
assert_eq!(
|
||||||
|
k("connecting: error following redirect for url (https://www.toddstashwick.com/): too many redirects").0,
|
||||||
|
"redirect_loop"
|
||||||
|
);
|
||||||
|
assert_eq!(k("connecting: dns error: failed to lookup address information").0, "dns");
|
||||||
|
assert_eq!(k("operation timed out").0, "timeout");
|
||||||
|
assert_eq!(k("something new").0, "other");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
fn explain_failure_translates_the_errors_the_ui_should_flag() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -925,6 +1354,10 @@ mod tests {
|
|||||||
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
let tok = "AbCdEfGhIjKlMnOpQrStUvWxYz012_-9";
|
||||||
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
assert_eq!(expand_input(&format!(" {tok} ")), format!("https://www.patreon.com/rss?auth={tok}"));
|
||||||
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
assert_eq!(expand_input("https://example.com/rss"), "https://example.com/rss");
|
||||||
|
assert_eq!(expand_input("http://example.com/rss"), "http://example.com/rss");
|
||||||
|
assert_eq!(expand_input(" cnn.com "), "https://cnn.com");
|
||||||
|
assert_eq!(expand_input("example.com/feed.xml"), "https://example.com/feed.xml");
|
||||||
|
assert_eq!(expand_input("//example.com/rss"), "https://example.com/rss");
|
||||||
|
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss/glasscannon?auth={tok}")));
|
||||||
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
assert!(is_patreon_creator(&format!("https://www.patreon.com/rss?auth={tok}")));
|
||||||
@@ -989,6 +1422,24 @@ mod tests {
|
|||||||
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_link_after_non_ascii_text_is_found() {
|
||||||
|
// U+0130 lowercases to three bytes from two, which once shifted every offset after it.
|
||||||
|
let page = "<html><title>\u{130}stanbul \u{130}\u{130}</title>\
|
||||||
|
<link rel=\"alternate\" type=\"application/rss+xml\" href=\"/feed.xml\">";
|
||||||
|
assert_eq!(alternate_feed_link(page.as_bytes()).as_deref(), Some("/feed.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn page_icon_prefers_the_touch_icon() {
|
||||||
|
let page = br#"<head><link rel="stylesheet" href="/a.css">
|
||||||
|
<link rel="shortcut icon" href="/fav.ico">
|
||||||
|
<LINK REL="apple-touch-icon-precomposed" sizes="180x180" href='/touch.png'></head>"#;
|
||||||
|
assert_eq!(page_icon(page).as_deref(), Some("/touch.png"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="icon" href="i.svg">"#).as_deref(), Some("i.svg"));
|
||||||
|
assert_eq!(page_icon(br#"<link rel="stylesheet" href="/a.css">"#), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
fn an_items_picture_comes_from_the_most_deliberate_source() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
125
src/ipc.rs
125
src/ipc.rs
@@ -15,6 +15,9 @@ pub enum Event {
|
|||||||
FeedSkip { feed: String, reason: String },
|
FeedSkip { feed: String, reason: String },
|
||||||
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
FeedDone { feed: String, new: usize, downloaded: usize, failed: usize, torrents: usize },
|
||||||
FeedError { feed: String, msg: String },
|
FeedError { feed: String, msg: String },
|
||||||
|
/// The feed answered from a new address after redirects that all said it moved for good,
|
||||||
|
/// and the catalogue now has that address (#115).
|
||||||
|
FeedMoved { feed: String, from: String, to: String },
|
||||||
Progress {
|
Progress {
|
||||||
feed: String,
|
feed: String,
|
||||||
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
/// Which enclosure this is about. Without it a UI cannot tell one download's
|
||||||
@@ -50,6 +53,7 @@ impl Event {
|
|||||||
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
"{feed}: {new} new entries, {downloaded} downloaded, {failed} failed, {torrents} torrents deferred"
|
||||||
),
|
),
|
||||||
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
Event::FeedError { feed, msg } => format!("{feed}: error: {msg}"),
|
||||||
|
Event::FeedMoved { feed, from, to } => format!("{feed}: moved for good from {from} to {to}; following it"),
|
||||||
Event::Progress { file, done, total, .. } => match total {
|
Event::Progress { file, done, total, .. } => match total {
|
||||||
Some(t) if *t > 0 => format!(
|
Some(t) if *t > 0 => format!(
|
||||||
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
" {file}: {:.1}% ({:.1}/{:.1} MB)",
|
||||||
@@ -69,7 +73,7 @@ impl Event {
|
|||||||
*bytes as f64 / 1_048_576.0
|
*bytes as f64 / 1_048_576.0
|
||||||
),
|
),
|
||||||
Event::Status { feeds, pending, downloaded } => {
|
Event::Status { feeds, pending, downloaded } => {
|
||||||
format!("{feeds} feeds, {pending} pending, {downloaded} downloaded")
|
format!("{feeds} feeds, {pending} queued to download, {downloaded} downloaded")
|
||||||
}
|
}
|
||||||
Event::Error { msg } => format!("error: {msg}"),
|
Event::Error { msg } => format!("error: {msg}"),
|
||||||
// Noise in a terminal; a UI still gets them on the socket.
|
// Noise in a terminal; a UI still gets them on the socket.
|
||||||
@@ -88,6 +92,11 @@ pub enum Command {
|
|||||||
feed: Option<String>,
|
feed: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
force: bool,
|
force: bool,
|
||||||
|
/// Only these feeds, and the feeds inside any of them that is an OPML: "check every feed"
|
||||||
|
/// from the web UI is every feed of the person asking, not of everyone (issue #37).
|
||||||
|
/// Empty is every feed, as the schedule and the CLI mean it.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
feeds: Vec<String>,
|
||||||
},
|
},
|
||||||
Reap {
|
Reap {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -126,38 +135,8 @@ impl Emitter {
|
|||||||
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
// Level by how much it matters. With 80-odd feeds in an OPML subscription, one
|
||||||
// line per feed per tick for "not due yet" would push everything worth reading
|
// line per feed per tick for "not due yet" would push everything worth reading
|
||||||
// out of the buffer within a few minutes.
|
// out of the buffer within a few minutes.
|
||||||
let routine = match &e {
|
log_event(&e, self.tx.is_some());
|
||||||
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
|
||||||
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
|
||||||
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
let bad = matches!(
|
|
||||||
&e,
|
|
||||||
Event::FeedError { .. } | Event::DownloadError { .. } | Event::Error { .. }
|
|
||||||
);
|
|
||||||
if let Some(line) = e.human() {
|
|
||||||
let line = line.trim();
|
|
||||||
if bad {
|
|
||||||
tracing::warn!(target: "ipx::scan", "{line}");
|
|
||||||
} else if routine {
|
|
||||||
tracing::debug!(target: "ipx::scan", "{line}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::scan", "{line}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(tx) = &self.tx {
|
if let Some(tx) = &self.tx {
|
||||||
// The outbound half of the protocol, as it goes on the wire. Progress is the
|
|
||||||
// high-volume one, so it sits at debug.
|
|
||||||
if let Ok(json) = serde_json::to_string(&e) {
|
|
||||||
if matches!(e, Event::Progress { .. }) {
|
|
||||||
tracing::debug!(target: "ipx::io", "<- {json}");
|
|
||||||
} else {
|
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// An error here only means nobody is listening yet.
|
// An error here only means nobody is listening yet.
|
||||||
let _ = tx.send(e.clone());
|
let _ = tx.send(e.clone());
|
||||||
}
|
}
|
||||||
@@ -167,6 +146,63 @@ impl Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An event, logged once (#91): its words as the message, and `ev`, `feed`, `new` and the rest as
|
||||||
|
/// fields, so Loki reads them without parsing the message. A failure also gets `error.type` and,
|
||||||
|
/// from an HTTP error, `http.response.status_code`, so failures group by kind without a regex.
|
||||||
|
/// Level by how much it matters: with 80-odd feeds in an OPML subscription, a line per feed per
|
||||||
|
/// tick for "not due yet" would push everything worth reading out of the log view in minutes,
|
||||||
|
/// and Progress fires on every whole percent. `wire` also logs the event as it goes on the
|
||||||
|
/// socket, at debug: the admin page's Daemon I/O tab shows it, production's log leaves it out.
|
||||||
|
fn log_event(e: &Event, wire: bool) {
|
||||||
|
let json = serde_json::to_string(e).unwrap_or_default();
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&json).unwrap_or_default();
|
||||||
|
let s = |k: &str| v.get(k).and_then(|x| x.as_str());
|
||||||
|
let n = |k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||||
|
let (kind, code) = match e {
|
||||||
|
Event::FeedError { msg, .. } | Event::DownloadError { msg, .. } | Event::Error { msg } => {
|
||||||
|
let (k, c) = crate::feed::failure_kind(msg);
|
||||||
|
(Some(k), c)
|
||||||
|
}
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
let routine = match e {
|
||||||
|
Event::Progress { .. } | Event::FeedSkip { .. } | Event::FeedStart { .. } => true,
|
||||||
|
Event::FeedDone { new, downloaded, failed, torrents, .. } => {
|
||||||
|
*new == 0 && *downloaded == 0 && *failed == 0 && *torrents == 0
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
macro_rules! line {
|
||||||
|
($level:ident, $target:literal, $text:expr) => {
|
||||||
|
tracing::$level!(
|
||||||
|
target: $target,
|
||||||
|
ev = s("ev"), feed = s("feed"), msg = s("msg"), url = s("url"), reason = s("reason"), from = s("from"), to = s("to"),
|
||||||
|
new = n("new"), downloaded = n("downloaded"), failed = n("failed"),
|
||||||
|
torrents = n("torrents"), bytes = n("bytes"), feeds = n("feeds"),
|
||||||
|
pending = n("pending"), enclosure = n("enclosure"), files = n("files"),
|
||||||
|
"error.type" = kind, "http.response.status_code" = code,
|
||||||
|
"{}", $text
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// The healthcheck's answer, every 30s: a reply on the socket rather than work done, so it
|
||||||
|
// stays with the rest of the conversation, and the Scans tab stays about scans.
|
||||||
|
if matches!(e, Event::Status { .. }) {
|
||||||
|
return line!(info, "ipx::io", format!("<- {json}"));
|
||||||
|
}
|
||||||
|
if wire {
|
||||||
|
tracing::debug!(target: "ipx::io", "<- {json}");
|
||||||
|
}
|
||||||
|
let text = e.human().map(|l| l.trim().to_owned()).unwrap_or_else(|| json.clone());
|
||||||
|
if kind.is_some() {
|
||||||
|
line!(warn, "ipx::scan", text)
|
||||||
|
} else if routine {
|
||||||
|
line!(debug, "ipx::scan", text)
|
||||||
|
} else {
|
||||||
|
line!(info, "ipx::scan", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True when something is already listening -- i.e. a daemon owns this socket.
|
/// True when something is already listening -- i.e. a daemon owns this socket.
|
||||||
pub async fn daemon_is_live(path: &Path) -> bool {
|
pub async fn daemon_is_live(path: &Path) -> bool {
|
||||||
UnixStream::connect(path).await.is_ok()
|
UnixStream::connect(path).await.is_ok()
|
||||||
@@ -176,7 +212,9 @@ pub async fn daemon_is_live(path: &Path) -> bool {
|
|||||||
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
/// healthcheck left waiting behind a scan or a long download timed out and called a busy daemon
|
||||||
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
/// dead. The answer goes to the client that asked and no one else: broadcast, it ended any
|
||||||
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
/// `ipx fetch` that was watching a scan, since `status` is a terminal event.
|
||||||
pub type StatusFn = std::sync::Arc<dyn Fn() -> Event + Send + Sync>;
|
/// A future, since reading the counts is a database query.
|
||||||
|
pub type StatusFn =
|
||||||
|
std::sync::Arc<dyn Fn() -> std::pin::Pin<Box<dyn std::future::Future<Output = Event> + Send>> + Send + Sync>;
|
||||||
|
|
||||||
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
/// Accepts connections, feeding commands to `cmds` and events from `events` back out.
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
@@ -249,10 +287,8 @@ async fn handle(
|
|||||||
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
// Answered here, not queued behind whatever the worker is on: see StatusFn.
|
||||||
Ok(Command::Status) => {
|
Ok(Command::Status) => {
|
||||||
tracing::info!(target: "ipx::io", "-> {line}");
|
tracing::info!(target: "ipx::io", "-> {line}");
|
||||||
let ev = status();
|
let ev = status().await;
|
||||||
if let Ok(json) = serde_json::to_string(&ev) {
|
log_event(&ev, true);
|
||||||
tracing::info!(target: "ipx::io", "<- {json}");
|
|
||||||
}
|
|
||||||
let _ = reply.send(ev).await;
|
let _ = reply.send(ev).await;
|
||||||
}
|
}
|
||||||
Ok(cmd) => {
|
Ok(cmd) => {
|
||||||
@@ -296,13 +332,21 @@ pub async fn proxy(path: &Path, cmd: &Command) -> Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_feed_that_moved_says_so_on_the_wire_and_in_words() {
|
||||||
|
let e = Event::FeedMoved { feed: "x".into(), from: "http://a/f".into(), to: "https://a/f".into() };
|
||||||
|
let wire = serde_json::to_value(&e).unwrap();
|
||||||
|
assert_eq!((wire["ev"].as_str(), wire["from"].as_str(), wire["to"].as_str()), (Some("feed_moved"), Some("http://a/f"), Some("https://a/f")));
|
||||||
|
assert_eq!(e.human().unwrap(), "x: moved for good from http://a/f to https://a/f; following it");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn commands_parse_from_the_wire_form() {
|
fn commands_parse_from_the_wire_form() {
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch"}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: None, force: false }));
|
assert!(matches!(got, Command::Fetch { feed: None, force: false, .. }));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"fetch","feed":"atp","force":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true } if f == "atp"));
|
assert!(matches!(got, Command::Fetch { feed: Some(f), force: true, .. } if f == "atp"));
|
||||||
|
|
||||||
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
let got: Command = serde_json::from_str(r#"{"cmd":"reap","dry_run":true}"#).unwrap();
|
||||||
assert!(matches!(got, Command::Reap { dry_run: true }));
|
assert!(matches!(got, Command::Reap { dry_run: true }));
|
||||||
@@ -366,7 +410,8 @@ mod tests {
|
|||||||
// Another client, watching a scan: it must not be handed someone else's answer, which
|
// Another client, watching a scan: it must not be handed someone else's answer, which
|
||||||
// would end its session.
|
// would end its session.
|
||||||
let mut watcher = events.subscribe();
|
let mut watcher = events.subscribe();
|
||||||
let status: StatusFn = std::sync::Arc::new(|| Event::Status { feeds: 1, pending: 2, downloaded: 3 });
|
let status: StatusFn =
|
||||||
|
std::sync::Arc::new(|| Box::pin(async { Event::Status { feeds: 1, pending: 2, downloaded: 3 } }));
|
||||||
let (client, server) = UnixStream::pair().unwrap();
|
let (client, server) = UnixStream::pair().unwrap();
|
||||||
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
tokio::spawn(handle(server, events.subscribe(), cmds, status));
|
||||||
|
|
||||||
|
|||||||
1045
src/main.rs
1045
src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -45,28 +45,32 @@ pub fn aged(candidates: &[Candidate], cutoff: i64) -> Vec<Candidate> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
pub async fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
||||||
let mut report = Report::default();
|
let mut report = Report::default();
|
||||||
|
|
||||||
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
// Someone may have deleted a file by hand; the row must stop claiming it exists.
|
||||||
for (id, path) in db.missing_files()? {
|
for (id, path) in db.missing_files().await? {
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
db.mark_reaped(id)?;
|
db.mark_reaped(id).await?;
|
||||||
}
|
}
|
||||||
tracing::debug!(path, "file gone, row reaped");
|
tracing::debug!(path, "file gone, row reaped");
|
||||||
report.reconciled += 1;
|
report.reconciled += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let candidates = db.reap_candidates()?;
|
for id in db.prune_abandoned_failures().await? {
|
||||||
|
tracing::info!(feed = id, "forgot a failing feed nobody subscribes to");
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = db.reap_candidates().await?;
|
||||||
|
|
||||||
if cfg.general.max_age_days > 0 {
|
if cfg.general.max_age_days > 0 {
|
||||||
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
let cutoff = now() - (cfg.general.max_age_days * 86_400) as i64;
|
||||||
report.aged_out = aged(&candidates, cutoff);
|
report.aged_out = aged(&candidates, cutoff);
|
||||||
for c in &report.aged_out {
|
for c in &report.aged_out {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
report.entries_pruned = db.prune_entries(cutoff)?;
|
report.entries_pruned = db.prune_entries(cutoff).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,14 +85,14 @@ pub fn run(cfg: &Config, db: &Db, dry_run: bool) -> Result<Report> {
|
|||||||
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
let total: u64 = remaining.iter().map(|c| c.bytes.max(0) as u64).sum();
|
||||||
report.over_quota = pick(&remaining, total, limit);
|
report.over_quota = pick(&remaining, total, limit);
|
||||||
for c in &report.over_quota {
|
for c in &report.over_quota {
|
||||||
report.bytes_freed += remove(db, c, dry_run)?;
|
report.bytes_freed += remove(db, c, dry_run).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
async fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
||||||
if dry_run {
|
if dry_run {
|
||||||
return Ok(c.bytes.max(0) as u64);
|
return Ok(c.bytes.max(0) as u64);
|
||||||
}
|
}
|
||||||
@@ -100,7 +104,7 @@ fn remove(db: &Db, c: &Candidate, dry_run: bool) -> Result<u64> {
|
|||||||
tracing::warn!(path = c.path, error = %e, "could not delete");
|
tracing::warn!(path = c.path, error = %e, "could not delete");
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
db.mark_reaped(c.id)?;
|
db.mark_reaped(c.id).await?;
|
||||||
Ok(size)
|
Ok(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +153,12 @@ mod tests {
|
|||||||
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
// age_key 0 means "never recorded" -- not the same as "infinitely old".
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
async fn query_never_offers_a_file_anyone_starred_and_prefers_ones_everyone_read() {
|
||||||
// One file serves both subscribers, so it takes both of them to release it.
|
// One file serves both subscribers, so it takes both of them to release it.
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1),(2,'sam',0);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true),(2,'sam',false);
|
||||||
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
INSERT INTO subscriptions (user_id, feed_id) VALUES (1,'f'),(2,'f');
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'keep', 0),
|
('f', 'keep', 0),
|
||||||
@@ -163,20 +167,20 @@ mod tests {
|
|||||||
('f', 'read', 0);
|
('f', 'read', 0);
|
||||||
-- Starred by one of the two, so it stays whatever the other thinks.
|
-- Starred by one of the two, so it stays whatever the other thinks.
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES
|
||||||
(1, 'f', 'keep', 1, 1),
|
(1, 'f', 'keep', true, true),
|
||||||
(2, 'f', 'keep', 1, 0),
|
(2, 'f', 'keep', true, false),
|
||||||
(1, 'f', 'half', 1, 0),
|
(1, 'f', 'half', true, false),
|
||||||
(1, 'f', 'read', 1, 0),
|
(1, 'f', 'read', true, false),
|
||||||
(2, 'f', 'read', 1, 0);
|
(2, 'f', 'read', true, false);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, bytes_done, state, downloaded_at) VALUES
|
||||||
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
(1, 'f', 'keep', 'u1', '/tmp/keep', 10, 'done', 10),
|
||||||
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
(2, 'f', 'half', 'u2', '/tmp/half', 10, 'done', 20),
|
||||||
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
(3, 'f', 'unread', 'u3', '/tmp/unread', 10, 'done', 30),
|
||||||
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
(4, 'f', 'read', 'u4', '/tmp/read', 10, 'done', 40);",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let got: Vec<i64> = db.reap_candidates().unwrap().iter().map(|c| c.id).collect();
|
let got: Vec<i64> = db.reap_candidates().await.unwrap().iter().map(|c| c.id).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
got,
|
got,
|
||||||
vec![4, 2, 3],
|
vec![4, 2, 3],
|
||||||
@@ -185,12 +189,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn prune_keeps_entries_that_still_have_a_file() {
|
async fn prune_keeps_entries_that_still_have_a_file() {
|
||||||
let db = Db::memory().unwrap();
|
let db = Db::memory().await.unwrap();
|
||||||
db.exec_for_test(
|
db.exec_for_test(
|
||||||
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',1);
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'ray',true);
|
||||||
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',1);
|
INSERT INTO entry_state (user_id, feed_id, guid, flagged) VALUES (1,'f','flagged',true);
|
||||||
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES
|
||||||
('f', 'has-file', 100),
|
('f', 'has-file', 100),
|
||||||
('f', 'no-file', 100),
|
('f', 'no-file', 100),
|
||||||
@@ -198,9 +202,9 @@ mod tests {
|
|||||||
('f', 'recent', 900);
|
('f', 'recent', 900);
|
||||||
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
|
||||||
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
(1, 'f', 'has-file', 'u1', '/tmp/x', 'done');",
|
||||||
)
|
).await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(db.prune_entries(500).unwrap(), 1, "only the old, fileless, unflagged one");
|
assert_eq!(db.prune_entries(500).await.unwrap(), 1, "only the old, fileless, unflagged one");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
821
src/web.rs
821
src/web.rs
File diff suppressed because it is too large
Load Diff
100
tests/contrast.js
Normal file
100
tests/contrast.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// Every theme's palette, checked against WCAG AA for the pairs the page actually draws. The
|
||||||
|
// published palettes ipx borrows (Flat Remix, Paper, Adwaita...) fell short in places: an unread
|
||||||
|
// count at 2.6:1, tags at 3.2:1. Each was tuned by hand; this keeps a new or edited theme honest.
|
||||||
|
//
|
||||||
|
// node tests/contrast.js
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(__dirname, '../web/app.css'), 'utf8');
|
||||||
|
const blocks = {};
|
||||||
|
for (const m of css.matchAll(/^(:root(?:\[[^\]]+\])*)\s*\{([^}]*)\}/gm)) {
|
||||||
|
const vars = {};
|
||||||
|
for (const v of m[2].matchAll(/--(\w+):\s*(#[0-9a-f]{6})\b/gi)) vars[v[1]] = v[2].toLowerCase();
|
||||||
|
if (Object.keys(vars).length) blocks[m[1]] = vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lum = h => {
|
||||||
|
const c = [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
|
.map(c => c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4);
|
||||||
|
return .2126 * c[0] + .7152 * c[1] + .0722 * c[2];
|
||||||
|
};
|
||||||
|
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
|
||||||
|
|
||||||
|
// [colour, grounds it is drawn on, minimum]. 4.5 for text, 3 for an icon, dot or bar.
|
||||||
|
const RULES = [
|
||||||
|
['fg', ['bg', 'panel', 'panel2', 'raise'], 4.5],
|
||||||
|
['dim', ['bg', 'panel', 'panel2'], 4.5], // metadata, labels
|
||||||
|
['faint', ['bg', 'panel', 'panel2'], 4.5], // hints, column headings, the status bar
|
||||||
|
['accent', ['bg', 'panel'], 4.5], // links, in the list and the reader
|
||||||
|
['ink', ['accent'], 4.5], // a primary button's label
|
||||||
|
['ink', ['accent2'], 4.5], // the unread count on its badge
|
||||||
|
['accent2', ['bg', 'panel', 'raise'], 3], // the unread dot, the EQ bars, download bars
|
||||||
|
['bad', ['bg', 'panel'], 4.5], // error text, the Error tag, a failed toast
|
||||||
|
['warn', ['bg', 'panel'], 4.5], // the Gone tag, log warnings
|
||||||
|
['good', ['bg', 'panel', 'panel2'], 3], // the downloaded and subscribed icons
|
||||||
|
];
|
||||||
|
|
||||||
|
const base = blocks[':root'];
|
||||||
|
const themes = {};
|
||||||
|
for (const sel of Object.keys(blocks)) {
|
||||||
|
const t = sel.match(/data-theme="(\w+)"/);
|
||||||
|
if (!t) continue;
|
||||||
|
const light = /data-mode="light"/.test(sel);
|
||||||
|
// A theme's dark half is :root under its own block; its light half adds the light block.
|
||||||
|
const name = `${t[1]}${light ? ' light' : ''}`;
|
||||||
|
themes[name] = light
|
||||||
|
? { ...base, ...blocks[`:root[data-theme="${t[1]}"]`], ...blocks[sel] }
|
||||||
|
: { ...base, ...blocks[sel] };
|
||||||
|
}
|
||||||
|
themes['modern'] = base;
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
for (const [name, p] of Object.entries(themes)) {
|
||||||
|
for (const [fg, grounds, min] of RULES) for (const g of grounds) {
|
||||||
|
const r = ratio(p[fg], p[g]);
|
||||||
|
if (r < min) { bad++; console.log(`FAIL ${name}: --${fg} on --${g} is ${r.toFixed(2)}:1, needs ${min}`); }
|
||||||
|
}
|
||||||
|
// A border the same colour as the ground it is drawn on does not show (Nordic, once).
|
||||||
|
if (p.line === p.panel2) { bad++; console.log(`FAIL ${name}: --line is --panel2, so borders on it vanish`); }
|
||||||
|
}
|
||||||
|
// Glass draws its text on a coloured wash, or on a panel that lets the wash through, so its hex
|
||||||
|
// grounds are not what the text lands on. Sample the wash the way the browser composites it, on a
|
||||||
|
// laptop, a phone and a tablet, and hold the text to AA wherever it is darkest or lightest.
|
||||||
|
const washes = [...css.matchAll(/radial-gradient\((\d+)% (\d+)% at (\d+)% (\d+)%,var\(--wash(\d)\)/g)]
|
||||||
|
.map(m => ({ rx: +m[1], ry: +m[2], cx: +m[3], cy: +m[4], n: m[5] }));
|
||||||
|
const rgba = (sel, n) => {
|
||||||
|
const m = css.slice(css.indexOf(sel + ' {')).match(new RegExp(`--wash${n}:rgba\\((\\d+),(\\d+),(\\d+),([\\d.]+)\\)`));
|
||||||
|
return [[+m[1], +m[2], +m[3]], +m[4]];
|
||||||
|
};
|
||||||
|
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
||||||
|
const over = (c, a, g) => g.map((x, i) => c[i] * a + x * (1 - a));
|
||||||
|
for (const [name, sel] of [['glass', ':root[data-theme="glass"]'], ['glass light', ':root[data-theme="glass"][data-mode="light"]']]) {
|
||||||
|
const p = themes[name];
|
||||||
|
const tint = washes.map(w => [w, rgba(sel, w.n)]).reverse(); // the first listed is drawn on top
|
||||||
|
const sm = css.slice(css.indexOf(sel + ' {')).match(/--glass-sheen:rgba\((\d+),(\d+),(\d+),([\d.]+)\)/);
|
||||||
|
const sheen = [[+sm[1], +sm[2], +sm[3]], +sm[4]];
|
||||||
|
const worst = {};
|
||||||
|
for (const [W, H] of [[1440, 900], [390, 844], [1024, 1366]])
|
||||||
|
for (let x = 0; x <= W; x += W / 40) for (let y = 0; y <= H; y += H / 40) {
|
||||||
|
let g = rgb(p.bg);
|
||||||
|
for (const [w, [c, a]] of tint) {
|
||||||
|
const d = Math.hypot((x - w.cx * W / 100) / (w.rx * W / 100), (y - w.cy * H / 100) / (w.ry * H / 100));
|
||||||
|
g = over(c, a * Math.max(0, 1 - d), g);
|
||||||
|
}
|
||||||
|
// The list sits on the bare wash; the sidebar, detail pane and dialogs on 70% panel over it,
|
||||||
|
// and a panel's top-left corner under its sheen at full strength.
|
||||||
|
const panel = over(rgb(p.panel), .7, g);
|
||||||
|
for (const [gn, gc] of [['the wash', g], ['a panel', panel], ['a panel\'s sheen', over(...sheen, panel)]])
|
||||||
|
for (const fg of ['fg', 'dim', 'faint', 'accent', 'bad', 'warn']) {
|
||||||
|
const r = ratio(p[fg], hex(gc)), k = `--${fg} on ${gn}`;
|
||||||
|
if (!(k in worst) || r < worst[k]) worst[k] = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!washes.length) { bad++; console.log('FAIL glass: no wash gradients found in app.css'); }
|
||||||
|
for (const [k, r] of Object.entries(worst))
|
||||||
|
if (r < 4.5) { bad++; console.log(`FAIL ${name}: ${k} is ${r.toFixed(2)}:1 at worst, needs 4.5`); }
|
||||||
|
}
|
||||||
|
if (bad) process.exit(1);
|
||||||
|
console.log(`OK: ${Object.keys(themes).length} palettes clear AA for every pair the page draws`);
|
||||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "k1",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"e": "AQAB",
|
||||||
|
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
<description>A synthetic feed used by the parser tests.</description>
|
<description>A synthetic feed used by the parser tests.</description>
|
||||||
<ttl>45</ttl>
|
<ttl>45</ttl>
|
||||||
<itunes:explicit>no</itunes:explicit>
|
<itunes:explicit>no</itunes:explicit>
|
||||||
|
<itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
|
||||||
|
<itunes:category text="News"/>
|
||||||
|
|
||||||
<item>
|
<item>
|
||||||
<title>Episode One</title>
|
<title>Episode One</title>
|
||||||
|
|||||||
131
tests/dom-stub.js
Normal file
131
tests/dom-stub.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
||||||
|
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
||||||
|
// to find what it reaches for, and no more.
|
||||||
|
//
|
||||||
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
||||||
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
||||||
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
||||||
|
// nothing about whether it worked.
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
function makeContext(html, script, { media = false } = {}) {
|
||||||
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
||||||
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
||||||
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
||||||
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
||||||
|
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
||||||
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
||||||
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
||||||
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
||||||
|
|
||||||
|
const body = el('body');
|
||||||
|
const audio = media ? makeMediaElement() : null;
|
||||||
|
if (media) {
|
||||||
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
||||||
|
// one has to be a real set rather than something that always says no.
|
||||||
|
const classes = new Set();
|
||||||
|
body.classList = {
|
||||||
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||||
|
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
||||||
|
contains: c => classes.has(c),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
querySelector(sel) {
|
||||||
|
if (sel === '#audio' && audio) return audio;
|
||||||
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
||||||
|
return el(sel);
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => el('created'),
|
||||||
|
addEventListener(){}, body,
|
||||||
|
documentElement: { dataset: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
document, console,
|
||||||
|
window: { isSecureContext: false, addEventListener(){} },
|
||||||
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
||||||
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
||||||
|
fetch: (url) => Promise.resolve({
|
||||||
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
||||||
|
json: () => Promise.resolve(
|
||||||
|
String(url).includes('/api/settings')
|
||||||
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
||||||
|
: String(url).includes('/api/users')
|
||||||
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
||||||
|
: /\/api\/(popular|directory)/.test(String(url))
|
||||||
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
||||||
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
||||||
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
||||||
|
}),
|
||||||
|
EventSource: function () { this.close = () => {}; },
|
||||||
|
MediaMetadata: function () {},
|
||||||
|
Blob: function () {},
|
||||||
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
||||||
|
confirm: () => false, prompt: () => null, alert(){},
|
||||||
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
||||||
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
||||||
|
};
|
||||||
|
if (media) {
|
||||||
|
ctx.HTMLMediaElement = MediaElement;
|
||||||
|
ctx.Event = Event;
|
||||||
|
ctx.isFinite = isFinite;
|
||||||
|
ctx.CSS = { escape: s => String(s) };
|
||||||
|
}
|
||||||
|
ctx.globalThis = ctx;
|
||||||
|
ctx.window.location = { href: '', hash: '' };
|
||||||
|
ctx.location = ctx.window.location;
|
||||||
|
|
||||||
|
return { ctx, missing, audio, body, ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
||||||
|
|
||||||
|
function Event(type) { this.type = type; }
|
||||||
|
|
||||||
|
function MediaElement() {
|
||||||
|
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
||||||
|
this._ready = 0; this._vol = 1; this._rate = 1;
|
||||||
|
this._listeners = {};
|
||||||
|
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
||||||
|
// Every call that reached the real element, so a test can say the host took over rather than
|
||||||
|
// the element quietly playing as well.
|
||||||
|
this.calls = [];
|
||||||
|
}
|
||||||
|
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
||||||
|
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
||||||
|
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
||||||
|
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
||||||
|
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
||||||
|
};
|
||||||
|
MediaElement.prototype.dispatchEvent = function(ev){
|
||||||
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
||||||
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
||||||
|
l.fn(ev);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
||||||
|
MediaElement.prototype.setAttribute = function(){};
|
||||||
|
MediaElement.prototype.getAttribute = function(){ return null; };
|
||||||
|
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
||||||
|
configurable: true,
|
||||||
|
get(){ return this[field]; },
|
||||||
|
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
||||||
|
});
|
||||||
|
accessor('src', '_src', true);
|
||||||
|
accessor('currentTime', '_t', true);
|
||||||
|
accessor('volume', '_vol');
|
||||||
|
accessor('playbackRate', '_rate');
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
||||||
|
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
||||||
|
|
||||||
|
function makeMediaElement(){ return new MediaElement(); }
|
||||||
|
|
||||||
|
module.exports = { makeContext, vm };
|
||||||
117
tests/native-bridge.js
Normal file
117
tests/native-bridge.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||||
|
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||||
|
//
|
||||||
|
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||||
|
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||||
|
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||||
|
// road, with nothing to look at.
|
||||||
|
//
|
||||||
|
// node tests/native-bridge.js
|
||||||
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
|
const { buildPage } = require('../web/build.mjs');
|
||||||
|
|
||||||
|
const { html, js: script } = buildPage('index.html');
|
||||||
|
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (cond, what) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||||
|
|
||||||
|
/* ---- a browser: nothing installs ---- */
|
||||||
|
{
|
||||||
|
const { ctx } = makeContext(html, script, { media: true });
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||||
|
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||||
|
const audio = ctx.document.querySelector('#audio');
|
||||||
|
audio.src = '/media/1';
|
||||||
|
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- inside the shell ---- */
|
||||||
|
const posted = [];
|
||||||
|
const { ctx, audio, body } = makeContext(html, script, { media: true });
|
||||||
|
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: m => posted.push(m) } } };
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||||
|
|
||||||
|
const last = t => [...posted].reverse().find(m => m.t === t);
|
||||||
|
const since = () => posted.splice(0, posted.length);
|
||||||
|
|
||||||
|
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||||
|
ok(last('ready'), 'the host was never told the bridge is in');
|
||||||
|
since();
|
||||||
|
|
||||||
|
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||||
|
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||||
|
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||||
|
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||||
|
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||||
|
|
||||||
|
body.classList.toggle('has-video', false);
|
||||||
|
audio.calls.length = 0;
|
||||||
|
audio.src = '/media/42';
|
||||||
|
|
||||||
|
const load = last('load');
|
||||||
|
ok(load, 'setting the src told the host nothing');
|
||||||
|
if (load) {
|
||||||
|
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||||
|
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||||
|
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||||
|
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||||
|
}
|
||||||
|
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||||
|
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||||
|
|
||||||
|
// player.ts sets currentTime=0 straight after the src.
|
||||||
|
since();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
ok(last('seek') && last('seek').to === 0, 'a seek did not reach the host');
|
||||||
|
|
||||||
|
since();
|
||||||
|
audio.play();
|
||||||
|
ok(last('play'), 'play did not reach the host');
|
||||||
|
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||||
|
|
||||||
|
// The host answers, and the page must move as it would have on its own.
|
||||||
|
let played = 0, timed = 0;
|
||||||
|
audio.addEventListener('play', () => played++);
|
||||||
|
audio.addEventListener('timeupdate', () => timed++);
|
||||||
|
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||||
|
ok(played === 1, 'the page never saw the host start playing');
|
||||||
|
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||||
|
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||||
|
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||||
|
|
||||||
|
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||||
|
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||||
|
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||||
|
|
||||||
|
// The 15-second key: a read and a write through the shim.
|
||||||
|
since();
|
||||||
|
audio.currentTime -= 15;
|
||||||
|
ok(last('seek') && last('seek').to === 15, 'back 15 seconds did not land at 15');
|
||||||
|
|
||||||
|
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||||
|
since();
|
||||||
|
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||||
|
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||||
|
ok(last('position') && /\/position$/.test(last('position').url), 'the position write did not become a request to the host');
|
||||||
|
|
||||||
|
// Closing the player has to stop the host, not just blank the element.
|
||||||
|
since();
|
||||||
|
audio.removeAttribute('src');
|
||||||
|
ok(last('stop'), 'closing the player left the host playing');
|
||||||
|
|
||||||
|
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||||
|
// buys nothing.
|
||||||
|
since();
|
||||||
|
audio.calls.length = 0;
|
||||||
|
body.classList.toggle('has-video', true);
|
||||||
|
audio.src = '/media/99';
|
||||||
|
ok(!last('load'), 'a video was handed to the host');
|
||||||
|
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||||
|
|
||||||
|
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||||
|
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||||
|
process.exit(0);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// Executes web/index.html's script against a stub DOM and fails on anything thrown.
|
// Builds a page from web/src as build.rs does, runs its script against a stub DOM, and fails
|
||||||
|
// on anything thrown: web/index.html, then web/admin.html in a second run of this file.
|
||||||
//
|
//
|
||||||
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
// This exists because a ReferenceError at load once blanked the whole UI: a patch
|
||||||
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
// anchored on a function that no longer existed, so `prefsModal` was referenced but
|
||||||
@@ -6,65 +7,23 @@
|
|||||||
// and every server-side test passed too, because the server was fine.
|
// and every server-side test passed too, because the server was fine.
|
||||||
//
|
//
|
||||||
// node tests/page-smoke.js
|
// node tests/page-smoke.js
|
||||||
const fs = require('fs');
|
const { makeContext, vm } = require('./dom-stub.js');
|
||||||
const path = require('path');
|
const PAGE = process.argv[2] || 'index.html';
|
||||||
const vm = require('vm');
|
|
||||||
|
|
||||||
const html = fs.readFileSync(path.join(__dirname, '..', 'web', 'index.html'), 'utf8');
|
const { buildPage } = require('../web/build.mjs');
|
||||||
const script = html.split('<script>')[1].split('</script>')[0];
|
// What ships: minified, so an id may have lost its quotes.
|
||||||
const ids = new Set([...html.matchAll(/id="([^"]+)"/g)].map(m => m[1]));
|
const { html, js: script, script: file } = buildPage(PAGE);
|
||||||
|
if (!/<link rel=stylesheet href="?\/app\.css\?v=[0-9a-f]{12}"?>/.test(html) && PAGE !== 'login.html') {
|
||||||
const missing = [];
|
console.error(`FAIL: ${PAGE} does not load /app.css?v=<hash>`); process.exit(1);
|
||||||
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
}
|
||||||
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
|
||||||
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
|
||||||
setAttribute(){}, getAttribute(){ return null; }, select(){}, setSelectionRange(){}, focus(){},
|
}
|
||||||
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
const { ctx, missing } = makeContext(html, script);
|
||||||
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
||||||
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
||||||
|
|
||||||
const document = {
|
|
||||||
querySelector(sel) {
|
|
||||||
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
||||||
return el(sel);
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [],
|
|
||||||
createElement: () => el('created'),
|
|
||||||
addEventListener(){}, body: el('body'),
|
|
||||||
documentElement: { dataset: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
document, console,
|
|
||||||
window: { isSecureContext: false, addEventListener(){} },
|
|
||||||
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
||||||
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
||||||
fetch: (url) => Promise.resolve({
|
|
||||||
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
||||||
json: () => Promise.resolve(
|
|
||||||
String(url).includes('/api/settings')
|
|
||||||
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
||||||
: String(url).includes('/api/users')
|
|
||||||
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
||||||
: /\/api\/(popular|directory)/.test(String(url))
|
|
||||||
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
||||||
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
||||||
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
||||||
}),
|
|
||||||
EventSource: function () { this.close = () => {}; },
|
|
||||||
MediaMetadata: function () {},
|
|
||||||
Blob: function () {},
|
|
||||||
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
confirm: () => false, prompt: () => null, alert(){},
|
|
||||||
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
||||||
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
||||||
};
|
|
||||||
ctx.globalThis = ctx;
|
|
||||||
ctx.window.location = { href: '' };
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vm.createContext(ctx);
|
vm.createContext(ctx);
|
||||||
vm.runInContext(script, ctx, { filename: 'index.html<script>', timeout: 5000 });
|
vm.runInContext(script, ctx, { filename: `${PAGE}<script>`, timeout: 5000 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
console.error('FAIL: the page script threw while loading\n ' + e.stack.split('\n').slice(0, 3).join('\n '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -78,18 +37,22 @@ const feed = {
|
|||||||
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
schedule: 'every 6h', schedule_mins: 360, every_mins: 360,
|
||||||
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
last_checked: 1, next_check: 2, entries: 1, downloaded: 0, unread: 1, last_error: null,
|
||||||
};
|
};
|
||||||
const drive = [
|
const drive = PAGE === 'admin.html' ? [
|
||||||
|
['drawServer', () => ctx.drawServer()],
|
||||||
|
['drawAccounts', () => ctx.drawAccounts()],
|
||||||
|
['drawLogView', () => ctx.drawLogView()],
|
||||||
|
] : [
|
||||||
['settingsModal', () => ctx.settingsModal(feed)],
|
['settingsModal', () => ctx.settingsModal(feed)],
|
||||||
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
['settingsModal (no override)', () => ctx.settingsModal({ ...feed, schedule: null, schedule_mins: null })],
|
||||||
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
['downloadLatestModal', () => ctx.downloadLatestModal(feed)],
|
||||||
['removeFeed', () => ctx.removeFeed(feed)],
|
['removeFeed', () => ctx.removeFeed(feed)],
|
||||||
['prefsModal', () => ctx.prefsModal()],
|
['prefsModal', () => ctx.prefsModal()],
|
||||||
['usersModal', () => ctx.usersModal()],
|
|
||||||
['opmlModal', () => ctx.opmlModal()],
|
['opmlModal', () => ctx.opmlModal()],
|
||||||
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
['selectFeed (directory)', () => ctx.selectFeed(':directory')],
|
||||||
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
['selectFeed (popular)', () => ctx.selectFeed(':popular')],
|
||||||
|
['selectFeed (currently listening)', () => ctx.selectFeed(':listening')],
|
||||||
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
['selectFeed (all subscriptions)', () => ctx.selectFeed(':all')],
|
||||||
['logsModal', () => ctx.logsModal()],
|
['keysModal', () => ctx.keysModal()],
|
||||||
// `const S` is not reachable from here: top-level const/let do not become properties
|
// `const S` is not reachable from here: top-level const/let do not become properties
|
||||||
// of a vm context the way var and function declarations do.
|
// of a vm context the way var and function declarations do.
|
||||||
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
['renderGroup', () => ctx.renderGroup(feed, [{ ...feed, id: 'child', group: 'f', orphaned: true }])],
|
||||||
@@ -106,10 +69,18 @@ for (const [name, fn] of drive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||||
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||||
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||||
|
missing.splice(0, missing.length, ...missing.filter(sel => !OPTIONAL.has(sel)));
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
console.error('FAIL: handlers wired to elements that do not exist: ' + [...new Set(missing)].join(', '));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('OK: page script loads clean, every selector it wires at load exists');
|
console.log(`OK: ${PAGE}: its script loads clean, every selector it wires at load exists`);
|
||||||
// logsModal arms a poll timer; without this the pending interval keeps node alive.
|
// The admin page's log arms a poll timer; without an exit the pending interval keeps node alive.
|
||||||
|
if (PAGE === 'index.html') {
|
||||||
|
const r = require('child_process').spawnSync(process.execPath, [__filename, 'admin.html'], { stdio: 'inherit' });
|
||||||
|
process.exit(r.status);
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -20,62 +20,120 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
|
|||||||
expect(errors, 'the page script must not throw at load').toEqual([]);
|
expect(errors, 'the page script must not throw at load').toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme toggle actually changes the theme', async ({ page }) => {
|
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
|
||||||
// Regression: this button was wired after a line that threw, so it did nothing.
|
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
|
||||||
const before = await page.evaluate(() => document.documentElement.dataset.theme || 'system');
|
const doc = await page.reload();
|
||||||
await page.locator('#theme').click();
|
const r = await js;
|
||||||
await expect
|
// The page is checked every visit, so it always names the current script...
|
||||||
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
|
expect(doc.headers()['cache-control']).toBe('no-cache');
|
||||||
.not.toBe(before);
|
// ...by a hash of its contents, which is why the script itself can be kept for a year.
|
||||||
|
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
|
||||||
|
expect(r.headers()['cache-control']).toContain('immutable');
|
||||||
|
expect(r.headers()['content-type']).toContain('javascript');
|
||||||
|
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
|
||||||
|
// The sign-in page's script loads before signing in.
|
||||||
|
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
|
||||||
|
expect(login.status()).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
|
||||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
expect(await theme()).toBe('classic');
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/);
|
await expect(page.locator('#stheme')).toHaveValue((await root())[0]);
|
||||||
|
|
||||||
await page.reload();
|
await page.locator('#stheme').selectOption('modern');
|
||||||
await expect.poll(theme).toBe('classic');
|
await page.locator('#smode').selectOption('auto');
|
||||||
|
// Auto follows the system, live, with no reload.
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'light']);
|
||||||
|
await expect.poll(bg).toBe('rgb(238, 245, 251)'); // Modern's light --bg
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
await expect.poll(root).toEqual(['modern', 'dark']);
|
||||||
|
await expect.poll(bg).toBe('rgb(10, 23, 38)'); // Modern's dark --bg
|
||||||
|
|
||||||
|
// Dracula, then its light half, Alucard.
|
||||||
|
await page.locator('#stheme').selectOption('dracula');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(40, 42, 54)'); // #282A36
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
await expect.poll(bg).toBe('rgb(255, 251, 235)'); // #FFFBEB
|
||||||
|
|
||||||
|
// Classic and Paper come one way only, so there is nothing to choose.
|
||||||
|
await page.locator('#stheme').selectOption('paper');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
|
||||||
|
await page.locator('#stheme').selectOption('classic');
|
||||||
|
await expect(page.locator('#smode')).toBeHidden();
|
||||||
|
await page.reload(); // kept in this browser's cookie
|
||||||
|
await expect.poll(root).toEqual(['classic', 'light']);
|
||||||
// The 2004 Mac app set its type in Lucida Grande.
|
// The 2004 Mac app set its type in Lucida Grande.
|
||||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||||
|
expect(await page.locator('#theme').count(), 'the theme lives in Settings only').toBe(0);
|
||||||
|
|
||||||
|
// Back to Nordic, dark: the mode chosen before Classic is kept for the themes that have one.
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#stheme').selectOption('nordic');
|
||||||
|
await expect(page.locator('#smode')).toHaveValue('light');
|
||||||
|
await page.locator('#smode').selectOption('dark');
|
||||||
|
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => {
|
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
|
||||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
|
||||||
|
let patched = false;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#stheme')).toHaveValue(await theme());
|
await page.locator('#stheme').selectOption('flatremix');
|
||||||
|
await page.locator('#smode').selectOption('light');
|
||||||
|
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
|
||||||
|
expect(cookie?.value).toBe('flatremix.light');
|
||||||
|
expect(patched, 'nothing sent to the account').toBe(false);
|
||||||
|
|
||||||
await page.locator('#stheme').selectOption('auto');
|
// This browser: the server reads the cookie and draws the page in it from the first frame.
|
||||||
await expect.poll(theme).toBe('auto');
|
const res = await page.reload();
|
||||||
// Auto follows the system; emulating a light system must show the light palette live,
|
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
|
||||||
// no reload needed, since it is a media query rather than something JS picks per click.
|
|
||||||
await page.emulateMedia({ colorScheme: 'light' });
|
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
|
||||||
.toBe('rgb(242, 244, 247)'); // --bg in the light palette
|
|
||||||
await page.emulateMedia({ colorScheme: 'dark' });
|
|
||||||
await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor))
|
|
||||||
.toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here
|
|
||||||
|
|
||||||
// The header button and the dropdown are the same one setting, not two.
|
// Another browser, the same account: not this one's theme.
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal
|
const other = await browser.newContext();
|
||||||
await page.locator('#theme').click();
|
const p2 = await other.newPage();
|
||||||
expect(await theme()).toBe('dark');
|
const res2 = await p2.goto(`/?token=${TOKEN}`);
|
||||||
|
expect(await res2.text()).not.toContain('data-theme=flatremix');
|
||||||
|
await other.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
|
||||||
await page.locator('#prefs').click();
|
const who = `theme-${Date.now()}@example.com`;
|
||||||
await expect(page.locator('#modal.on')).toBeVisible();
|
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
|
||||||
await expect(page.locator('#gnum')).toBeVisible();
|
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
|
||||||
|
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
|
||||||
|
.toEqual(['modern', 'light']);
|
||||||
|
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the admin page saves the global schedule', async ({ page }) => {
|
||||||
|
// Reached from the header's Admin link, and on its own page, not in Settings (issue #19).
|
||||||
|
await page.locator('#admin').click();
|
||||||
|
await expect(page).toHaveURL(/\/admin$/);
|
||||||
|
await expect(page.locator('#atabs a.on')).toHaveText('Server');
|
||||||
await page.locator('#gnum').fill('4');
|
await page.locator('#gnum').fill('4');
|
||||||
await page.locator('#gunit').selectOption('h');
|
await page.locator('#gunit').selectOption('h');
|
||||||
await page.locator('#gsave').click();
|
await page.locator('#gsave').click();
|
||||||
await expect(page.locator('#modal.on')).toBeHidden();
|
await expect(page.locator('#toasts')).toContainText('Settings saved');
|
||||||
|
|
||||||
// It must survive a reload, i.e. actually reach the config.
|
// It must survive a reload, i.e. actually reach the config.
|
||||||
await page.locator('#prefs').click();
|
await page.reload();
|
||||||
await expect(page.locator('#gnum')).toHaveValue('4');
|
await expect(page.locator('#gnum')).toHaveValue('4');
|
||||||
await expect(page.locator('#gunit')).toHaveValue('h');
|
await expect(page.locator('#gunit')).toHaveValue('h');
|
||||||
|
// And Settings in the app no longer has it.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await expect(page.locator('#modalCard')).toContainText('Theme');
|
||||||
|
await expect(page.locator('#gnum')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
test('episodes show with their metadata, and the text opens below', async ({ page }) => {
|
||||||
@@ -120,6 +178,35 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
await expect(page.locator('#files [data-a="play"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('while an episode plays, its play buttons all say pause, and pause it', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
const downloaded = page.locator('.ep', { has: page.locator('.kind.here') }).first();
|
||||||
|
await expect(downloaded).toBeVisible({ timeout: 20_000 });
|
||||||
|
await downloaded.click();
|
||||||
|
// What the buttons do, not whether this browser decodes the fixture: it does not, reliably,
|
||||||
|
// and a load error pauses the player, rightly turning every button back to play. So the
|
||||||
|
// player plays and pauses here as a real one does, events and all, with no file behind it.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
let paused = true;
|
||||||
|
Object.defineProperty(audio, 'paused', { get: () => paused, configurable: true });
|
||||||
|
audio.play = async () => { paused = false; audio.dispatchEvent(new Event('play')); };
|
||||||
|
audio.pause = () => { paused = true; audio.dispatchEvent(new Event('pause')); };
|
||||||
|
});
|
||||||
|
const pane = page.locator('#files [data-a="play"]');
|
||||||
|
await pane.click();
|
||||||
|
await expect.poll(() => page.evaluate(() => !audio.paused)).toBe(true);
|
||||||
|
// The files pane, the row and the toolbar all follow the player bar, not only the bar.
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(downloaded.locator('[data-a="play"]')).toHaveAttribute('title', 'Pause');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Pause');
|
||||||
|
await pane.click(); // and pressing it pauses
|
||||||
|
await expect.poll(() => page.evaluate(() => audio.paused)).toBe(true);
|
||||||
|
await expect(pane).toHaveAttribute('title', 'Play');
|
||||||
|
await expect(page.locator('#tbPlay')).toHaveAttribute('title', 'Play the selected item');
|
||||||
|
await page.locator('#pclose').click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||||
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
||||||
// header image rendered as a broken player.
|
// header image rendered as a broken player.
|
||||||
@@ -155,30 +242,69 @@ test('an item with several enclosures lists them all', async ({ page }) => {
|
|||||||
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => {
|
test('Currently Listening, its own place below Popular, resumes an episode you started or forgets it', async ({ page }) => {
|
||||||
// Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes
|
// Second Episode (900s) is 42 seconds in and unfinished. An earlier test may have opened it,
|
||||||
// the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier
|
// and opening marks it read; it is listed all the same, because read is not finished. This
|
||||||
// test may have opened -- and so read -- this one already; reset it before relying on it.
|
// test used to set it unread first, which hid exactly the bug in issue #14.
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) }));
|
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||||
|
|
||||||
|
// Popular lists feeds and nothing else; the episodes have a place of their own under it.
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
||||||
|
await expect(page.locator('#popular')).toBeVisible();
|
||||||
|
await expect(page.locator('#listening')).toHaveCount(0);
|
||||||
|
const places = await page.locator('#feedlist .place b').allTextContents();
|
||||||
|
expect(places.indexOf('Currently Listening')).toBe(places.indexOf('Popular') + 1);
|
||||||
|
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
const row = page.locator('#listening .childrow', { hasText: 'Second Episode' });
|
||||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(row).toContainText('0:42 of 15:00');
|
await expect(row).toContainText('14:18 left');
|
||||||
|
|
||||||
|
// Removing it forgets where you got to, so it is still gone on the next visit.
|
||||||
|
await row.locator('[data-a=remove]').click();
|
||||||
|
await expect(row).toHaveCount(0);
|
||||||
|
await expect(page.locator('#player')).not.toBeVisible();
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
|
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
||||||
|
|
||||||
|
// Started again, it is back, and clicking the row resumes it. Finishing it (90%) is
|
||||||
|
// covered in the Rust tests; here the player's own save on close would race it.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) }));
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'Currently Listening' }).click();
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
await expect(page.locator('#player')).toBeVisible();
|
await expect(page.locator('#player')).toBeVisible();
|
||||||
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
await expect(page.locator('#ptitle')).toHaveText('Second Episode');
|
||||||
|
// The row in the player carries the EQ bars, as the feed view's does, until the player closes.
|
||||||
|
await expect(row).toHaveClass(/\bnow\b/);
|
||||||
|
await expect(row.locator('.eq')).toBeVisible();
|
||||||
await page.locator('#pclose').click();
|
await page.locator('#pclose').click();
|
||||||
|
await expect(row).not.toHaveClass(/\bnow\b/);
|
||||||
|
await expect(row.locator('.eq')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
// Finished (read) drops it from the list, however far it got.
|
test('a player nobody has played since it last saved does not save again', async ({ page }) => {
|
||||||
await page.evaluate(() =>
|
// A tab left paused at 41:15 saved that as it reloaded, over the 32:48 another had reached,
|
||||||
api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) }));
|
// and the episode dropped out of Currently Listening. The fixture audio does not decode, so
|
||||||
await page.locator('#feedlist .place', { hasText: 'Popular' }).click();
|
// this stands in for a loaded file paused at 2 seconds and counts what savePos sends.
|
||||||
await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 });
|
const sent = await page.evaluate(() => {
|
||||||
|
Object.defineProperty(audio, 'readyState', { get: () => 4 });
|
||||||
|
Object.defineProperty(audio, 'currentTime', { get: () => 2, set() {} });
|
||||||
|
let n = 0;
|
||||||
|
navigator.sendBeacon = () => (n++, true);
|
||||||
|
player.guid = 'ui-2'; player.feed = 'test-show'; player.entry = null; player.moved = false;
|
||||||
|
savePos(); // what a reload, a pause or the close button calls
|
||||||
|
const idle = n;
|
||||||
|
player.moved = true; // what playing sets
|
||||||
|
savePos();
|
||||||
|
savePos(); // and once saved, it is idle again
|
||||||
|
return [idle, n];
|
||||||
|
});
|
||||||
|
expect(sent).toEqual([0, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the filter tabs change what is listed', async ({ page }) => {
|
test('the filter tabs change what is listed', async ({ page }) => {
|
||||||
@@ -190,10 +316,36 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
|||||||
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
|
||||||
|
|
||||||
await page.locator('.tabs button', { hasText: 'Kept' }).first().click();
|
await page.locator('.tabs button', { hasText: 'Pinned' }).first().click();
|
||||||
await expect(page.locator('#count')).toContainText('0 items');
|
await expect(page.locator('#count')).toContainText('0 items');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab an item stays while you read it and goes when you move on', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read things; make the first two unread with their own dots.
|
||||||
|
for (const i of [0, 1]) {
|
||||||
|
const row = page.locator('.ep').nth(i);
|
||||||
|
if (await row.evaluate(r => r.classList.contains('read'))) {
|
||||||
|
await row.locator('[data-a="read"]').click();
|
||||||
|
await expect(page.locator('.ep').nth(i)).not.toHaveClass(/\bread\b/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
const first = page.locator('.ep').first();
|
||||||
|
const guid = await first.getAttribute('data-guid');
|
||||||
|
await first.click();
|
||||||
|
const it = page.locator(`.ep[data-guid="${guid}"]`);
|
||||||
|
await expect(it).toHaveClass(/\bread\b/);
|
||||||
|
await page.waitForTimeout(1500); // past the SSE refresh debounce and loadFeeds
|
||||||
|
await expect(it).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('.ep').nth(1).click();
|
||||||
|
await expect(it).toHaveCount(0);
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
});
|
||||||
|
|
||||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||||
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
await page.locator('#content .acts [data-a="settings"]').click();
|
await page.locator('#content .acts [data-a="settings"]').click();
|
||||||
@@ -209,7 +361,7 @@ test('a feed URL is editable and has a copy button', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
test('the log view has tabs and shows daemon traffic', async ({ page }) => {
|
||||||
await page.locator('#logs').click();
|
await page.goto('/admin#log');
|
||||||
await expect(page.locator('#logbox')).toBeVisible();
|
await expect(page.locator('#logbox')).toBeVisible();
|
||||||
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
await expect(page.locator('#logtabs button')).toHaveCount(4);
|
||||||
|
|
||||||
@@ -308,6 +460,11 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
|||||||
test.describe('on a phone', () => {
|
test.describe('on a phone', () => {
|
||||||
test.use({ viewport: { width: 390, height: 844 } });
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('the top bar leaves the logo out, for the search box', async ({ page }) => {
|
||||||
|
await expect(page.locator('#burger')).toBeVisible();
|
||||||
|
await expect(page.locator('#applogo')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
test('the feed list is reachable and an item reads full screen', async ({ page }) => {
|
||||||
// The burger used to live in the player bar, which is hidden until something plays --
|
// The burger used to live in the player bar, which is hidden until something plays --
|
||||||
// leaving no way to reach the feeds at all.
|
// leaving no way to reach the feeds at all.
|
||||||
@@ -396,10 +553,12 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
const ctx = await browser.newContext();
|
const ctx = await browser.newContext();
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
// The sign-in page shows the icon, so it has to load before anyone has signed in.
|
// The sign-in page shows the logo, so it has to load before anyone has signed in.
|
||||||
const icon = await page.request.get('/icon.png');
|
for (const path of ['/logo.svg', '/logo-dark.svg']) {
|
||||||
expect(icon.status()).toBe(200);
|
const logo = await page.request.get(path);
|
||||||
expect(icon.headers()['content-type']).toBe('image/png');
|
expect(logo.status(), path).toBe(200);
|
||||||
|
expect(logo.headers()['content-type'], path).toBe('image/svg+xml');
|
||||||
|
}
|
||||||
await page.locator('#name').fill('sam');
|
await page.locator('#name').fill('sam');
|
||||||
await page.locator('#pw').fill('sampassword');
|
await page.locator('#pw').fill('sampassword');
|
||||||
await page.locator('button[type=submit]').click();
|
await page.locator('button[type=submit]').click();
|
||||||
@@ -407,18 +566,22 @@ test('a second person has their own feeds and their own read state', async ({ br
|
|||||||
|
|
||||||
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
// Sam subscribes to nothing yet, so sees nothing -- the admin's feeds are not theirs.
|
||||||
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
await expect(page.locator('#feedlist')).toContainText('No feeds.');
|
||||||
// Settings stays: Sam has their own subscriptions to export and import, and the
|
// Settings stays: Sam has their own theme and subscriptions. The admin page -- the server's
|
||||||
// schedule and quota are worth seeing even without a say in them. Only the log and the
|
// settings, the accounts and the log -- is an admin's alone, and Sam is not even sent the
|
||||||
// users screen -- and the server -- are an admin's alone.
|
// link to it, never mind the page.
|
||||||
await expect(page.locator('#prefs')).toBeVisible();
|
await expect(page.locator('#prefs')).toBeVisible();
|
||||||
await page.locator('#prefs').click();
|
await page.locator('#prefs').click();
|
||||||
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
await expect(page.locator('#modalCard')).toContainText('Subscriptions');
|
||||||
await expect(page.locator('#gsave')).toBeHidden();
|
await expect(page.locator('#modalCard')).toContainText('Only an admin changes this');
|
||||||
await expect(page.locator('#gusers')).toBeHidden();
|
await page.locator('#modalCard .cardx').click();
|
||||||
await page.locator('#modalCard .cardacts .btn').first().click();
|
await expect(page.locator('#admin')).toHaveCount(0);
|
||||||
// Hiding the button is not the guard; the server is.
|
expect(await (await page.request.get('/')).text()).not.toContain('href=/admin');
|
||||||
|
// Asking for it anyway goes back to the app, and its script is refused.
|
||||||
|
await page.goto('/admin');
|
||||||
|
await expect(page).toHaveURL(/:8791\/$/);
|
||||||
|
expect((await page.request.get('/admin.js')).status()).toBe(403);
|
||||||
|
// Hiding the way in is not the guard; the server is.
|
||||||
expect((await page.request.get('/api/users')).status()).toBe(403);
|
expect((await page.request.get('/api/users')).status()).toBe(403);
|
||||||
await expect(page.locator('#logs')).toBeHidden();
|
|
||||||
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
expect((await page.request.get('/api/logs')).status()).toBe(403);
|
||||||
|
|
||||||
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
// Subscribing to a feed the admin already has costs no second fetch: same feed, same
|
||||||
@@ -474,11 +637,12 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page }
|
|||||||
|
|
||||||
// Every row says "Admin" on its checkbox, so match the name exactly.
|
// Every row says "Admin" on its checkbox, so match the name exactly.
|
||||||
const userRow = (page, name) =>
|
const userRow = (page, name) =>
|
||||||
page.locator('#modalCard [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
page.locator('#accounts [data-id]').filter({ has: page.locator('b', { hasText: new RegExp(`^${name}$`) }) });
|
||||||
|
|
||||||
async function openUsers(page) {
|
async function openUsers(page) {
|
||||||
await page.locator('#prefs').click();
|
await page.goto('/admin');
|
||||||
await page.locator('#gusers').click();
|
await page.locator('#atabs a', { hasText: 'Accounts' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin#accounts$/);
|
||||||
await expect(userRow(page, 'admin')).toBeVisible();
|
await expect(userRow(page, 'admin')).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,25 +859,47 @@ test('Popular lists what everyone here reads, but never a private feed', async (
|
|||||||
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
expect(top.every(t => ids.includes(t.id))).toBe(true);
|
||||||
expect(ids).not.toContain('paid-show');
|
expect(ids).not.toContain('paid-show');
|
||||||
|
|
||||||
// Subscribe from the directory this time; the popular list shares the same rows.
|
// Subscribe from the directory this time: the same feeds, as a grid of cover art.
|
||||||
|
const tiles = piper.locator('#popular .tile');
|
||||||
|
const pick = (row, name) => piper.locator(`#dirbar .${row} button`, { hasText: new RegExp(`^${name}$`) });
|
||||||
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
|
||||||
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
|
||||||
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
|
await expect(tiles.filter({ hasText: 'Test Show' })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
await expect(tiles.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
|
||||||
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
|
||||||
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
await expect(tiles.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
// Add a feed opened over Directory fills its own list, not the pane behind it.
|
// Two filters that combine: what a feed is, and what it is about.
|
||||||
|
expect(dir.find(p => p.id === 'test-show')).toMatchObject({ podcast: true, category: 'Technology' });
|
||||||
|
expect(dir.find(p => p.id === 'picture-blog')).toMatchObject({ podcast: false });
|
||||||
|
await pick('tabs', 'Blogs').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => !p.podcast).length);
|
||||||
|
await expect(tiles.filter({ hasText: 'Test Show' })).toHaveCount(0);
|
||||||
|
// No empty chips: no blog here names Technology, so Blogs does not offer it.
|
||||||
|
await expect(pick('chips', 'Technology')).toHaveCount(0);
|
||||||
|
await pick('tabs', 'Podcasts').click();
|
||||||
|
await pick('chips', 'Technology').click();
|
||||||
|
await expect(pick('chips', 'Technology')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast && p.category === 'Technology').length);
|
||||||
|
// A second press lifts the chip and leaves the kind as it was.
|
||||||
|
await pick('chips', 'Technology').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.filter(p => p.podcast).length);
|
||||||
|
await pick('tabs', 'All').click();
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
|
|
||||||
|
// Add a feed is for an address; Popular and Directory are where you browse (issue #30).
|
||||||
await piper.locator('#addFeed').click();
|
await piper.locator('#addFeed').click();
|
||||||
await expect(piper.locator('#modalCard .childrow', { hasText: 'Test Show' })).toBeVisible();
|
await expect(piper.locator('#nurl')).toBeVisible();
|
||||||
await expect(offered).toHaveCount(dir.length);
|
await expect(piper.locator('#modalCard .childrow')).toHaveCount(0);
|
||||||
|
await expect(tiles).toHaveCount(dir.length);
|
||||||
await piper.locator('#modalCard button[title="Cancel"]').click();
|
await piper.locator('#modalCard button[title="Cancel"]').click();
|
||||||
|
|
||||||
const row = async () =>
|
const row = async () =>
|
||||||
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
(await (await piper.request.get('/api/popular')).json()).find(p => p.id === 'test-show');
|
||||||
const before = await row();
|
const before = await row();
|
||||||
expect(before.subscribed).toBe(false);
|
expect(before.subscribed).toBe(false);
|
||||||
await offered.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
await tiles.filter({ hasText: 'Test Show' }).locator('button[title="Subscribe"]').click();
|
||||||
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
// Everyone counts, you included: it stays listed, marked as yours, with one more subscriber.
|
||||||
@@ -741,6 +927,66 @@ test('adding a feed scans it straight away', async ({ page }) => {
|
|||||||
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('.ep', { hasText: 'Fresh Ep' })).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('adding a page adds the feed it links, and a page with no feed is refused', async ({ page }) => {
|
||||||
|
const feeds = await page.locator('.feed').count();
|
||||||
|
await page.locator('#addFeed').click();
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/nofeed.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.toast')).toContainText('links no feed');
|
||||||
|
await expect(page.locator('#modal.on')).toBeVisible(); // left open to correct it
|
||||||
|
await expect(page.locator('.feed')).toHaveCount(feeds);
|
||||||
|
await page.locator('#nurl').fill('http://127.0.0.1:8792/site.html');
|
||||||
|
await page.locator('#nsave').click();
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Linked Site' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reading an item updates its feed\'s count without reloading the list', async ({ page }) => {
|
||||||
|
const unread = page.locator('.feed:not(.group)', { has: page.locator('.badge:not(.zero)') }).first();
|
||||||
|
await expect(unread).toBeVisible({ timeout: 20_000 });
|
||||||
|
const id = await unread.getAttribute('data-id');
|
||||||
|
const badge = page.locator(`.feed[data-id="${id}"] .badge`);
|
||||||
|
const before = Number(await badge.textContent());
|
||||||
|
await unread.click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible();
|
||||||
|
const lists = [];
|
||||||
|
page.on('request', r => { if (new URL(r.url()).pathname === '/api/feeds') lists.push(r.url()); });
|
||||||
|
await page.locator('.ep').first().click(); // opening an item reads it
|
||||||
|
await expect(badge).toHaveText(String(before - 1));
|
||||||
|
expect(lists, 'the row came back with the read, not by reloading the list').toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('artwork comes from iPX, kept, and only as an image', async ({ page }) => {
|
||||||
|
// Every image a feed names is drawn from iPX's address.
|
||||||
|
expect(await page.evaluate(() => artHTML('https://example.com/a.jpg', 'A'))).toContain('src="/api/art?u=https%3A%2F%2Fexample.com%2Fa.jpg"');
|
||||||
|
// Multi Show's items name art.jpg.
|
||||||
|
await expect(page.locator('.feed', { hasText: 'Multi Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const src = '/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/art.jpg');
|
||||||
|
for (const _ of [1, 2]) { // fetched, then kept
|
||||||
|
const r = await page.request.get(src);
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
expect(r.headers()['content-type']).toMatch(/^image\//);
|
||||||
|
expect(r.headers()['content-security-policy']).toContain('sandbox');
|
||||||
|
}
|
||||||
|
// An address no feed names is not fetched.
|
||||||
|
expect((await page.request.get('/api/art?u=' + encodeURIComponent('http://127.0.0.1:8792/show.xml'))).status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an item without a title is named from its text, then its file, then its show and date', async ({ page }) => {
|
||||||
|
const names = await page.evaluate(() => [
|
||||||
|
entryName({ title: 'A Title' }),
|
||||||
|
entryName({ title: ' ', description: '<p>I like the way <b>AI</b> is evolving.</p><img src="x.png">' }),
|
||||||
|
entryName({ description: '<p>' + 'word '.repeat(40) + '</p>' }).text.length,
|
||||||
|
entryName({ enclosures: [{ url: 'https://k.example/media/KARTAS691.mp3?x=1' }] }),
|
||||||
|
entryName({ feed_id: 'nobody', published: 0 }),
|
||||||
|
]);
|
||||||
|
expect(names[0]).toEqual({ text: 'A Title', derived: false });
|
||||||
|
expect(names[1]).toEqual({ text: 'I like the way AI is evolving.', derived: true });
|
||||||
|
expect(names[2]).toBeLessThanOrEqual(121); // cut at a word, with …
|
||||||
|
expect(names[3]).toEqual({ text: 'KARTAS691', derived: true });
|
||||||
|
expect(names[4]).toEqual({ text: 'nobody', derived: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
||||||
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
// Other people subscribe to Picture Blog by now, so both prompts come; take them.
|
||||||
page.on('dialog', d => d.accept());
|
page.on('dialog', d => d.accept());
|
||||||
@@ -759,13 +1005,14 @@ test('a deleted file looks as if it was never downloaded', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
test('one action, one icon: the toolbar, the page and every dialog agree', async ({ page }) => {
|
||||||
const icon = loc => loc.locator('svg path').first().getAttribute('d');
|
// The whole glyph, not just its path: pinned and not pinned share one outline and differ in fill.
|
||||||
|
const icon = loc => loc.locator('svg').first().innerHTML();
|
||||||
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click();
|
||||||
|
|
||||||
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
// Unsubscribe is a minus in the toolbar and the feed header, never the x that closes things.
|
||||||
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
expect(await icon(page.locator('#content .acts [data-a="rm"]'))).toBe(await icon(page.locator('#tbRemove')));
|
||||||
|
|
||||||
// The toolbar's read and keep show the selected item's state, as its own buttons do, and follow
|
// The toolbar's read and pin show the selected item's state, as its own buttons do, and follow
|
||||||
// a change made from the toolbar.
|
// a change made from the toolbar.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
const pair = async a => [await icon(page.locator(a === 'read' ? '#tbRead' : '#tbFlag')),
|
||||||
@@ -781,9 +1028,7 @@ test('one action, one icon: the toolbar, the page and every dialog agree', async
|
|||||||
const dialogs = [
|
const dialogs = [
|
||||||
() => page.locator('#addFeed').click(),
|
() => page.locator('#addFeed').click(),
|
||||||
() => page.locator('#prefs').click(),
|
() => page.locator('#prefs').click(),
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gusers').click(); },
|
|
||||||
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
async () => { await page.locator('#prefs').click(); await page.locator('#gopml').click(); },
|
||||||
() => page.locator('#logs').click(),
|
|
||||||
() => page.locator('#content .acts [data-a="settings"]').click(),
|
() => page.locator('#content .acts [data-a="settings"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="dl"]').click(),
|
() => page.locator('#content .acts [data-a="dl"]').click(),
|
||||||
() => page.locator('#content .acts [data-a="rm"]').click(),
|
() => page.locator('#content .acts [data-a="rm"]').click(),
|
||||||
@@ -808,6 +1053,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
|
|||||||
// its own button makes it unread again.
|
// its own button makes it unread again.
|
||||||
await page.locator('.ep').first().click();
|
await page.locator('.ep').first().click();
|
||||||
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
|
||||||
|
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
|
||||||
|
// begin with, and a mark-unread still in flight could land after the read-all below.
|
||||||
|
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
|
||||||
await expect(all.locator('.badge')).not.toHaveText('0');
|
await expect(all.locator('.badge')).not.toHaveText('0');
|
||||||
|
|
||||||
page.once('dialog', d => d.accept());
|
page.once('dialog', d => d.accept());
|
||||||
@@ -890,3 +1138,389 @@ test('someone the proxy signs in never sees the password page, and signs out thr
|
|||||||
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/);
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keys move through items and places, after Feedly', async ({ page }) => {
|
||||||
|
// Last in the file: selecting an item marks it read, which would change what later tests see.
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
const rows = page.locator('#eps .ep');
|
||||||
|
await expect(rows.nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const sel = page.locator('#eps .ep.sel');
|
||||||
|
const guid = i => rows.nth(i).getAttribute('data-guid');
|
||||||
|
await page.keyboard.press('j');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
||||||
|
await page.keyboard.press('j');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(1));
|
||||||
|
await page.keyboard.press('k');
|
||||||
|
await expect(sel).toHaveAttribute('data-guid', await guid(0));
|
||||||
|
|
||||||
|
// g and a letter go somewhere; typed into a box, the same letters are only text.
|
||||||
|
await page.keyboard.press('g');
|
||||||
|
await page.keyboard.press('d');
|
||||||
|
await expect(page.locator('#count')).toContainText('Directory');
|
||||||
|
await page.locator('#epSearch').focus();
|
||||||
|
await page.keyboard.type('ga');
|
||||||
|
await expect(page.locator('#count')).toContainText('Directory');
|
||||||
|
await page.locator('#epSearch').fill('');
|
||||||
|
await page.locator('#epSearch').blur();
|
||||||
|
|
||||||
|
await page.keyboard.press('?');
|
||||||
|
await expect(page.locator('#modalCard')).toContainText('Keyboard shortcuts');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.keyboard.press('g');
|
||||||
|
await page.keyboard.press('a');
|
||||||
|
await expect(page.locator('#count')).toContainText('All Subscriptions');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an admin can give a blog its Directory category', async ({ page }) => {
|
||||||
|
const patch = (id, category) => page.evaluate(([id, category]) =>
|
||||||
|
api(`/api/feeds/${id}`, { method: 'PATCH', body: JSON.stringify({ category }) }), [id, category]);
|
||||||
|
const listed = async id => (await page.evaluate(() => api('/api/directory'))).find(p => p.id === id);
|
||||||
|
|
||||||
|
await patch('picture-blog', 'Visual Arts');
|
||||||
|
expect(await listed('picture-blog')).toMatchObject({ podcast: false, category: 'Visual Arts' });
|
||||||
|
// A feed's own iTunes category wins over one given here.
|
||||||
|
await patch('test-show', 'Comedy');
|
||||||
|
expect((await listed('test-show')).category).toBe('Technology');
|
||||||
|
|
||||||
|
for (const id of ['picture-blog', 'test-show']) await patch(id, null);
|
||||||
|
expect((await listed('picture-blog')).category).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pinned heading sits over its pins, and the page is set in Inter', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await expect(page.locator('#eps .ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const boxes = {
|
||||||
|
headCell: await page.locator('.ephead [data-sort="kept"]').boundingBox(),
|
||||||
|
headIcon: await page.locator('.ephead [data-sort="kept"] svg').first().boundingBox(),
|
||||||
|
rowCell: await page.locator('#eps .ep .fl').first().boundingBox(),
|
||||||
|
rowIcon: await page.locator('#eps .ep .fl svg').first().boundingBox(),
|
||||||
|
};
|
||||||
|
const mid = b => b.x + b.width / 2;
|
||||||
|
expect(Math.abs(mid(boxes.headIcon) - mid(boxes.rowIcon)), JSON.stringify(boxes)).toBeLessThan(1);
|
||||||
|
|
||||||
|
// From ipx itself, not a font service.
|
||||||
|
expect((await page.request.get('/inter.woff2')).headers()['content-type']).toBe('font/woff2');
|
||||||
|
expect(await page.evaluate(() => document.fonts.ready.then(() => document.fonts.check('14px Inter')))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('a downloaded file can be deleted, from above the show notes', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'Downloaded' }).first().click();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const del = page.locator('#detail [data-a="del"]');
|
||||||
|
await expect(del).toBeInViewport();
|
||||||
|
// Below a long set of notes it was screens down and looked missing (issue #21).
|
||||||
|
expect(await page.locator('#detail').evaluate(d =>
|
||||||
|
!!(d.querySelector('.encbox').compareDocumentPosition(d.querySelector('.dbody')) & Node.DOCUMENT_POSITION_FOLLOWING)))
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file not yet downloaded has its icon in line with the rest of its row', async ({ page }) => {
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await expect(page.locator('.ep .dlbar').first()).toBeAttached({ timeout: 20_000 });
|
||||||
|
// The icon's middle against the date's, downloaded or not. The download bar used to take a
|
||||||
|
// line of its own and lift the icon of every pending file (issue #31).
|
||||||
|
const offsets = await page.$$eval('.ep', rows => rows.map(r => {
|
||||||
|
const k = r.querySelector('.file .kind'), d = r.querySelector('.date');
|
||||||
|
if (!k || !d) return null;
|
||||||
|
const a = k.getBoundingClientRect(), b = d.getBoundingClientRect();
|
||||||
|
return Math.round((a.top + a.height / 2) - (b.top + b.height / 2));
|
||||||
|
}).filter(x => x !== null));
|
||||||
|
expect(offsets.length).toBeGreaterThan(1);
|
||||||
|
for (const o of offsets) expect(Math.abs(o)).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the logo is the dark one in dark mode and the light one in light mode', async ({ page }) => {
|
||||||
|
const shown = () => page.locator('#applogo img:visible').getAttribute('src');
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo-dark\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
// Paper has only a light palette, so the light logo whatever the mode asked.
|
||||||
|
await page.evaluate(() => setTheme('paper', 'dark'));
|
||||||
|
expect(await shown()).toMatch(/^\/logo\.svg\?v=/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/status gives a dashboard the counts, with the shared token as a cookie', async ({ page }) => {
|
||||||
|
// As Homepage's customapi widget asks: no session, only the token in a Cookie header.
|
||||||
|
const r = await page.request.get('/api/status', { headers: { cookie: `ipx_token=${TOKEN}` } });
|
||||||
|
expect(r.status()).toBe(200);
|
||||||
|
const s = await r.json();
|
||||||
|
for (const k of ['feeds', 'pending', 'downloaded']) expect(typeof s[k], k).toBe('number');
|
||||||
|
expect(s.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
const none = await page.request.get('/api/status', { headers: { cookie: '' } });
|
||||||
|
expect(none.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the favicon is the logo, square, from both pages', async ({ page }) => {
|
||||||
|
await page.evaluate(() => setTheme('modern', 'light'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon\.png\?v=[0-9a-f]{12}$/);
|
||||||
|
await page.evaluate(() => setTheme('modern', 'dark'));
|
||||||
|
await expect(page.locator('#favicon')).toHaveAttribute('href', /^\/favicon-dark\.png\?v=[0-9a-f]{12}$/);
|
||||||
|
// A browser asks for /favicon.ico on its own, signed in or not.
|
||||||
|
for (const path of ['/favicon.ico', '/favicon.png', '/favicon-dark.png', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']) {
|
||||||
|
const r = await page.request.get(path, { headers: { cookie: '' } });
|
||||||
|
expect(r.status(), path).toBe(200);
|
||||||
|
expect(r.headers()['content-type'], path).toBe('image/png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing feed is marked on its artwork and says why', async ({ page }) => {
|
||||||
|
await expect(page.locator('.feed.group .chev').first()).toBeVisible();
|
||||||
|
if ((await page.locator('.feed.group .chev').first().getAttribute('aria-expanded')) !== 'true')
|
||||||
|
await page.locator('.feed.group .chev').first().click();
|
||||||
|
// Faked in the page: no fixture feed fails. A feed on its own failing for a day, and one
|
||||||
|
// inside a folder that failed its last check.
|
||||||
|
const solo = await page.evaluate(() => {
|
||||||
|
S.feeds.find(f => f.group).last_error = 'HTTP 404';
|
||||||
|
const f = S.feeds.find(f => !f.group && !S.feeds.some(c => c.group === f.id));
|
||||||
|
f.last_error = 'HTTP 404 Not Found';
|
||||||
|
f.failing = { reason: 'The publisher took this feed down, or moved it.' };
|
||||||
|
renderFeeds();
|
||||||
|
return f.id;
|
||||||
|
});
|
||||||
|
await expect(page.locator('.fart .ferr svg')).toHaveCount(3); // both feeds and the folder
|
||||||
|
await expect(page.locator('.chev.bad')).toHaveCount(1); // the folder holding one
|
||||||
|
const row = page.locator(`.feed[data-id="${solo}"]`);
|
||||||
|
await expect(row).toHaveClass(/failing/);
|
||||||
|
await expect(row.locator('small')).toHaveText('The publisher took this feed down, or moved it.');
|
||||||
|
await expect(page.locator('.feed.group.err small').first()).toHaveText('1 feed not updating');
|
||||||
|
await page.reload(); // put the real list back
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('touch gestures on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
// Playwright's touchscreen only taps; a drag goes through the DevTools protocol.
|
||||||
|
async function drag(page, from, to, lift = true) {
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
const steps = 8;
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [from] });
|
||||||
|
for (let i = 1; i <= steps; i++)
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{
|
||||||
|
x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps }] });
|
||||||
|
if (lift) await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
return cdp;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a swipe moves between items, and right from the first goes back to the list', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 230, y: 410 }); // too short: stays (#49)
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await expect(page.locator('#detail')).not.toHaveAttribute('style', /translate/); // springs back
|
||||||
|
// Held partway: the next item is beside the reader, not the list under it (#52), and
|
||||||
|
// let go short of a swipe, it goes again.
|
||||||
|
const held = await drag(page, { x: 300, y: 400 }, { x: 230, y: 405 }, false);
|
||||||
|
await expect(page.locator('#dpeek.next .dt')).toHaveText(titles[1]);
|
||||||
|
await expect(page.locator('#detail')).toHaveCSS('opacity', '1');
|
||||||
|
await held.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
await expect(page.locator('#dpeek')).toHaveCount(0);
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left on the last: stays
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right: the one before
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 200, y: 300 }, { x: 210, y: 600 }); // down: a scroll, not a swipe
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 }); // right on the first: the list
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on the Unread tab, a swipe back goes to the item just read', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
// Earlier tests read these; both have to be unread to be on the Unread tab.
|
||||||
|
await page.evaluate(() => Promise.all(S.entries.map(e => setRead(e, false))));
|
||||||
|
await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
|
||||||
|
await expect(page.locator('.ep').nth(1)).toBeVisible({ timeout: 20_000 });
|
||||||
|
const titles = await page.locator('.ep .t').allTextContents();
|
||||||
|
await page.locator('.ep').first().click();
|
||||||
|
const shown = page.locator('#detail .dt');
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 }); // left: the next item
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// The first is read now, and used to be gone from the list already, so this went back to
|
||||||
|
// the list instead.
|
||||||
|
await drag(page, { x: 80, y: 400 }, { x: 300, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[0]);
|
||||||
|
await drag(page, { x: 300, y: 400 }, { x: 80, y: 410 });
|
||||||
|
await expect(shown).toHaveText(titles[1]);
|
||||||
|
// Out of the reader, the ones read on the way leave the Unread tab as before.
|
||||||
|
await page.locator('#dback').click();
|
||||||
|
await expect(page.locator('body')).not.toHaveClass(/reading/);
|
||||||
|
await expect(page.locator('.ep .t', { hasText: titles[0] })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pulling the list down from its top checks the feed for new items', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
const box = await page.locator('#list').boundingBox();
|
||||||
|
const fetch = page.waitForRequest(r => r.url().endsWith('/api/fetch') && r.method() === 'POST');
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
expect((await fetch).postDataJSON()).toEqual({ feed: 'test-show', force: true });
|
||||||
|
await expect(page.locator('#pulltip')).toHaveCount(0); // the note goes on letting go
|
||||||
|
// and a spinner says the check started, for a couple of seconds.
|
||||||
|
await expect(page.locator('#pullspin')).toBeVisible();
|
||||||
|
// A second pull while it is up checks nothing more.
|
||||||
|
let again = 0;
|
||||||
|
page.on('request', r => { if (r.url().endsWith('/api/fetch')) again++; });
|
||||||
|
await drag(page, { x: 200, y: box.y + 20 }, { x: 200, y: box.y + 220 });
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#pullspin')).toHaveCount(0, { timeout: 5_000 });
|
||||||
|
expect(again).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('an item with no files, on a phone', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
test('shows no files box at all', async ({ page }) => {
|
||||||
|
await page.locator('#burger').click();
|
||||||
|
await page.locator('#feedlist .place', { hasText: 'All Subscriptions' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
// An item with no enclosure, found from the list the page itself has.
|
||||||
|
const guid = await page.evaluate(() => S.entries.find(e => !e.enclosures.length)?.guid);
|
||||||
|
expect(guid, 'the fixtures have an item with no files').toBeTruthy();
|
||||||
|
await page.locator(`.ep[data-guid="${guid}"]`).click();
|
||||||
|
await expect(page.locator('#detail .dt')).toBeVisible();
|
||||||
|
await expect(page.locator('#detail .encbox')).toHaveCount(0);
|
||||||
|
await expect(page.locator('#detail')).not.toContainText('No files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
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();
|
||||||
|
const name = (await child.locator('.txt b').textContent()).trim();
|
||||||
|
await child.click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
|
||||||
|
// First in the list, out of its folder, marked, and with the rule under it.
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
await expect(rows.first()).toHaveClass(/\bpinned\b/);
|
||||||
|
await expect(rows.first()).not.toHaveClass(/\bchild\b/);
|
||||||
|
await expect(rows.first()).toHaveClass(/\blastpin\b/);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(0);
|
||||||
|
await expect(page.locator('#content .acts [data-a="pin"]')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
// It is on the account: a reload keeps it.
|
||||||
|
await page.reload();
|
||||||
|
await expect(rows.first().locator('.txt b')).toHaveText(name);
|
||||||
|
|
||||||
|
// Unpinned, it goes back into its folder.
|
||||||
|
await rows.first().click();
|
||||||
|
await page.locator('#content .acts [data-a="pin"]').click();
|
||||||
|
await expect(page.locator('.feed.pinned')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.feed.child', { hasText: name })).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a feed being checked shows a spinner on its row, and no toast', async ({ page }) => {
|
||||||
|
const row = page.locator('#feedlist .feed', { hasText: 'Test Show' });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
await expect(row).toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge'), 'the spinner stands in for the count').toBeHidden();
|
||||||
|
await page.evaluate(() => setScanning('test-show', false));
|
||||||
|
await expect(row).not.toHaveClass(/\bscanning\b/);
|
||||||
|
await expect(row.locator('.badge')).toBeVisible();
|
||||||
|
// Checking every feed says so on the rows, not in a toast (issue #37).
|
||||||
|
await page.locator('#scanAll').click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.toast', { hasText: /Scanning|Checking| new/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"check every feed" checks only the feeds of the person asking', async ({ browser }) => {
|
||||||
|
// piper, made in an earlier test, subscribes to Test Show alone.
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const piper = await ctx.newPage();
|
||||||
|
await piper.goto('/login');
|
||||||
|
await piper.locator('#name').fill('piper');
|
||||||
|
await piper.locator('#pw').fill('piperpassword');
|
||||||
|
await piper.locator('button[type=submit]').click();
|
||||||
|
await expect(piper.locator('#feedlist .feed', { hasText: 'Test Show' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
const mine = await piper.evaluate(() => S.feeds.map(f => f.id));
|
||||||
|
// Listen as the page does, then ask.
|
||||||
|
await piper.evaluate(() => {
|
||||||
|
window.started = []; window.done = false;
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onmessage = m => { const ev = JSON.parse(m.data);
|
||||||
|
if (ev.ev === 'feed_start') window.started.push(ev.feed);
|
||||||
|
if (ev.ev === 'scan_done') window.done = true; };
|
||||||
|
});
|
||||||
|
await piper.waitForTimeout(500);
|
||||||
|
await piper.locator('#scanAll').click();
|
||||||
|
await expect.poll(() => piper.evaluate(() => window.done), { timeout: 30_000 }).toBe(true);
|
||||||
|
const started = await piper.evaluate(() => window.started);
|
||||||
|
expect(started.length, 'something was checked').toBeGreaterThan(0);
|
||||||
|
for (const id of started) expect(mine, `${id} is not one of piper's feeds`).toContain(id);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('words in Settings hide the items that mention them', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await page.locator('#prefs').click();
|
||||||
|
await page.locator('#sblock').fill('notes for the second'); // a phrase, in the show notes
|
||||||
|
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
|
||||||
|
await page.locator('#sblock').press('Tab');
|
||||||
|
expect((await saved).status()).toBe(204);
|
||||||
|
await page.locator('#modal .cardx').click();
|
||||||
|
await expect(page.locator('.ep', { hasText: 'Second Episode' })).toHaveCount(0);
|
||||||
|
await expect(page.locator('.ep', { hasText: 'First Episode' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => api('/api/me', { method: 'PATCH', body: JSON.stringify({ blocked: [] }) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Share hands the feed, the item and the file to the share sheet', async ({ page }) => {
|
||||||
|
await page.evaluate(() => { window.shared = []; navigator.share = async d => { window.shared.push(d); }; });
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
await page.locator('.tabs button', { hasText: 'All' }).first().click();
|
||||||
|
await page.locator('#content .acts [data-a="share"]').click();
|
||||||
|
await page.locator('.ep', { hasText: 'Second Episode' }).click();
|
||||||
|
await page.locator('.encbox [data-a="share"]').first().click();
|
||||||
|
await expect.poll(() => page.evaluate(() => window.shared)).toEqual([
|
||||||
|
{ title: 'Test Show', url: expect.stringContaining('/show.xml') },
|
||||||
|
{ title: 'Second Episode', url: expect.stringContaining('/ep1.mp3?2') },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh button turns while what it checks is being checked', async ({ page }) => {
|
||||||
|
await page.locator('.feed', { hasText: 'Test Show' }).click();
|
||||||
|
const spin = sel => page.locator(sel).evaluate(el => getComputedStyle(el).animationName);
|
||||||
|
const btn = '.fhead [data-a=scan] .i';
|
||||||
|
await expect(page.locator(btn)).toBeVisible();
|
||||||
|
expect(await spin(btn)).toBe('none');
|
||||||
|
await page.evaluate(() => setScanning('test-show', true));
|
||||||
|
expect(await spin(btn)).toBe('ipxspin');
|
||||||
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
|
// Another feed being checked turns the toolbar's, not this page's.
|
||||||
|
await page.evaluate(() => { setScanning('test-show', false); setScanning('multi-show', true); });
|
||||||
|
expect(await spin(btn)).toBe('none');
|
||||||
|
expect(await spin('#scanAll .i')).toBe('ipxspin');
|
||||||
|
await page.evaluate(() => setScanning('multi-show', false));
|
||||||
|
expect(await spin('#scanAll .i')).toBe('none');
|
||||||
|
});
|
||||||
|
|||||||
4
tests/ui/fixtures/linked.xml
Normal file
4
tests/ui/fixtures/linked.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Linked Site</title><link>http://127.0.0.1:8792/site.html</link>
|
||||||
|
<item><title>Linked Post</title><guid>linked-1</guid></item>
|
||||||
|
</channel></rss>
|
||||||
2
tests/ui/fixtures/nofeed.html
Normal file
2
tests/ui/fixtures/nofeed.html
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>No Feed Here</title></head><body>A site that links no feed.</body></html>
|
||||||
@@ -12,7 +12,10 @@ http.createServer((req, res) => {
|
|||||||
fs.readFile(file, (err, body) => {
|
fs.readFile(file, (err, body) => {
|
||||||
if (err) { res.writeHead(404).end('no'); return; }
|
if (err) { res.writeHead(404).end('no'); return; }
|
||||||
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
const type = file.endsWith('.mp3') ? 'audio/mpeg'
|
||||||
: file.endsWith('.opml') ? 'text/x-opml' : 'application/xml';
|
: file.endsWith('.opml') ? 'text/x-opml'
|
||||||
|
// Artwork as an image, or /api/art refuses it as not one.
|
||||||
|
: file.endsWith('.jpg') ? 'image/jpeg'
|
||||||
|
: file.endsWith('.html') ? 'text/html' : 'application/xml';
|
||||||
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
||||||
res.end(body);
|
res.end(body);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||||
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
<channel><title>Test Show</title><link>http://127.0.0.1:8792/</link><description>A fixture feed.</description>
|
||||||
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
<itunes:image href="http://127.0.0.1:8792/art.png"/>
|
||||||
|
<itunes:category text="Technology"/>
|
||||||
<item><title>First Episode</title><guid>ui-1</guid>
|
<item><title>First Episode</title><guid>ui-1</guid>
|
||||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
<description><p>Show notes for the first one.</p></description>
|
<description><p>Show notes for the first one.</p></description>
|
||||||
|
|||||||
4
tests/ui/fixtures/site.html
Normal file
4
tests/ui/fixtures/site.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><head><title>A Site</title>
|
||||||
|
<link rel="alternate" type="application/rss+xml" href="/linked.xml">
|
||||||
|
</head><body>A site with a feed.</body></html>
|
||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
// Type-checks web/src (npx tsc). Nothing is emitted: web/build.mjs does that with swc.
|
||||||
|
// The files are one script in one scope, not modules, which is why there are no imports.
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"lib": ["es2022", "dom", "dom.iterable"],
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": false,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["web/src/*.ts"]
|
||||||
|
}
|
||||||
92
web/Inter-LICENSE.txt
Normal file
92
web/Inter-LICENSE.txt
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
web/InterVariable.woff2
Normal file
BIN
web/InterVariable.woff2
Normal file
Binary file not shown.
34
web/admin.html
Normal file
34
web/admin.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>iPX admin</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" id="favicon" href="/favicon.png" data-light="/favicon.png" data-dark="/favicon-dark.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
<link rel="stylesheet" data-src="app.css">
|
||||||
|
</head>
|
||||||
|
<body class="adminpage">
|
||||||
|
<!-- The server sends this page, and its script, to admins only. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<a class="btn ico" href="/" title="Back to iPX" aria-label="Back to iPX" data-icon="left"></a>
|
||||||
|
<img class="logo onlight" src="/logo.svg" alt="" width="26"><img class="logo ondark" src="/logo-dark.svg" alt="" width="26">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<span class="grow"></span>
|
||||||
|
<nav class="tabs" id="atabs">
|
||||||
|
<a href="#server" data-t="server">Server</a>
|
||||||
|
<a href="#accounts" data-t="accounts">Accounts</a>
|
||||||
|
<a href="#log" data-t="log">Log</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="wrap plain" id="admin">
|
||||||
|
<section id="server" hidden></section>
|
||||||
|
<section id="accounts" hidden></section>
|
||||||
|
<section id="log" hidden></section>
|
||||||
|
</main>
|
||||||
|
<div id="toasts"></div>
|
||||||
|
|
||||||
|
<script data-src="web/src"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1299
web/app.css
Normal file
1299
web/app.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/apple-touch-icon.png
Normal file
BIN
web/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
96
web/build.mjs
Normal file
96
web/build.mjs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Builds the pages ipx serves: each page's TypeScript from web/src, types stripped and minified
|
||||||
|
// by swc into a script of its own (app.js, login.js), and the page minified, its
|
||||||
|
// <script data-src> pointing at that script. build.rs runs it into OUT_DIR, where web.rs
|
||||||
|
// include_str!s the results, so the binary still carries everything and nothing is served
|
||||||
|
// from disk.
|
||||||
|
//
|
||||||
|
// The page names its script with a hash of the script's contents, /app.js?v=<hash>, and the
|
||||||
|
// server lets a browser keep that for a year without asking again. A changed script is a new
|
||||||
|
// URL, and the page, which the browser checks on every visit, is what carries it.
|
||||||
|
//
|
||||||
|
// node web/build.mjs [out-dir] default out-dir: web/dist
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import swc from '@swc/core';
|
||||||
|
import html from '@swc/html';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// 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'] },
|
||||||
|
'admin.html': { script: 'admin.js', src: ['util', 'theme', 'admin'] },
|
||||||
|
'login.html': { script: 'login.js', src: ['login'] },
|
||||||
|
};
|
||||||
|
// The stylesheet the app and admin pages share, served and named by hash as the scripts are.
|
||||||
|
const STYLE = 'app.css';
|
||||||
|
|
||||||
|
const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 12);
|
||||||
|
|
||||||
|
/// 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 } = {}) {
|
||||||
|
const css = fs.readFileSync(path.join(here, STYLE), 'utf8');
|
||||||
|
if (!minify) return css;
|
||||||
|
const r = html.minifySync(`<!doctype html><style>${css}</style>`, { minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'error' || e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${STYLE}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
const out = r.code.slice(r.code.indexOf('<style>') + 7, r.code.lastIndexOf('</style>'));
|
||||||
|
// The minifier drops the space between a calc() and the value after it in a shorthand --
|
||||||
|
// `padding:7px calc(12px + var(--safe-r))7px ...` -- and a browser throws the whole
|
||||||
|
// declaration away, so the element silently loses its padding. It reports no error and the
|
||||||
|
// page still loads, which is why this is checked rather than trusted. Longhands avoid it.
|
||||||
|
const run = out.match(/calc\([^()]*(?:\([^()]*\)[^()]*)*\)(?=[0-9a-zA-Z.])/);
|
||||||
|
if (run) throw new Error(`${STYLE}: minifying ran ${run[0]} into the value after it; use longhand properties`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page and its script, built: { html, js, script }, where script is the file's name.
|
||||||
|
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');
|
||||||
|
const js = swc.transformSync(src, {
|
||||||
|
filename: name + '.ts',
|
||||||
|
jsc: {
|
||||||
|
parser: { syntax: 'typescript' },
|
||||||
|
target: 'es2022',
|
||||||
|
// Top-level names stay as they are: markup calls some of them by name (onclick="closeModal()")
|
||||||
|
// and the browser tests reach others (player, savePos) through page.evaluate.
|
||||||
|
minify: minify ? { compress: { toplevel: false }, mangle: { toplevel: false } } : undefined,
|
||||||
|
},
|
||||||
|
isModule: false,
|
||||||
|
minify,
|
||||||
|
}).code;
|
||||||
|
const page = fs.readFileSync(path.join(here, name), 'utf8');
|
||||||
|
const marker = /<script data-src="[^"]*"><\/script>/;
|
||||||
|
if (!marker.test(page)) throw new Error(`${name} has no <script data-src> to put its script in`);
|
||||||
|
// Where the inline script was, and a plain <script src>, so it still runs in the same place:
|
||||||
|
// after the markup it wires up, before anything else.
|
||||||
|
let out = page.replace(marker, `<script src="/${script}?v=${hash(js)}"></script>`);
|
||||||
|
out = out.replace(/<link rel="stylesheet" data-src="[^"]*">/,
|
||||||
|
() => `<link rel="stylesheet" href="/${STYLE}?v=${hash(buildStyle({ minify }))}">`);
|
||||||
|
// The icons are named by their contents too. They are kept a day under a fixed name, and a new
|
||||||
|
// logo went unseen for that day, in browsers and at Cloudflare's edge. The server ignores the
|
||||||
|
// query; /favicon.ico, which a browser asks for on its own, cannot carry one.
|
||||||
|
out = out.replace(/(href|src|srcset|data-light|data-dark)="\/(favicon\.png|favicon-dark\.png|apple-touch-icon\.png|logo\.svg|logo-dark\.svg)"/g,
|
||||||
|
(_, attr, file) => `${attr}="/${file}?v=${hash(fs.readFileSync(path.join(here, file)))}"`);
|
||||||
|
if (!minify) return { html: out, js, script };
|
||||||
|
const r = html.minifySync(out, { minifyJs: false, minifyCss: true, removeComments: true });
|
||||||
|
const bad = (r.errors || []).filter(e => e.level === 'Error');
|
||||||
|
if (bad.length) throw new Error(`${name}: ${bad.map(e => e.message).join('; ')}`);
|
||||||
|
return { html: r.code, js, script };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const out = process.argv[2] || path.join(here, 'dist');
|
||||||
|
fs.mkdirSync(out, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(out, STYLE), buildStyle());
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/favicon-dark.png
Normal file
BIN
web/favicon-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/favicon.png
Normal file
BIN
web/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
2025
web/index.html
2025
web/index.html
File diff suppressed because it is too large
Load Diff
@@ -1,54 +1,65 @@
|
|||||||
<title>Sign in — iPodderX</title>
|
<!doctype html>
|
||||||
<link rel="icon" href="/icon.png">
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<title>Sign in — iPX</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon.png" media="(prefers-color-scheme:light)">
|
||||||
|
<link rel="icon" type="image/png" sizes="128x128" href="/favicon-dark.png" media="(prefers-color-scheme:dark)">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<style>
|
<style>
|
||||||
|
/* Inter, from ipx itself; see the same rule in index.html. */
|
||||||
|
@font-face{font-family:Inter;src:url(/inter.woff2) format("woff2");font-weight:100 900;font-display:swap}
|
||||||
:root {
|
:root {
|
||||||
--bg:#0e131b; /* the screen's navy (#314B74), taken right down */
|
--bg:#0a1726; /* the dark logo's navy (#0c2238), taken down */
|
||||||
--panel:#151c27;
|
--panel:#0f1f31;
|
||||||
--panel2:#1c2431;
|
--panel2:#15283d;
|
||||||
--raise:#25303f;
|
--raise:#1d3450;
|
||||||
--line:#2c3849;
|
--line:#264060;
|
||||||
--fg:#f5f5f5; /* #F5F5F5 device highlight */
|
--fg:#f3f7fb;
|
||||||
--dim:#95a0b1; /* #95A0B1 straight from the icon's blue-grey */
|
--dim:#a3b6ca;
|
||||||
--faint:#7a8799; /* lifted from the icon ramp until it clears AA at small sizes */
|
--faint:#8a9fb5;
|
||||||
--accent:#92b2e6; /* #92B2E6 the screen blue */
|
--accent:#8fc2ea; /* #8FC2EA the dark logo's scale */
|
||||||
--accent2:#f49e2c; /* #F49E2C the EQ bars */
|
--accent2:#ff6a1a; /* #FF6A1A the dark logo's needle */
|
||||||
--ink:#0e131b; /* text on an accent fill */
|
--ink:#0a1726; /* text on an accent fill */
|
||||||
--good:#6fbf8b;
|
--good:#6fbf8b;
|
||||||
--warn:#f49e2c; /* the amber doubles as the pending colour */
|
--warn:#f5a524; /* amber, apart from the needle's orange, for pending */
|
||||||
--bad:#e2705f;
|
--bad:#ff6b7a; /* pushed towards pink, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(6,10,16,.55);
|
--shadow:0 8px 28px rgba(4,10,20,.55);
|
||||||
--r:10px;
|
--r:10px;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] {
|
/* Modern, as app.css has it. Signed out, there is no account to take a theme from, so this follows
|
||||||
--bg:#f2f4f7;
|
the system. */
|
||||||
--panel:#ffffff; /* #FFFFFF device body */
|
@media (prefers-color-scheme:light){:root {
|
||||||
--panel2:#e9edf3;
|
--bg:#eef5fb; /* the light logo's sky (#DFF0FB), paler */
|
||||||
--raise:#dde3ec;
|
--panel:#ffffff;
|
||||||
--line:#d6d6d6; /* #D6D6D6 device edge */
|
--panel2:#e4eff9;
|
||||||
--fg:#1a1a1a; /* #1A1A1A icon outline */
|
--raise:#d5e6f5;
|
||||||
--dim:#606060; /* #606060 */
|
--line:#c6d9ea;
|
||||||
--faint:#767676; /* between the icon's #929292 and #606060, to clear AA */
|
--fg:#10233a;
|
||||||
--accent:#2d5391; /* #2D5391 the deep screen blue reads better on white */
|
--dim:#46596e;
|
||||||
--accent2:#b06f10;
|
--faint:#56697e;
|
||||||
|
--accent:#2f6aa0; /* #2F6AA0 the logo's scale */
|
||||||
|
--accent2:#c43e00; /* the needle (#FF5500), taken down until white on it clears AA */
|
||||||
--ink:#ffffff;
|
--ink:#ffffff;
|
||||||
--good:#2f7d4f;
|
--good:#2f7d4f;
|
||||||
--warn:#b06f10;
|
--warn:#985e0a; /* amber, apart from the needle's orange; lighter failed AA as text */
|
||||||
--bad:#b3402f;
|
--bad:#b3263a; /* pushed towards crimson, apart from the needle */
|
||||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
--shadow:0 8px 28px rgba(47,106,160,.14);
|
||||||
}
|
}}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
html,body{height:100%}
|
||||||
body{
|
body{
|
||||||
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
margin:0;display:grid;place-items:center;background:var(--bg);color:var(--fg);
|
||||||
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
|
font:14.5px/1.55 Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:20px;
|
||||||
}
|
}
|
||||||
form{
|
form{
|
||||||
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
width:min(360px,100%);background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
border-radius:14px;padding:22px;box-shadow:var(--shadow);
|
||||||
}
|
}
|
||||||
/* The one place the 2004 icon is shown at the size it was drawn for. */
|
/* The logo is a square app icon, so it gets an app icon's corners. */
|
||||||
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
.brand{display:flex;flex-direction:column;align-items:center;gap:6px;margin-bottom:20px}
|
||||||
.brand img{width:96px;height:auto}
|
.brand img{width:72px;height:72px;border-radius:16px}
|
||||||
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.01em}
|
||||||
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
label{display:block;font-size:12px;color:var(--dim);margin:0 0 4px}
|
||||||
input{
|
input{
|
||||||
@@ -65,7 +76,7 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<form id="f">
|
<form id="f">
|
||||||
<div class="brand"><img src="/icon.png" alt=""><h1>iPodderX</h1></div>
|
<div class="brand"><picture><source media="(prefers-color-scheme:light)" srcset="/logo.svg"><img src="/logo-dark.svg" alt=""></picture><h1>iPX</h1></div>
|
||||||
<label for="name">Name</label>
|
<label for="name">Name</label>
|
||||||
<input id="name" name="name" autocomplete="username" autofocus required>
|
<input id="name" name="name" autocomplete="username" autofocus required>
|
||||||
<label for="pw">Password</label>
|
<label for="pw">Password</label>
|
||||||
@@ -74,20 +85,4 @@ button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|||||||
<p class="msg" id="msg"></p>
|
<p class="msg" id="msg"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<script>
|
<script data-src="web/src"></script>
|
||||||
document.getElementById('f').onsubmit = async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const msg = document.getElementById('msg');
|
|
||||||
msg.textContent = '';
|
|
||||||
const r = await fetch('/api/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: document.getElementById('name').value,
|
|
||||||
password: document.getElementById('pw').value,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (r.ok) location.href = '/';
|
|
||||||
else msg.textContent = await r.text() || 'Sign in failed';
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|||||||
19
web/logo-dark.svg
Normal file
19
web/logo-dark.svg
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- logo.svg's dark appearance: the same layers in the same places, only recoloured, as the
|
||||||
|
guidelines ask, so the icon reads as itself in either. -->
|
||||||
|
<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="scale" fill="#8fc2ea">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff6a1a"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1003 B |
22
web/logo.svg
Normal file
22
web/logo.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<!-- The iPodderX radio's screen, reduced to its tuning scale and needle, laid out as Apple's
|
||||||
|
app icon guidelines ask: square and opaque (the system cuts the corners, and paints
|
||||||
|
transparency black), one background and flat foreground layers with hard edges, no
|
||||||
|
highlights or shadows of its own (the system adds them), nothing thin enough to vanish
|
||||||
|
at 32px. Each <g> is a layer, for splitting out into Icon Composer. -->
|
||||||
|
<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="scale" fill="#2f6aa0">
|
||||||
|
<rect x="120" y="520" width="112" height="304" rx="56" opacity=".45"/>
|
||||||
|
<rect x="288" y="360" width="112" height="464" rx="56" opacity=".7"/>
|
||||||
|
<rect x="456" y="440" width="112" height="384" rx="56" opacity=".55"/>
|
||||||
|
<rect x="624" y="280" width="112" height="544" rx="56" opacity=".85"/>
|
||||||
|
<rect x="792" y="600" width="112" height="224" rx="56" opacity=".5"/>
|
||||||
|
</g>
|
||||||
|
<g id="needle"><rect x="728" y="160" width="72" height="704" rx="36" fill="#ff5500"/></g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
209
web/src/admin.ts
Normal file
209
web/src/admin.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/* ---------------- admin page ---------------- */
|
||||||
|
// /admin: the server's settings, the accounts, and the log, each a section chosen by the URL's
|
||||||
|
// hash so a link can go straight to one. The server sends this page and this script to admins
|
||||||
|
// only, and refuses every call below to anyone else; they were parts of Settings and the header,
|
||||||
|
// shown or hidden by the main page's script (issue #19).
|
||||||
|
|
||||||
|
let me: {name: string} | null = null;
|
||||||
|
api('/api/me').then(u => { me = u; }).catch(() => {});
|
||||||
|
|
||||||
|
const SECTIONS: Record<string, () => void> = {server: drawServer, accounts: drawAccounts, log: drawLogView};
|
||||||
|
|
||||||
|
function showSection(){
|
||||||
|
const t = SECTIONS[location.hash.slice(1)] ? location.hash.slice(1) : 'server';
|
||||||
|
for(const s of $$('#admin > section')) s.hidden = s.id !== t;
|
||||||
|
for(const a of $$('#atabs a')) a.classList.toggle('on', a.dataset.t === t);
|
||||||
|
// The log polls every two seconds while it is showing, and not otherwise.
|
||||||
|
if(t !== 'log' && logTimer){ clearInterval(logTimer); logTimer = null; }
|
||||||
|
SECTIONS[t]();
|
||||||
|
}
|
||||||
|
window.addEventListener('hashchange', showSection);
|
||||||
|
|
||||||
|
/* ---------------- server ---------------- */
|
||||||
|
async function drawServer(){
|
||||||
|
const box = $('#server');
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const gs = splitEvery(g.every_mins);
|
||||||
|
box.innerHTML = `<h2>Server</h2>
|
||||||
|
<p class="hint">These apply to everyone. Each person's own choices, such as keywords or how
|
||||||
|
many items a feed downloads for them, are in that feed's settings.</p>
|
||||||
|
<div class="field"><label>Check feeds every</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="number" id="gnum" min="1" max="999" value="${gs.n}">
|
||||||
|
<select id="gunit">${unitOptions(gs.u)}</select>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Applies to every feed that does not set its own. A feed's suggested
|
||||||
|
interval (its <b>ttl</b>) is still honoured when it asks to be polled less often.</span></div>
|
||||||
|
<div class="field"><label>Newest episodes to download, per feed</label>
|
||||||
|
<input type="number" id="gmax" min="0" max="999" value="${g.max_new_per_check}">
|
||||||
|
<span class="hint">Applies to any feed that does not set its own — including every feed
|
||||||
|
inside an OPML subscription. Older episodes stay listed to download by hand. <b>0 means
|
||||||
|
every episode</b>, the whole back catalogue.</span></div>
|
||||||
|
<div class="field"><label>Download these media types automatically</label>
|
||||||
|
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" placeholder="audio, video">
|
||||||
|
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
|
||||||
|
put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
|
||||||
|
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
|
||||||
|
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
|
||||||
|
<span class="hint">Over this, the oldest played items are deleted first. Pinned
|
||||||
|
items are never touched.</span></div>
|
||||||
|
<div class="field"><label>Delete items older than (days, 0 = keep)</label>
|
||||||
|
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
|
||||||
|
<div class="field"><label>Artwork kept (MB, 0 = none)</label>
|
||||||
|
<input type="number" id="gart" min="0" value="${g.art_cache_mb}">
|
||||||
|
<span class="hint">Show and episode artwork is kept here once shown, so it loads from iPX
|
||||||
|
instead of each publisher. Over this, what has gone longest unshown is dropped first.</span></div>
|
||||||
|
<div class="field"><label>Download folder</label>
|
||||||
|
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="gsave">${ICON.check} Save</button></div>`;
|
||||||
|
$('#gsave').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/settings', {method: 'PATCH', body: JSON.stringify({
|
||||||
|
schedule: `every ${Math.max(1, Number($('#gnum').value) || 1)}${$('#gunit').value}`,
|
||||||
|
max_new_per_check: Math.max(0, Number($('#gmax').value) || 0),
|
||||||
|
media_types: $('#gtypes').value.split(',').map(t => t.trim()).filter(Boolean),
|
||||||
|
max_total_gb: Number($('#gquota').value) || 0,
|
||||||
|
max_age_days: Number($('#gage').value) || 0,
|
||||||
|
art_cache_mb: Math.max(0, Number($('#gart').value) || 0)})});
|
||||||
|
toast('Settings saved');
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- accounts ---------------- */
|
||||||
|
async function drawAccounts(){
|
||||||
|
const box = $('#accounts');
|
||||||
|
const users = await api('/api/users') || [];
|
||||||
|
box.innerHTML = `<h2>Accounts</h2>
|
||||||
|
${users.map(u => `<div class="inline urow" data-id="${u.id}">
|
||||||
|
<div style="flex:1;min-width:0"><b style="overflow-wrap:anywhere">${esc(u.name)}</b>
|
||||||
|
<small style="display:block;color:var(--faint)">${u.created ? `Added ${dateOf(u.created)}` : 'Added before this was kept'} · ${
|
||||||
|
u.last_login ? `signed in ${ago(u.last_login)}` : 'never signed in'}</small></div>
|
||||||
|
${u.password ? '' : '<span class="tag" title="No password: signs in through the proxy">Proxy</span>'}
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" data-a="admin" ${u.admin ? 'checked' : ''}> Admin</label>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Remove ${esc(u.name)}" aria-label="Remove ${esc(u.name)}">${ICON.trash}</button></div>`).join('')}
|
||||||
|
<div class="field" style="margin-top:20px"><label>Add someone</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="uname" placeholder="Name" autocomplete="off" spellcheck="false">
|
||||||
|
<input type="password" id="upass" placeholder="Password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<label class="check" style="margin-top:8px"><input type="checkbox" id="uadmin"> Admin</label>
|
||||||
|
<span class="hint">At least 8 characters. Leave the password empty for someone who signs in
|
||||||
|
through the proxy. New people start with no feeds.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn primary" id="uadd">${ICON.plus} Add</button></div>`;
|
||||||
|
const change = async (u, opts) => {
|
||||||
|
try{
|
||||||
|
await api(`/api/users/${u.id}`, opts);
|
||||||
|
// Demoting yourself takes this page away; go back to the app rather than stay on a page
|
||||||
|
// the server no longer answers. Only on success: a refusal's toast has to stay readable.
|
||||||
|
if(u.name === me?.name){ location.href = '/'; return; }
|
||||||
|
}catch(e){ toast(e.message, true); }
|
||||||
|
drawAccounts(); // on a refusal, this puts the checkbox back where the server left it
|
||||||
|
};
|
||||||
|
for(const row of $$('#accounts [data-id]')){
|
||||||
|
const u = users.find(x => String(x.id) === row.dataset.id);
|
||||||
|
$('[data-a="admin"]', row).onchange = e =>
|
||||||
|
change(u, {method: 'PATCH', body: JSON.stringify({admin: e.target.checked})});
|
||||||
|
$('[data-a="rm"]', row).onclick = () => {
|
||||||
|
if(confirm(`Remove ${u.name}? Their subscriptions and read state go with them. Downloaded files stay.`))
|
||||||
|
change(u, {method: 'DELETE'});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$('#uadd').onclick = async () => {
|
||||||
|
try{
|
||||||
|
await api('/api/users', {method: 'POST', body: JSON.stringify({
|
||||||
|
name: $('#uname').value, password: $('#upass').value, admin: $('#uadmin').checked})});
|
||||||
|
toast('Added'); drawAccounts();
|
||||||
|
}catch(e){ toast(e.message, true); } // keep what was typed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- log ---------------- */
|
||||||
|
let logTimer = null, logSeq = 0, logLines = [], logFilter = '', logLevel = '', logTab = 'all';
|
||||||
|
// Which sources belong to each tab. "daemon" is the control protocol itself: every
|
||||||
|
// command in and every event out, whatever sent it.
|
||||||
|
const LOG_TABS = {
|
||||||
|
all: null,
|
||||||
|
daemon: t => t === 'ipx::io',
|
||||||
|
scan: t => t === 'ipx::scan',
|
||||||
|
web: t => t === 'ipx::http',
|
||||||
|
};
|
||||||
|
const LEVELS = {ERROR: 3, WARN: 2, INFO: 1, DEBUG: 0, TRACE: 0};
|
||||||
|
|
||||||
|
function drawLogView(){
|
||||||
|
logSeq = 0; logLines = [];
|
||||||
|
$('#log').innerHTML = `<h2>Log</h2>
|
||||||
|
<div class="logbar">
|
||||||
|
<div class="tabs" id="logtabs">
|
||||||
|
${Object.keys(LOG_TABS).map(t =>
|
||||||
|
`<button data-t="${t}" class="${logTab === t ? 'on' : ''}">${
|
||||||
|
{all: 'All', daemon: 'Daemon I/O', scan: 'Scans', web: 'HTTP'}[t]}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<select id="loglevel" style="width:auto">
|
||||||
|
<option value="">All levels</option>
|
||||||
|
<option value="INFO">Info and above</option>
|
||||||
|
<option value="WARN">Warnings and errors</option>
|
||||||
|
<option value="ERROR">Errors only</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" id="logq" class="grow" placeholder="Filter…">
|
||||||
|
<label class="check" style="margin:0"><input type="checkbox" id="logfollow" checked> Follow</label>
|
||||||
|
<button class="btn ico" id="logcopy" title="Copy what is showing" aria-label="Copy what is showing">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<div id="logbox"><p class="empty">Loading…</p></div>
|
||||||
|
<span class="hint"><b>Daemon I/O</b> is the control protocol itself — every command in and
|
||||||
|
every event out. <b>Scans</b> is feed and download activity, <b>HTTP</b> is web requests.
|
||||||
|
The buffer keeps debug detail even when the terminal does not; <b>IPX_UI_LOG</b> changes
|
||||||
|
what it captures.</span>`;
|
||||||
|
for(const b of $$('#logtabs button')) b.onclick = () => {
|
||||||
|
logTab = b.dataset.t;
|
||||||
|
for(const x of $$('#logtabs button')) x.classList.toggle('on', x.dataset.t === logTab);
|
||||||
|
drawLog();
|
||||||
|
};
|
||||||
|
$('#loglevel').onchange = e => { logLevel = e.target.value; drawLog(); };
|
||||||
|
$('#logq').oninput = e => { logFilter = e.target.value.toLowerCase(); drawLog(); };
|
||||||
|
$('#logcopy').onclick = () => copyText(visibleLog().map(l =>
|
||||||
|
`${new Date(l.ts * 1000).toISOString()} ${l.level} ${l.target} ${l.msg}`).join('\n'), $('#logcopy'));
|
||||||
|
pollLog();
|
||||||
|
if(!logTimer) logTimer = setInterval(pollLog, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollLog(){
|
||||||
|
try{
|
||||||
|
const r = await api(`/api/logs?after=${logSeq}&limit=500`);
|
||||||
|
if(r.lines.length){
|
||||||
|
logLines = logLines.concat(r.lines).slice(-2000);
|
||||||
|
logSeq = r.latest;
|
||||||
|
drawLog();
|
||||||
|
}else if(!logLines.length){ drawLog(); }
|
||||||
|
}catch(e){
|
||||||
|
const box = $('#logbox');
|
||||||
|
if(box) box.innerHTML = `<p class="empty">Lost contact with the daemon: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function visibleLog(){
|
||||||
|
const min = logLevel ? LEVELS[logLevel] : -1;
|
||||||
|
const tab = LOG_TABS[logTab];
|
||||||
|
return logLines.filter(l =>
|
||||||
|
(!tab || tab(l.target)) &&
|
||||||
|
(LEVELS[l.level] ?? 1) >= min &&
|
||||||
|
(!logFilter || (l.msg + ' ' + l.target).toLowerCase().includes(logFilter)));
|
||||||
|
}
|
||||||
|
function drawLog(){
|
||||||
|
const box = $('#logbox'); if(!box) return;
|
||||||
|
const follow = $('#logfollow')?.checked;
|
||||||
|
const rows = visibleLog();
|
||||||
|
box.innerHTML = rows.length ? rows.map(l => {
|
||||||
|
const t = new Date(l.ts * 1000).toLocaleTimeString();
|
||||||
|
if(logTab === 'daemon'){
|
||||||
|
const out = l.msg.startsWith('<-');
|
||||||
|
return `<div class="l"><time>${t}</time>` +
|
||||||
|
`<span class="lv" style="color:${out ? 'var(--good)' : 'var(--accent)'}">${out ? 'out' : 'in'}</span>` +
|
||||||
|
`<span>${esc(l.msg.replace(/^[<-]+\s*/, ''))}</span></div>`;
|
||||||
|
}
|
||||||
|
return `<div class="l"><time>${t}</time><span class="lv ${esc(l.level)}">${esc(l.level)}</span>` +
|
||||||
|
`<span class="tg">${esc(l.target.replace(/^ipx::?/, ''))}</span><span>${esc(l.msg)}</span></div>`;
|
||||||
|
}).join('') : '<p class="empty">Nothing matches.</p>';
|
||||||
|
if(follow) box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
showSection();
|
||||||
417
web/src/dialogs.ts
Normal file
417
web/src/dialogs.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/* ---------------- modals ---------------- */
|
||||||
|
function openModal(html: string, wide?: boolean){
|
||||||
|
$('#modalCard').innerHTML=html;
|
||||||
|
$('#modalCard').classList.toggle('wide',!!wide);
|
||||||
|
$('#modal').classList.add('on');
|
||||||
|
}
|
||||||
|
function closeModal(){
|
||||||
|
$('#modal').classList.remove('on');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#modal').onclick=e=>{ if(e.target.id==='modal') closeModal(); };
|
||||||
|
|
||||||
|
$('#addFeed').onclick=()=>{
|
||||||
|
openModal(`<h3>Add a feed</h3>
|
||||||
|
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss">
|
||||||
|
<span class="hint">A Patreon token on its own adds every show from that creator.</span></div>
|
||||||
|
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
|
||||||
|
<div class="field"><label>Keywords (optional, comma separated)</label>
|
||||||
|
<input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="nexp"> Allow items marked explicit</label>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="nsave" title="Add feed" aria-label="Add feed">${ICON.plus}</button></div>`);
|
||||||
|
$('#nurl').focus();
|
||||||
|
$('#nsave').onclick=async()=>{
|
||||||
|
const url=$('#nurl').value.trim(); if(!url) return;
|
||||||
|
$('#nsave').disabled=true; $('#nsave').title='Adding…';
|
||||||
|
try{
|
||||||
|
const r=await api('/api/feeds',{method:'POST',body:JSON.stringify({
|
||||||
|
url, folder:$('#nfolder').value.trim()||null, allow_explicit:$('#nexp').checked,
|
||||||
|
keywords:$('#nkw').value.split(',').map(s=>s.trim()).filter(Boolean)})});
|
||||||
|
closeModal(); toast(r.existing?`Already subscribed as ${r.id}`:`Added ${r.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(r.id);
|
||||||
|
}catch(e){ toast(e.message,true); $('#nsave').title='Add feed'; $('#nsave').disabled=false; }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// What everyone here reads, you included, as a place to start. The rows carry an id, never a
|
||||||
|
// URL, so a key in someone's feed address never reaches this page.
|
||||||
|
const NONE_LISTED='<p class="hint">Nothing yet. Feeds people here subscribe to show up here.</p>';
|
||||||
|
async function listFeeds(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':NONE_LISTED;
|
||||||
|
for(const p of rows) box.appendChild(listedFeed(p,'childrow'));
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One listed feed: a row in Popular and the Add a feed dialog, a tile in Directory's grid. The
|
||||||
|
/// parts are the same either way; the class lays them out.
|
||||||
|
function listedFeed(p,cls){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className=cls;
|
||||||
|
el.innerHTML=artHTML(p.image,p.title||p.id)+
|
||||||
|
`<div class="txt"><b>${esc(p.title||p.id)}</b>`+
|
||||||
|
`<small class="meta">${p.subscribers} subscriber${p.subscribers===1?'':'s'}</small></div>`+
|
||||||
|
// Green, as a downloaded file is: it is already yours. Plus, beside it, is the way to get one.
|
||||||
|
(p.subscribed?`<span class="subbed" title="Subscribed: click to open it" aria-label="Subscribed">${ICON.subbed}</span>`
|
||||||
|
:`<button class="btn ico" data-a="sub" title="Subscribe" aria-label="Subscribe">${ICON.plus}</button>`);
|
||||||
|
// Yours already: the row opens it instead.
|
||||||
|
if(p.subscribed){ el.onclick=()=>{ closeModal(); selectFeed(p.id); }; return el; }
|
||||||
|
$('[data-a="sub"]',el).onclick=async()=>{
|
||||||
|
try{
|
||||||
|
await api(`/api/popular/${encodeURIComponent(p.id)}`,{method:'POST'});
|
||||||
|
closeModal(); toast(`Subscribed to ${p.title||p.id}`);
|
||||||
|
await loadFeeds(true); selectFeed(p.id);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory's filters. Kept out here because a finished scan redraws the pane, which would
|
||||||
|
// otherwise clear them.
|
||||||
|
let dirKind='All', dirCat=null;
|
||||||
|
const KINDS={All:()=>true,Podcasts:p=>p.podcast,Blogs:p=>!p.podcast};
|
||||||
|
/// Directory: every listed feed as its cover art, under two filters that combine: what a feed is
|
||||||
|
/// (Podcasts, anything with audio or video, or Blogs, the rest) and what it is about (its iTunes
|
||||||
|
/// category, as chips). Both filter in place, without asking the server again.
|
||||||
|
async function renderDirectory(url,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=await api(url)||[]; }catch{}
|
||||||
|
if(!rows.length){ box.innerHTML=NONE_LISTED; return 0; }
|
||||||
|
const bar=$('#dirbar');
|
||||||
|
// Only a filter when the server has both kinds.
|
||||||
|
const both=rows.some(KINDS.Podcasts)&&rows.some(KINDS.Blogs);
|
||||||
|
const btn=(k,v,on)=>`<button type="button" data-${k}="${esc(v)}" class="${on?'on':''}" aria-pressed="${on}">${esc(v)}</button>`;
|
||||||
|
const draw=()=>{
|
||||||
|
if(!both) dirKind='All';
|
||||||
|
const ofKind=rows.filter(KINDS[dirKind]);
|
||||||
|
// No empty chips: only the categories among the feeds the kind lets through.
|
||||||
|
const cats=[...new Set(ofKind.map(p=>p.category).filter(Boolean))].sort();
|
||||||
|
if(!cats.includes(dirCat)) dirCat=null;
|
||||||
|
bar.innerHTML=
|
||||||
|
(both?`<div class="tabs" role="group" aria-label="Kind">${Object.keys(KINDS).map(k=>btn('kind',k,k===dirKind)).join('')}</div>`:'')+
|
||||||
|
(cats.length?`<div class="chips" role="group" aria-label="Category">${cats.map(c=>btn('cat',c,c===dirCat)).join('')}</div>`:'');
|
||||||
|
// A picked chip lifts on a second press. Everything is redrawn, so the keyboard goes back to
|
||||||
|
// the button just pressed.
|
||||||
|
for(const b of $$('button',bar)) b.onclick=()=>{
|
||||||
|
const k=b.dataset.kind!=null?'kind':'cat', v=b.dataset[k];
|
||||||
|
if(k==='kind') dirKind=v; else dirCat=dirCat===v?null:v;
|
||||||
|
draw(); $(`[data-${k}="${CSS.escape(v)}"]`,bar)?.focus();
|
||||||
|
};
|
||||||
|
box.innerHTML='';
|
||||||
|
for(const p of ofKind.filter(p=>!dirCat||p.category===dirCat)) box.appendChild(listedFeed(p,'tile'));
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory and Popular open in the main pane, as the original's Directory did.
|
||||||
|
async function renderListed(v){
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.add('plain');
|
||||||
|
$('#tbRemove').disabled=true;
|
||||||
|
syncTools(null);
|
||||||
|
$('#epSearch').placeholder='Search items…';
|
||||||
|
const listening=v===VIEWS[':listening'], grid=v===VIEWS[':directory'];
|
||||||
|
box.innerHTML=`
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta"><h2>${v.title}</h2>
|
||||||
|
<div class="sub">${v.blurb}${listening?'':' Everyone counts, you included. Private feeds are never listed.'}</div></div>
|
||||||
|
</div>
|
||||||
|
${grid?'<div class="dirbar" id="dirbar"></div>':''}
|
||||||
|
<div class="${grid?'tiles':'childlist'}" id="${listening?'listening':'popular'}"><p class="hint">Loading…</p></div>`;
|
||||||
|
$('#count').textContent=v.title;
|
||||||
|
const n=await (listening?renderListening:grid?renderDirectory:listFeeds)(v.url,$(listening?'#listening':'#popular',box));
|
||||||
|
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${plural(n,listening?'episode':'feed')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,box){
|
||||||
|
let rows=[];
|
||||||
|
try{ rows=(await api(url)).entries||[]; }catch{}
|
||||||
|
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
|
||||||
|
for(const e of rows){
|
||||||
|
// Carries its episode, for paintListenRow to repaint as the player moves.
|
||||||
|
const el: HTMLDivElement & {entry?: any}=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.entry=e;
|
||||||
|
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
|
||||||
|
`<div class="txt"><b>${EQ}<span>${esc(entryName(e).text)}</span></b>`+
|
||||||
|
`<small><span class="fd">${esc(feedName(e.feed_id))}</span><span class="left"></span></small></div>`+
|
||||||
|
`<button class="iconbtn" data-a="play"></button>`+
|
||||||
|
`<button class="iconbtn" data-a="remove" title="Remove from Currently Listening" aria-label="Remove from Currently Listening">${ICON.close}</button>`+
|
||||||
|
`<div class="rail"><i></i></div>`;
|
||||||
|
el.onclick=ev=>(ev.target as Element).closest('[data-a=remove]')?forget(e)
|
||||||
|
:el.classList.contains('now')&&!audio.paused?audio.pause():play(e);
|
||||||
|
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.
|
||||||
|
function paintListenRow(el){
|
||||||
|
const e=el.entry, now=player.guid===e.guid&&player.feed===e.feed_id;
|
||||||
|
// Zero until the player has sought to where you left off; the saved position stands till then.
|
||||||
|
if(now&&audio.currentTime) e.position=Math.floor(audio.currentTime);
|
||||||
|
// The player's own length first: a feed's can be minutes out.
|
||||||
|
const d=(now&&isFinite(audio.duration)&&Math.floor(audio.duration))||e.duration;
|
||||||
|
el.classList.toggle('now',now);
|
||||||
|
$('.left',el).textContent=d?`${clock(d-e.position)} left`:`${clock(e.position)} in`;
|
||||||
|
// With no length there is nothing to show, and an empty rail reads as a heavy border.
|
||||||
|
const rail=$('.rail',el); rail.hidden=!d;
|
||||||
|
$('i',rail).style.width=`${d?Math.min(100,e.position/d*100):0}%`;
|
||||||
|
const b=$('[data-a=play]',el), label=now&&!audio.paused?'Pause':'Resume';
|
||||||
|
if(b.title!==label){ b.title=label; b.setAttribute('aria-label',label); b.innerHTML=label==='Pause'?ICON.pause:ICON.play; }
|
||||||
|
}
|
||||||
|
/// Keeps the list in step with the player. Only a row that is, or was, the one in it changes.
|
||||||
|
function syncListening(){
|
||||||
|
for(const el of $$('#listening .childrow'))
|
||||||
|
if(el.entry&&(el.classList.contains('now')||player.guid===el.entry.guid)) paintListenRow(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes an episode off Currently Listening by forgetting where you got to: the list is every
|
||||||
|
/// episode with a saved position short of the end, so the position is what has to go.
|
||||||
|
async function forget(e){
|
||||||
|
// Closed without saving first, or the player's next save would put it straight back.
|
||||||
|
if(player.guid===e.guid){ player.guid=null; $('#pclose').click(); }
|
||||||
|
try{
|
||||||
|
await api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/position`,
|
||||||
|
{method:'POST',body:JSON.stringify({secs:0})});
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
if(S.feed===':listening') renderListed(VIEWS[':listening']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The toolbar acts on whatever is selected: the feed on the left, the item in the table.
|
||||||
|
$('#tbRemove').onclick=()=>{ const f=S.feeds.find(x=>x.id===S.feed); if(f) removeFeed(f); };
|
||||||
|
$('#tbPlay').onclick=()=>{ const e=cur(); if(e) play(e); };
|
||||||
|
$('#tbRead').onclick=()=>{ const e=cur(); if(e) epAction('read',e,null); };
|
||||||
|
$('#tbFlag').onclick=()=>{ const e=cur(); if(e) epAction('flag',e,null); };
|
||||||
|
let searchT;
|
||||||
|
$('#epSearch').oninput=ev=>{ clearTimeout(searchT);
|
||||||
|
searchT=setTimeout(()=>{ S.q=ev.target.value; S.offset=0; loadEntries(); },250); };
|
||||||
|
// Crossing the phone breakpoint moves the files between their pane and the text.
|
||||||
|
window.matchMedia?.('(max-width:820px)')?.addEventListener?.('change',()=>{ const e=cur(); if(e) showDetail(e); });
|
||||||
|
|
||||||
|
let expanded = new Set(JSON.parse(localStorage.getItem('ipx.expanded')||'[]'));
|
||||||
|
function toggleGroup(id){
|
||||||
|
expanded.has(id) ? expanded.delete(id) : expanded.add(id);
|
||||||
|
try{ localStorage.setItem('ipx.expanded', JSON.stringify([...expanded])); }catch{}
|
||||||
|
renderFeeds();
|
||||||
|
}
|
||||||
|
let globalMax = 3;
|
||||||
|
function due(ts){
|
||||||
|
const d = ts - Date.now()/1000;
|
||||||
|
if(d <= 0) return 'due now';
|
||||||
|
if(d < 3600) return 'in '+Math.max(1,Math.round(d/60))+'m';
|
||||||
|
if(d < 86400) return 'in '+Math.round(d/3600)+'h';
|
||||||
|
return 'in '+Math.round(d/86400)+'d';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Your settings: the theme, your subscriptions as OPML, and, to read, what the server does
|
||||||
|
/// with feeds. The server's own settings, the accounts and the log are on /admin, which only an
|
||||||
|
/// admin is sent (issue #19); this used to hold them too, shown to admins only.
|
||||||
|
async function prefsModal(){
|
||||||
|
const g = await api('/api/settings');
|
||||||
|
const admin = !!(S.me&&S.me.admin);
|
||||||
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Settings</h3>
|
||||||
|
<div class="field"><label>Theme</label>
|
||||||
|
<select id="stheme">${Object.entries(THEMES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.name===k?' selected':''}>${esc(t.name)}</option>`).join('')}</select></div>
|
||||||
|
<div class="field" id="smodefield"${THEMES[theme.name].modes?'':' hidden'}><label>Light or dark</label>
|
||||||
|
<select id="smode">${Object.entries(MODES).map(([k,t])=>
|
||||||
|
`<option value="${k}"${theme.mode===k?' selected':''}>${esc(t)}</option>`).join('')}</select>
|
||||||
|
<span class="hint">Auto follows your system's light/dark setting.</span></div>
|
||||||
|
<div class="field"><label>Subscriptions</label>
|
||||||
|
<div class="inline">
|
||||||
|
<!-- Words as well as icons: a floppy disk and a plus mean nothing on their own here. -->
|
||||||
|
<a class="btn" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save} Export</a>
|
||||||
|
<button class="btn" id="gopml" title="Import OPML…" aria-label="Import OPML">${ICON.plus} Import…</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">Export saves your subscriptions as OPML for another podcast app. Import
|
||||||
|
subscribes you to every feed in one.</span></div>
|
||||||
|
<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((S.me?.blocked||[]).join(', '))}">
|
||||||
|
<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>Feeds are checked every</label>
|
||||||
|
<span class="hint">${everyText(g.every_mins)}, for every feed that does not set its own.
|
||||||
|
${admin?'This and the rest of the server\'s settings are on the <a href="/admin">admin page</a>.':'Only an admin changes this.'}</span></div>`);
|
||||||
|
$('#stheme').onchange=e=>setTheme(e.target.value,undefined,true);
|
||||||
|
$('#smode').onchange=e=>setTheme(undefined,e.target.value,true);
|
||||||
|
$('#gopml').onclick=opmlModal;
|
||||||
|
$('#sblock').onchange=async e=>{
|
||||||
|
const blocked=splitWords(e.target.value);
|
||||||
|
try{
|
||||||
|
await api('/api/me',{method:'PATCH',body:JSON.stringify({blocked})});
|
||||||
|
if(S.me) S.me.blocked=blocked;
|
||||||
|
toast('Saved'); await loadFeeds(true); loadEntries();
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitWords=(s: string)=>s.split(',').map(w=>w.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
function settingsModal(f, newUrl?: string){
|
||||||
|
const isGroup = S.feeds.some(c=>c.group===f.id);
|
||||||
|
openModal(`<h3>${esc(f.title||f.id)}</h3>
|
||||||
|
${isGroup?`<p class="hint" style="margin:-6px 0 12px">This is ${isPatreon(f)?'a Patreon creator':'an OPML subscription'}. These
|
||||||
|
settings apply to it and are inherited by every feed inside it.</p>`:''}
|
||||||
|
${f.managed?`<p class="hint" style="margin:-6px 0 12px">This feed comes from
|
||||||
|
${isPatreon(S.feeds.find(p=>p.id===f.group))?'a Patreon creator':'an OPML subscription'} and follows its settings. Saving anything here gives it its own entry in
|
||||||
|
config.toml, and it stops following the subscription's settings.</p>`:''}
|
||||||
|
<p class="hint" style="margin:-4px 0 10px">These are <b>your</b> settings for this feed.
|
||||||
|
Everyone else keeps their own.</p>
|
||||||
|
<div class="field"><label>Keywords</label>
|
||||||
|
<input type="text" id="skw" value="${esc(f.keywords.join(', '))}">
|
||||||
|
<span class="hint">Comma separated. Empty takes everything.</span></div>
|
||||||
|
${isGroup?'':`<div class="field"><label for="sblock">Hide items mentioning</label>
|
||||||
|
<input type="text" id="sblock" value="${esc((f.blocked||[]).join(', '))}">
|
||||||
|
<span class="hint">Comma separated words or phrases. An item with one in its title or text
|
||||||
|
is hidden from you and not downloaded for you, as well as those your Settings hide
|
||||||
|
everywhere.</span></div>`}
|
||||||
|
<div class="field"><label>Newest episodes to download</label>
|
||||||
|
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
|
||||||
|
<span class="hint">Blank follows the global default (${globalMax}). Older episodes stay
|
||||||
|
listed to download by hand. <b>0 means every episode</b>, the whole back catalogue.</span></div>
|
||||||
|
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
|
||||||
|
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
|
||||||
|
<div class="field"><label>Feed URL</label>
|
||||||
|
<div class="inline">
|
||||||
|
<input type="text" id="surl" value="${esc(newUrl||f.url)}" spellcheck="false" ${S.me&&S.me.admin?'':'readonly'}>
|
||||||
|
<button type="button" class="btn ico" id="scopy" title="Copy the URL" aria-label="Copy the URL">${ICON.copy}</button>
|
||||||
|
</div>
|
||||||
|
<span class="hint">${S.me&&S.me.admin
|
||||||
|
? `Shared with everyone reading this feed. Editing it keeps every item and download —
|
||||||
|
handy when an auth token in the URL is rotated. The feed is re-checked from scratch
|
||||||
|
on the next scan.`
|
||||||
|
: `The same for everyone reading this feed, so only an admin can change it.`}</span></div>
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Download folder (shared)</label>
|
||||||
|
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||||
|
<span class="hint">Where the files land. There is one copy however many people
|
||||||
|
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||||
|
${S.me&&S.me.admin?`<div class="field"><label>Directory category (shared)</label>
|
||||||
|
<input type="text" id="scat" list="scats" value="${esc(f.category||'')}" placeholder="${esc(f.feed_category||'None')}">
|
||||||
|
<datalist id="scats"></datalist>
|
||||||
|
<span class="hint">${f.feed_category
|
||||||
|
? `The feed names its own, ${esc(f.feed_category)}, and the Directory uses that.`
|
||||||
|
: `The feed names none, so the Directory files it under this. Pick one already listed where it fits.`}</span></div>`:''}
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">${ICON.check}</button></div>`);
|
||||||
|
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||||
|
// Offer the categories the Directory already shows, so a blog about games joins Games rather
|
||||||
|
// than starting a second chip beside it.
|
||||||
|
if($('#scats')) api('/api/directory').then(rows=>{ $('#scats').innerHTML=[...new Set((rows||[])
|
||||||
|
.map(p=>p.category).filter(Boolean))].sort().map(c=>`<option value="${esc(c)}">`).join(''); }).catch(()=>{});
|
||||||
|
$('#ssave').onclick=async()=>{
|
||||||
|
const max=$('#smax').value;
|
||||||
|
try{
|
||||||
|
const patch: Record<string, unknown>={
|
||||||
|
keywords:splitWords($('#skw').value),
|
||||||
|
max_new_per_check:max===''?null:Number(max),
|
||||||
|
auto_download:$('#sauto').checked, allow_explicit:$('#sexp').checked};
|
||||||
|
if($('#sblock')) patch.blocked=splitWords($('#sblock').value);
|
||||||
|
// The shared half is an admin's to change, and the API refuses it from anyone else.
|
||||||
|
if(S.me&&S.me.admin){
|
||||||
|
patch.url=$('#surl').value.trim();
|
||||||
|
patch.folder=$('#sfolder').value.trim()||null;
|
||||||
|
patch.category=$('#scat').value.trim()||null;
|
||||||
|
}
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify(patch)});
|
||||||
|
closeModal(); toast('Saved — applies on the next scan');
|
||||||
|
await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadLatestModal(f){
|
||||||
|
openModal(`<h3>Download latest items</h3>
|
||||||
|
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||||
|
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||||
|
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">${ICON.download}</button></div>`);
|
||||||
|
$('#dgo').onclick=async()=>{
|
||||||
|
const n=Number($('#dcount').value)||5;
|
||||||
|
try{
|
||||||
|
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
|
||||||
|
{method:'POST',body:JSON.stringify({count:n})});
|
||||||
|
closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeed(f){
|
||||||
|
openModal(`<h3>Unsubscribe?</h3>
|
||||||
|
<p style="color:var(--dim)">Removes <b>${esc(f.title||f.id)}</b> from your feeds. Anyone else
|
||||||
|
reading it keeps it, along with their own read state.
|
||||||
|
Downloaded files and history are kept, so re-adding it will not pull the back catalogue again.</p>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
|
<button class="btn ico danger" id="rgo" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button></div>`);
|
||||||
|
$('#rgo').onclick=async()=>{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'DELETE'});
|
||||||
|
closeModal(); toast('Unsubscribed'); S.feed=null;
|
||||||
|
await loadFeeds(); if(!S.feeds.length) renderFeed();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function opmlModal(){
|
||||||
|
openModal(`<h3>OPML</h3>
|
||||||
|
<p style="color:var(--dim);font-size:13.5px">Move subscriptions between podcast apps.</p>
|
||||||
|
<div class="field"><label>Export: save your subscriptions as OPML</label>
|
||||||
|
<div class="inline">
|
||||||
|
<a class="btn ico" href="/api/opml" download="ipx-subscriptions.opml" title="Export OPML" aria-label="Export OPML">${ICON.save}</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="field" style="margin-top:16px"><label>Import: choose a file, or paste OPML</label>
|
||||||
|
<input type="file" id="opmlFile" accept=".opml,.xml,text/x-opml,text/xml,application/xml" style="margin-bottom:8px">
|
||||||
|
<textarea id="opmlText" rows="6" style="width:100%;background:var(--bg);border:1px solid var(--line);color:var(--fg);border-radius:8px;padding:8px;font:12px monospace"></textarea></div>
|
||||||
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button>
|
||||||
|
<button class="btn ico primary" id="oimp" title="Import: subscribe to every feed in it" aria-label="Import">${ICON.plus}</button></div>`);
|
||||||
|
$('#oimp').onclick=async()=>{
|
||||||
|
// A chosen file is read here and sent as text, so the server never stores it. Clearing
|
||||||
|
// the picker lets go of it on this side too, whether it was refused or imported.
|
||||||
|
const pick=$('#opmlFile'), file=pick.files[0];
|
||||||
|
const xml=file ? await file.text() : $('#opmlText').value;
|
||||||
|
const letGo=()=>{ pick.value=''; };
|
||||||
|
// A quick look before sending anything. The server parses it properly and has the last word.
|
||||||
|
if(!/<opml[\s>]/i.test(xml)){
|
||||||
|
letGo(); toast(`${file?file.name:'That'} is not an OPML file`,true); return;
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const r=await api('/api/opml',{method:'POST',body:JSON.stringify({xml})});
|
||||||
|
letGo(); closeModal(); toast(`Subscribed to ${r.added} feed(s)`+(r.already?`, ${r.already} you already had`:'')); loadFeeds(true);
|
||||||
|
}catch(e){ letGo(); toast(e.message,true); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No toast: the spinners on the rows being checked say it (issue #37).
|
||||||
|
async function scanAll(){ await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
|
$('#scanAll').onclick=scanAll;
|
||||||
|
$('#prefs').onclick=prefsModal;
|
||||||
|
// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while
|
||||||
|
// the proxy still vouches for them. /api/me says where, when that is the case.
|
||||||
|
$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; };
|
||||||
|
api('/api/me').then(u=>{
|
||||||
|
S.me=u;
|
||||||
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
|
}).catch(()=>{});
|
||||||
|
$('#feedFilter').oninput=renderFeeds;
|
||||||
|
// The feed list from the keyboard: Enter or Space opens a row, Right and Left open and close a
|
||||||
|
// folder. Handled keys stop here, or the player's own Space and arrows would act on them too.
|
||||||
|
$('#feedlist').onkeydown=ev=>{
|
||||||
|
const row=ev.target.closest('[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')&&
|
||||||
|
(ev.key==='ArrowRight'&&!expanded.has(id)||ev.key==='ArrowLeft'&&expanded.has(id))) toggleGroup(id);
|
||||||
|
else return;
|
||||||
|
ev.preventDefault(); ev.stopPropagation();
|
||||||
|
};
|
||||||
|
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||||
|
$('#scrim').onclick=()=>nav(false);
|
||||||
|
|
||||||
54
web/src/events.ts
Normal file
54
web/src/events.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* ---------------- live events ---------------- */
|
||||||
|
let sse;
|
||||||
|
function connect(){
|
||||||
|
sse=new EventSource('/api/events');
|
||||||
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
||||||
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
|
sse.onmessage=m=>{
|
||||||
|
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
|
||||||
|
if(ev.ev==='progress'){
|
||||||
|
const pct=ev.total?ev.done/ev.total*100:0;
|
||||||
|
// Only the row actually downloading. Without the enclosure id this used to paint
|
||||||
|
// every pending bar at once, so adding a feed looked like it was fetching the lot.
|
||||||
|
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
|
||||||
|
if(bar){ bar.style.width=pct+'%'; bar.parentElement.classList.add('live'); }
|
||||||
|
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
|
||||||
|
}
|
||||||
|
else if(ev.ev==='download_done'){
|
||||||
|
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
|
||||||
|
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
|
||||||
|
// everyone's feeds used to announce themselves to everyone.
|
||||||
|
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
|
||||||
|
refreshEntries();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
|
||||||
|
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
|
||||||
|
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
|
||||||
|
else if(ev.ev==='feed_done'){
|
||||||
|
setScanning(ev.feed,false);
|
||||||
|
if(!mine(ev.feed)) return;
|
||||||
|
if(ev.feed===S.feed||S.feed===':all') refreshEntries();
|
||||||
|
}
|
||||||
|
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
|
||||||
|
// red ! marks the feed instead, and its page says why.
|
||||||
|
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);
|
||||||
|
// 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
|
||||||
|
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
|
||||||
|
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); if(ev.feeds) refreshEntries(); }
|
||||||
|
};
|
||||||
|
sse.onerror=()=>{ sse.close(); setTimeout(connect,4000); };
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
loadFeeds();
|
||||||
189
web/src/feedpage.ts
Normal file
189
web/src/feedpage.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/* ---------------- feed page ---------------- */
|
||||||
|
function renderFeed(){
|
||||||
|
paintScanning(); // another feed's page may be the one open now
|
||||||
|
const box=$('#content');
|
||||||
|
box.classList.remove('plain');
|
||||||
|
const v=VIEWS[S.feed];
|
||||||
|
if(v&&v.url) return renderListed(v);
|
||||||
|
const f=v?null:S.feeds.find(x=>x.id===S.feed);
|
||||||
|
$('#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;
|
||||||
|
$('#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; }
|
||||||
|
const unreadAll=S.feeds.reduce((n,x)=>n+(x.unread||0),0);
|
||||||
|
box.innerHTML = (f ? `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${artHTML(f.image,name)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(name)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}${f.next_check?`, next ${due(f.next_check)}`:''}">${
|
||||||
|
plural(f.entries,'item')}, ${f.downloaded} downloaded · checked ${ago(f.last_checked)}${
|
||||||
|
f.subscribers>1?` · shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'}`:''}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
|
||||||
|
OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
|
||||||
|
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="scan" title="Check this feed now" aria-label="Check this feed now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">${ICON.download}</button>
|
||||||
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
|
</div>
|
||||||
|
</div>` : `
|
||||||
|
<div class="fhead slim">
|
||||||
|
<div class="art">${v.icon}</div>
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${v.title}</h2>
|
||||||
|
<div class="sub">Every item from the ${S.feeds.length} feed${S.feeds.length===1?'':'s'} you
|
||||||
|
subscribe to, newest first · ${unreadAll} unread</div>
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="scanall" title="Check every feed now" aria-label="Check every feed now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="readall" title="Mark everything read" aria-label="Mark everything read">${ICON.checks}</button>
|
||||||
|
</div>
|
||||||
|
</div>`) + `
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="tabs">
|
||||||
|
${[['all','All'],['unread','Unread'],['downloaded','Downloaded'],['flagged','Pinned']].map(([t,label])=>
|
||||||
|
`<button data-f="${t}" class="${S.filter===t?'on':''}">${label}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const pane=document.createElement('div');
|
||||||
|
pane.id='split';
|
||||||
|
if(f) pane.className='one';
|
||||||
|
pane.innerHTML='<div id="list">'+sortHead()+
|
||||||
|
'<div id="eps"></div></div><div id="files"></div><div id="grab"></div><div id="detail"></div>';
|
||||||
|
$$('.ephead [data-sort]',pane).forEach(b=>b.onclick=()=>sortBy(b.dataset.sort));
|
||||||
|
box.appendChild(pane);
|
||||||
|
pane.style.setProperty('--listh', localStorage.getItem('ipx.listh') || '60%');
|
||||||
|
dragSplit(pane);
|
||||||
|
// Nothing is open any more, so nothing is kept on the Unread tab for being open.
|
||||||
|
S.sel=null;
|
||||||
|
showDetail(null);
|
||||||
|
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
|
||||||
|
$$('#content .tabs button').forEach(b=>b.onclick=()=>{
|
||||||
|
S.filter=b.dataset.f; S.offset=0;
|
||||||
|
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
|
||||||
|
renderFeed(); loadEntries();
|
||||||
|
});
|
||||||
|
if(f) wireFailBanner(box,f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item table's headings, each a button that sorts by its column. The first click goes the
|
||||||
|
/// natural way round (A to Z; newest, largest and kept first) and the next one reverses it.
|
||||||
|
const COLS=[['kept','Pinned',ICON.pin],['title','Title'],['feed','Feed'],['type','File'],['size','Size'],['published','Published']];
|
||||||
|
function sortHead(){
|
||||||
|
return '<div class="ephead"><span></span>'+COLS.map(([k,label,icon])=>{
|
||||||
|
const on=S.sort.col===k;
|
||||||
|
return `<button class="hs${on?' on':''}${k==='feed'?' h-fd':''}${icon?' h-ic':''}" data-sort="${k}"`+
|
||||||
|
` title="Sort by ${label.toLowerCase()}" aria-label="Sort by ${label.toLowerCase()}">${icon||label}`+
|
||||||
|
`${on?`<span class="arr ${S.sort.dir}">${ICON.caret}</span>`:''}</button>`;
|
||||||
|
}).join('')+'<span></span></div>';
|
||||||
|
}
|
||||||
|
function sortBy(col){
|
||||||
|
const first=['published','size','kept'].includes(col)?'desc':'asc';
|
||||||
|
S.sort={col,dir:S.sort.col===col?(S.sort.dir==='asc'?'desc':'asc'):first};
|
||||||
|
try{ localStorage.setItem('ipx.sort',JSON.stringify(S.sort)); }catch{}
|
||||||
|
S.offset=0; renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An OPML subscription's page lists the feeds inside it rather than items, but keeps
|
||||||
|
/// every action a normal feed has -- it is still an ordinary feed entry underneath.
|
||||||
|
// A Patreon creator split into its shows is drawn like an OPML, and named for what it is.
|
||||||
|
const isPatreon=f=>/patreon\.com\//.test(f&&f.url||'');
|
||||||
|
|
||||||
|
function renderGroup(f,kids){
|
||||||
|
const unread=kids.reduce((n,c)=>n+c.unread,0);
|
||||||
|
const saved=kids.reduce((n,c)=>n+c.downloaded,0);
|
||||||
|
const gone=kids.filter(c=>c.orphaned).length;
|
||||||
|
$('#count').textContent=`${f.title||f.id}: ${kids.length} feed${kids.length===1?'':'s'}, ${unread} unread`;
|
||||||
|
// The same header as a feed's, buttons in the same places: it is a feed underneath.
|
||||||
|
$('#content').innerHTML = `
|
||||||
|
<div class="fhead slim">
|
||||||
|
${folderArt(f,kids)}
|
||||||
|
<div class="meta">
|
||||||
|
<h2>${esc(f.title||f.id)}</h2>
|
||||||
|
<div class="sub stat" title="Checked every ${everyText(f.every_mins)}">${isPatreon(f)?'Patreon creator':'OPML subscription'}
|
||||||
|
· ${plural(kids.length,'feed')}, ${unread} unread, ${saved} downloaded · checked ${ago(f.last_checked)}</div>
|
||||||
|
${failBannerHTML(f)}
|
||||||
|
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||||
|
listed but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||||
|
</div>
|
||||||
|
<div class="acts">
|
||||||
|
<button class="btn ico" data-a="scan" title="Re-read the OPML now" aria-label="Re-read the OPML now">${ICON.scan}</button>
|
||||||
|
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">${ICON.checks}</button>
|
||||||
|
<button class="btn ico" data-a="pin" title="${f.pinned?'Unpin from the top of the feed list':'Pin to the top of the feed list'}" aria-label="${f.pinned?'Unpin':'Pin'}" aria-pressed="${!!f.pinned}">${f.pinned?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>
|
||||||
|
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">${ICON.settings}</button>
|
||||||
|
<button class="btn ico danger" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">${ICON.circleMinus}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input type="search" class="grow" id="kidSearch" placeholder="Search these feeds…">
|
||||||
|
<span style="color:var(--faint);font-size:12.5px">${esc(f.url)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="childlist" id="kidlist"></div>`;
|
||||||
|
$$('#content .acts .btn').forEach(b=>b.onclick=()=>feedAction(b.dataset.a,f));
|
||||||
|
wireFailBanner($('#content'),f);
|
||||||
|
const draw=()=>{
|
||||||
|
const q=($('#kidSearch').value||'').trim().toLowerCase();
|
||||||
|
const box=$('#kidlist'); box.innerHTML='';
|
||||||
|
const rows=kids.filter(c=>!q||(c.title||c.id).toLowerCase().includes(q)).sort(unreadFirst);
|
||||||
|
if(!rows.length){ box.innerHTML='<p class="empty">Nothing matches.</p>'; return; }
|
||||||
|
for(const c of rows){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='childrow';
|
||||||
|
el.innerHTML = artHTML(c.image,c.title||c.id)+
|
||||||
|
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+
|
||||||
|
`<small class="meta">${plural(c.entries,'item')} · ${c.downloaded} downloaded`+
|
||||||
|
(c.failing?` · <span style="color:var(--bad)" title="${esc(c.failing.reason)}">error</span>`
|
||||||
|
:c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
|
||||||
|
(c.orphaned?'<span class="tag">Gone</span>':'')+
|
||||||
|
(c.failing?`<span class="tag" style="color:var(--bad)" title="${esc(c.failing.reason)}">Error</span>`:'')+
|
||||||
|
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
|
||||||
|
el.onclick=()=>selectFeed(c.id);
|
||||||
|
box.appendChild(el);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$('#kidSearch').oninput=draw;
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function feedAction(a,f){
|
||||||
|
if(a==='scan'){ await api('/api/fetch',{method:'POST',body:JSON.stringify({feed:f.id,force:true})}); }
|
||||||
|
if(a==='read'){ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/read-all`,{method:'POST'}); toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries(); }
|
||||||
|
if(a==='rm') removeFeed(f);
|
||||||
|
if(a==='pin'){
|
||||||
|
try{
|
||||||
|
await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({pinned:!f.pinned})});
|
||||||
|
await loadFeeds(true); renderFeed();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
if(a==='settings') settingsModal(f);
|
||||||
|
if(a==='share') share(f.title||f.id, f.url, $('#content .acts [data-a="share"]'));
|
||||||
|
if(a==='dl') downloadLatestModal(f);
|
||||||
|
}
|
||||||
|
/// All Subscriptions' own buttons: a feed's, across every feed you read.
|
||||||
|
async function allAction(a){
|
||||||
|
if(a==='scanall') return scanAll();
|
||||||
|
if(a==='readall'){
|
||||||
|
const n=S.feeds.reduce((k,x)=>k+(x.unread||0),0);
|
||||||
|
if(!n){ toast('Nothing unread'); return; }
|
||||||
|
// One click across every feed is a lot to take back, so this one asks first.
|
||||||
|
if(!confirm(`Mark all ${n} unread item${n===1?'':'s'} read, in every feed you subscribe to?`)) return;
|
||||||
|
try{
|
||||||
|
const r=await api('/api/read-all',{method:'POST'});
|
||||||
|
toast(`Marked ${r.marked} read`); await loadFeeds(true); renderFeed(); loadEntries();
|
||||||
|
}catch(e){ toast(e.message,true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
169
web/src/feeds.ts
Normal file
169
web/src/feeds.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/* ---------------- feeds ---------------- */
|
||||||
|
/// One feed's row from the server, in place of the old one (or added, for a feed an OPML just
|
||||||
|
/// listed). A scan sends dozens; the list is redrawn once a frame, not once each.
|
||||||
|
let rowsQueued=0;
|
||||||
|
function patchFeed(row){
|
||||||
|
const i=S.feeds.findIndex(f=>f.id===row.id);
|
||||||
|
if(i<0) S.feeds.push(row); else S.feeds[i]=row;
|
||||||
|
if(!rowsQueued) rowsQueued=requestAnimationFrame(()=>{ rowsQueued=0; renderFeeds(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeeds(keepSel?: boolean){
|
||||||
|
S.feeds = await api('/api/feeds');
|
||||||
|
// 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(()=>{});
|
||||||
|
renderFeeds();
|
||||||
|
// Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
|
||||||
|
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
|
||||||
|
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
|
||||||
|
if(!keepSel && S.feeds.length){
|
||||||
|
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
|
||||||
|
selectFeed(known ? S.feed : ':all');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
|
||||||
|
// server's alphabetical order still holds within each half.
|
||||||
|
const unreadFirst=(a,b)=>Number(b.unread>0)-Number(a.unread>0);
|
||||||
|
// The original's source list opened with these, above the feeds. They are places, not feeds:
|
||||||
|
// an id starting with ':' can never be a feed's, since feed ids are slugs.
|
||||||
|
const VIEWS={
|
||||||
|
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
|
||||||
|
blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
|
||||||
|
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
|
||||||
|
blurb:'The ten feeds with the most subscribers here. The feeds inside an OPML count one by one, not the OPML.'},
|
||||||
|
':listening':{title:'Currently Listening',icon:ICON.audio,url:'/api/entries?filter=in_progress&limit=50',
|
||||||
|
blurb:'Episodes you started and have not finished, across every feed you subscribe to. Pick one up where you left off.'},
|
||||||
|
':all':{title:'All Subscriptions',icon:ICON.all},
|
||||||
|
};
|
||||||
|
/// Feeds being checked right now, from the event stream: their rows, and the row of a folder
|
||||||
|
/// holding one, carry a spinner.
|
||||||
|
const scanning=new Set<string>();
|
||||||
|
function setScanning(id: string, on: boolean){
|
||||||
|
if(on) scanning.add(id); else scanning.delete(id);
|
||||||
|
paintScanning();
|
||||||
|
}
|
||||||
|
function paintScanning(){
|
||||||
|
for(const row of $$('#feedlist .feed')){
|
||||||
|
const id=row.dataset.id;
|
||||||
|
const on=scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
row.classList.toggle('scanning',on);
|
||||||
|
if(on) row.title='Checking for new items…'; else row.removeAttribute('title');
|
||||||
|
}
|
||||||
|
// The refresh buttons turn while what they check is being checked (#78). On <body>, because
|
||||||
|
// a feed's page is redrawn as its items come in and would take a class on the button with it.
|
||||||
|
const busy=id=>scanning.has(id)||S.feeds.some(c=>c.group===id&&scanning.has(c.id));
|
||||||
|
document.body.classList.toggle('scan-this',busy(S.feed));
|
||||||
|
document.body.classList.toggle('scan-any',S.feeds.some(f=>scanning.has(f.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFeeds(){
|
||||||
|
const q=$('#feedFilter').value.trim().toLowerCase();
|
||||||
|
const list=$('#feedlist'); const top=list.scrollTop;
|
||||||
|
// Every row is replaced, so put the keyboard back on the row, or the triangle, it was on.
|
||||||
|
const a=document.activeElement, was=a&&a.closest&&a.closest<HTMLElement>('#feedlist [data-id]');
|
||||||
|
const back=was&&([was.dataset.id,a.classList.contains('chev')] as [string, boolean]);
|
||||||
|
const done=()=>{
|
||||||
|
list.scrollTop=top;
|
||||||
|
const row=back&&list.querySelector(`[data-id="${CSS.escape(back[0])}"]`);
|
||||||
|
if(row) (back[1]&&$('.chev',row)||row).focus();
|
||||||
|
};
|
||||||
|
list.innerHTML='';
|
||||||
|
const unreadAll=S.feeds.reduce((n,f)=>n+(f.unread||0),0);
|
||||||
|
const places=document.createElement('div');
|
||||||
|
places.className='places';
|
||||||
|
for(const [id,v] of Object.entries(VIEWS)){
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='place'+(S.feed===id?' sel':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=id;
|
||||||
|
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); };
|
||||||
|
places.appendChild(el);
|
||||||
|
}
|
||||||
|
list.appendChild(places);
|
||||||
|
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(); }
|
||||||
|
|
||||||
|
// Feeds from a subscribed OPML sit under it, so the group reads as one thing. Pinned feeds
|
||||||
|
// go first: a folder with its feeds under it, a feed from inside one lifted out of it.
|
||||||
|
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
|
||||||
|
const inside=f=>shown.filter(c=>c.group===f.id&&!c.pinned);
|
||||||
|
const tops=shown.filter(f=>f.pinned||!(f.group&&byId[f.group]));
|
||||||
|
const order=[];
|
||||||
|
for(const f of [...tops.filter(f=>f.pinned),...tops.filter(f=>!f.pinned)]){
|
||||||
|
order.push([f,0]);
|
||||||
|
// A subscription can hold dozens of feeds, so a folder starts closed. Searching
|
||||||
|
// opens them all, or matches inside a closed folder would be invisible.
|
||||||
|
if(expanded.has(f.id) || q)
|
||||||
|
for(const c of inside(f).sort(unreadFirst)) order.push([c,1]);
|
||||||
|
}
|
||||||
|
// The rule under the pinned block goes under its last row: a pinned folder's last feed when
|
||||||
|
// it is open.
|
||||||
|
const lastTop=order.findIndex(([f,d])=>!d&&!f.pinned);
|
||||||
|
const lastPin=(lastTop<0?order.filter(([f])=>f.pinned):order.slice(0,lastTop)).pop()?.[0];
|
||||||
|
for(const [f,depth] of order){
|
||||||
|
const kids=inside(f).length;
|
||||||
|
// A subscription holds no entries itself, so its counts are the sum of what is inside --
|
||||||
|
// taken from every feed it holds, not just the ones a filter left showing.
|
||||||
|
const mine=S.feeds.filter(c=>c.group===f.id);
|
||||||
|
const sum=k=>mine.reduce((n,c)=>n+(c[k]||0),0);
|
||||||
|
const [unread,eps,saved]=mine.length
|
||||||
|
? [sum('unread'),sum('entries'),sum('downloaded')]
|
||||||
|
: [f.unread,f.entries,f.downloaded];
|
||||||
|
// A group's own row has no error of its own worth mentioning if the OPML itself
|
||||||
|
// reads fine; it is failing when any feed inside it is. The feed's own page says what
|
||||||
|
// went wrong (failBannerHTML); the list only has to make it findable.
|
||||||
|
const bad=c=>c.failing?.reason||c.last_error;
|
||||||
|
const err=mine.length ? mine.map(bad).find(Boolean) : bad(f);
|
||||||
|
const nbad=mine.filter(bad).length;
|
||||||
|
const el=document.createElement('div');
|
||||||
|
// A feed that has failed for a day goes grey, as if switched off: a change in lightness
|
||||||
|
// shows in every theme, where the red mark alone was easy to miss on a dark one.
|
||||||
|
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'')+
|
||||||
|
(err?' err':'')+(f.failing?' failing':'')+
|
||||||
|
(f.pinned&&!depth?' pinned':'')+(f===lastPin&&lastTop>=0?' lastpin':'');
|
||||||
|
el.tabIndex=0; el.dataset.id=f.id;
|
||||||
|
const open = !!(kids && (expanded.has(f.id) || q));
|
||||||
|
el.innerHTML =
|
||||||
|
(kids?`<button class="chev${err?' bad':''}" aria-expanded="${open}" title="${err?`A feed inside has a problem: ${esc(err)}`:'Show or hide the feeds inside'}" aria-label="Show or hide the feeds inside">${ICON.caret}</button>`:'')+
|
||||||
|
// The mark sits on the artwork, the thing the eye scans the list by, and the line under
|
||||||
|
// the name says what is wrong in place of the counts.
|
||||||
|
`<span class="fart">${mine.length?folderArt(f,mine):artHTML(f.image,f.title||f.id)}`+
|
||||||
|
(err?`<span class="ferr" role="img" title="${esc(err)}" aria-label="Error: ${esc(err)}">${ICON.alert}</span>`:'')+
|
||||||
|
(f.pinned?`<span class="fpin" role="img" title="Pinned" aria-label="Pinned">${ICON.pinOn}</span>`:'')+`</span>`+
|
||||||
|
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
|
||||||
|
(nbad?`${plural(nbad,'feed')} not updating`
|
||||||
|
:err?esc(f.failing?.reason||'The last check failed')
|
||||||
|
:`${mine.length?plural(mine.length,'feed'):plural(eps,'item')} · ${saved} downloaded`)+
|
||||||
|
`</small></div>`+
|
||||||
|
(f.orphaned?'<span class="tag" title="No longer listed, kept because it has downloads">Gone</span>':'')+
|
||||||
|
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
|
||||||
|
el.onclick=()=>{ selectFeed(f.id); nav(false); };
|
||||||
|
if(kids) $('.chev',el).onclick=ev=>{ ev.stopPropagation(); toggleGroup(f.id); };
|
||||||
|
list.appendChild(el);
|
||||||
|
}
|
||||||
|
paintScanning();
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
function selectFeed(id){
|
||||||
|
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
|
||||||
|
try{ localStorage.setItem('ipx.feed',id); }catch{}
|
||||||
|
renderFeeds(); renderFeed(); loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failing feed's error, in plain words with something to do about it, once `failing` is
|
||||||
|
/// set (it has been failing for a day and is a kind worth naming -- see `explain_failure` in
|
||||||
|
/// src/feed.rs). Anything else still shows the raw error, as before.
|
||||||
|
function failBannerHTML(f){
|
||||||
|
if(f.failing) return `<div class="sub" style="color:var(--bad)">${esc(f.failing.reason)}
|
||||||
|
<button type="button" class="btn tiny" data-ffail="unsub">Unsubscribe</button>${
|
||||||
|
f.failing.new_url?` <button type="button" class="btn tiny" data-ffail="newurl">Use the new address</button>`:''}</div>`;
|
||||||
|
if(f.last_error) return `<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
function wireFailBanner(box,f){
|
||||||
|
const un=$('[data-ffail="unsub"]',box); if(un) un.onclick=()=>removeFeed(f);
|
||||||
|
const nu=$('[data-ffail="newurl"]',box); if(nu) nu.onclick=()=>settingsModal(f,f.failing.new_url);
|
||||||
|
}
|
||||||
|
|
||||||
170
web/src/gestures.ts
Normal file
170
web/src/gestures.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* ---------------- touch gestures ---------------- */
|
||||||
|
// On a touch screen: pull the item list down from its top to check the feed for new items, and
|
||||||
|
// swipe the item you are reading left for the next one, right for the one before, or back to
|
||||||
|
// the list from the first. Touch events only, so a mouse never sets these off. Listened for on
|
||||||
|
// the document because the panes are rebuilt every time a feed renders.
|
||||||
|
|
||||||
|
const PULL = 70; // px down, from the list's top, that counts as a pull
|
||||||
|
// A quarter of the reader's width across, and never less than 100px: at a flat 60px a thumb
|
||||||
|
// scrolling slightly on the diagonal jumped to the next item by accident (issue #49).
|
||||||
|
const swipeMin = () => Math.max(100, ($('#detail')?.clientWidth || 0) / 4);
|
||||||
|
const GAP = 16; // px of page between two items side by side, so they read as two sheets
|
||||||
|
const narrow = () => !!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
const still = () => !!window.matchMedia?.('(prefers-reduced-motion:reduce)')?.matches;
|
||||||
|
|
||||||
|
let touch: {x: number, y: number, t: number, pane: 'list' | 'detail', dx: number, dy: number} | null = null;
|
||||||
|
|
||||||
|
/// Something that scrolls sideways, or takes typing, keeps its own gestures: a wide code block
|
||||||
|
/// or table in a post, the player's seek bar, a text box.
|
||||||
|
function ownsSwipe(el: Element | null){
|
||||||
|
for(let n = el; n && n.id !== 'detail'; n = n.parentElement){
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT|AUDIO|VIDEO)$/.test(n.tagName)) return true;
|
||||||
|
if(n.scrollWidth > n.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(n).overflowX)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far the list has been pulled: a note at its top, growing with the pull and pushing the
|
||||||
|
/// items down, that says what letting go will do.
|
||||||
|
function pullShow(dy: number){
|
||||||
|
const list = $('#list'); if(!list) return;
|
||||||
|
const d = Math.round(Math.min(dy, PULL * 1.6) / 2);
|
||||||
|
let tip = $('#pulltip');
|
||||||
|
if(!d){ tip?.remove(); return; }
|
||||||
|
if(!tip){ tip = document.createElement('div'); tip.id = 'pulltip'; list.prepend(tip); }
|
||||||
|
tip.style.height = d + 'px';
|
||||||
|
tip.textContent = dy >= PULL ? 'Release to check for new items' : 'Pull to check for new items';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A check started by a pull, while its spinner is up. Letting go showed nothing before (issue
|
||||||
|
/// #68), the sidebar's spinner is hidden on a phone, and people pulled again and again.
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
|
function refreshFeed(){
|
||||||
|
const f = S.feeds.find(x => x.id === S.feed);
|
||||||
|
if(!f && S.feed !== ':all') return;
|
||||||
|
if(refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
// Outside #list, which a feed's render rebuilds and would take the spinner with it.
|
||||||
|
const spin = document.createElement('div');
|
||||||
|
spin.id = 'pullspin'; spin.setAttribute('role', 'status'); spin.textContent = 'Checking for new items';
|
||||||
|
document.body.append(spin);
|
||||||
|
// New items arrive by the event stream when the scan finishes, as they do for a button press.
|
||||||
|
const asked = api('/api/fetch', {method: 'POST', body: JSON.stringify(f ? {feed: f.id, force: true} : {force: true})})
|
||||||
|
.catch(e => toast(e.message, true));
|
||||||
|
loadEntries();
|
||||||
|
// Up for two seconds at least, so it is seen even when the server answers at once.
|
||||||
|
Promise.all([asked, new Promise(r => setTimeout(r, 2000))]).then(() => { spin.remove(); refreshing = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchstart', ev => {
|
||||||
|
touch = null;
|
||||||
|
if(ev.touches.length !== 1 || $('#modal')?.classList.contains('on')) return;
|
||||||
|
const t = ev.touches[0], target = ev.target as Element;
|
||||||
|
const detail = target.closest?.('#detail'), list = target.closest?.('#list');
|
||||||
|
// Reading means an item is open; the reader is its own screen on a phone, a pane otherwise.
|
||||||
|
if(detail && S.sel && !ownsSwipe(target)){
|
||||||
|
touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'detail', dx: 0, dy: 0};
|
||||||
|
// Safari takes a swipe from the screen's left edge as Back, which leaves the page halfway
|
||||||
|
// through going back to the list. Kept narrower than the reader's padding, so a tap on the
|
||||||
|
// back button is never swallowed with it.
|
||||||
|
if(t.clientX < 14 && narrow() && ev.cancelable) ev.preventDefault();
|
||||||
|
}
|
||||||
|
else if(list && list.scrollTop <= 0 && !VIEWS[S.feed]?.url) touch = {x: t.clientX, y: t.clientY, t: Date.now(), pane: 'list', dx: 0, dy: 0};
|
||||||
|
}, {passive: false});
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', ev => {
|
||||||
|
if(!touch) return;
|
||||||
|
const t = ev.touches[0];
|
||||||
|
touch.dx = t.clientX - touch.x; touch.dy = t.clientY - touch.y;
|
||||||
|
if(touch.pane === 'list'){
|
||||||
|
// Only a pull that starts at the top and goes down; anything else is an ordinary scroll.
|
||||||
|
if(touch.dy < 0 || $('#list').scrollTop > 0){ pullShow(0); touch = null; return; }
|
||||||
|
pullShow(touch.dy);
|
||||||
|
if(ev.cancelable) ev.preventDefault(); // or the list rubber-bands under the finger as well
|
||||||
|
}else if(Math.abs(touch.dy) > 10 && Math.abs(touch.dy) > Math.abs(touch.dx)){
|
||||||
|
slide(0); touch = null; // scrolling the text, not swiping; the first few px
|
||||||
|
// decide nothing, being mostly jitter
|
||||||
|
}else if(Math.abs(touch.dx) > 10){
|
||||||
|
// The reader follows the finger, so it is plain before letting go whether this will move on;
|
||||||
|
// with nothing that way it only gives a little.
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
|
const end = touch.dx < 0 && i >= S.entries.length - 1;
|
||||||
|
slide(end ? touch.dx / 3 : touch.dx, 0);
|
||||||
|
}
|
||||||
|
}, {passive: false});
|
||||||
|
|
||||||
|
/// On a phone, what lies beside the reader while it is dragged by dx: the item it will move on
|
||||||
|
/// to, a note that there are no more, or, going back to the list, a dimmer over the list. The
|
||||||
|
/// reader sits over the list there, and moving it alone showed the list through the gap it left
|
||||||
|
/// (issue #52). Kept outside #content, which every feed render rebuilds, and inert, since its
|
||||||
|
/// copy of the reader's markup repeats the reader's ids.
|
||||||
|
function beside(dx: number){
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel), e = S.entries[i + (dx < 0 ? 1 : -1)];
|
||||||
|
const kind = dx < 0 ? (e ? 'next' : 'end') : (e ? 'prev' : 'dim');
|
||||||
|
let p = $('#dpeek');
|
||||||
|
if(!p){
|
||||||
|
p = document.createElement('div'); p.id = 'dpeek'; p.inert = true; p.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.append(p);
|
||||||
|
}
|
||||||
|
if(p.dataset.key !== kind + (e?.guid || '')){
|
||||||
|
p.dataset.key = kind + (e?.guid || ''); p.className = kind;
|
||||||
|
p.innerHTML = e ? `<div>${detailHtml(e)}</div>` : kind === 'end' ? '<p>No more items</p>' : '';
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the reader across by dx over ms, or at once (when following a finger), and what lies
|
||||||
|
/// beside it with it.
|
||||||
|
function slide(dx: number, ms = 160){
|
||||||
|
const d = $('#detail'); if(!d) return;
|
||||||
|
dx = Math.round(dx); // whole pixels, or the seam between the reader and its neighbour blurs
|
||||||
|
const w = d.clientWidth || 1, tr = ms ? `transform ${ms}ms ease-out, opacity ${ms}ms ease-out` : 'none';
|
||||||
|
d.style.transition = tr;
|
||||||
|
d.style.transform = dx ? `translateX(${dx}px)` : '';
|
||||||
|
// A wider screen has the reader as a pane beside the list, with nothing under it to show.
|
||||||
|
if(!narrow()){ d.style.opacity = dx ? String(Math.max(.4, 1 - Math.abs(dx) / w)) : ''; return; }
|
||||||
|
const p = dx ? beside(dx) : $('#dpeek');
|
||||||
|
d.classList.toggle('popping', !!dx && p?.className === 'dim');
|
||||||
|
if(!p) return;
|
||||||
|
p.style.transition = tr;
|
||||||
|
if(p.className === 'dim') p.style.opacity = String(.35 * (1 - Math.abs(dx) / w));
|
||||||
|
else p.style.transform = `translateX(${dx + (p.className === 'prev' ? -w - GAP : w)}px)`;
|
||||||
|
// Gone once the reader is back, unless another drag has already begun.
|
||||||
|
if(!dx){ if(ms) setTimeout(() => { if(!touch) $('#dpeek')?.remove(); }, ms); else p.remove(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reader carries on the way the finger went, from wherever it was let go, and the item
|
||||||
|
/// beside it becomes the reader in its place. Timers, not transitionend, because with reduced
|
||||||
|
/// motion the stylesheet turns transitions off and transitionend never comes.
|
||||||
|
function slideOn(dx: number, go: () => void){
|
||||||
|
const d = $('#detail'), w = d?.clientWidth || 0, dir = Math.sign(dx);
|
||||||
|
if(!narrow()){
|
||||||
|
// A pane beside the list: off one side, and the next item in from the other.
|
||||||
|
slide(dir * w);
|
||||||
|
setTimeout(() => { go(); slide(-dir * w, 0); requestAnimationFrame(() => requestAnimationFrame(() => slide(0))); }, 160);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const to = dir * (w + (beside(dx).className === 'dim' ? 0 : GAP));
|
||||||
|
const ms = still() ? 0 : Math.round(Math.max(120, 250 * (1 - Math.abs(dx) / (w || 1))));
|
||||||
|
slide(to, ms);
|
||||||
|
setTimeout(() => { go(); slide(0, 0); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchend', () => {
|
||||||
|
const g = touch; touch = null;
|
||||||
|
if(!g) return;
|
||||||
|
if(g.pane === 'list'){
|
||||||
|
pullShow(0);
|
||||||
|
if(g.dy >= PULL) refreshFeed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Across, mostly sideways, and not a slow drag while selecting text.
|
||||||
|
const i = S.entries.findIndex(x => x.guid === S.sel);
|
||||||
|
if(Math.abs(g.dx) < swipeMin() || Math.abs(g.dx) < 2 * Math.abs(g.dy) || Date.now() - g.t > 800
|
||||||
|
|| (g.dx < 0 && i >= S.entries.length - 1)) return slide(0);
|
||||||
|
if(g.dx < 0) return slideOn(g.dx, () => stepEntry(1));
|
||||||
|
if(i > 0) return slideOn(g.dx, () => stepEntry(-1));
|
||||||
|
slideOn(g.dx, () => showDetail(null));
|
||||||
|
});
|
||||||
|
document.addEventListener('touchcancel', () => { if(touch?.pane === 'list') pullShow(0); else slide(0); touch = null; });
|
||||||
398
web/src/items.ts
Normal file
398
web/src/items.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
/* ---------------- items ---------------- */
|
||||||
|
let loadSeq=0;
|
||||||
|
async function loadEntries(append?: boolean){
|
||||||
|
// Directory and Popular list feeds, not items.
|
||||||
|
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});
|
||||||
|
if(S.q) p.set('q',S.q);
|
||||||
|
const asked=performance.now(), seq=++loadSeq;
|
||||||
|
const r=await api(S.feed===':all' ? `/api/entries?${p}`
|
||||||
|
: `/api/feeds/${encodeURIComponent(S.feed)}/entries?${p}`);
|
||||||
|
// 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;
|
||||||
|
S.total=r.total;
|
||||||
|
for(const e of r.entries){
|
||||||
|
const w=readWrites.get(readKey(e));
|
||||||
|
if(w && !(w.done<asked)) e.read=w.read;
|
||||||
|
}
|
||||||
|
let entries = append ? S.entries.concat(r.entries) : r.entries;
|
||||||
|
// A background scan finishing refreshes the list from the server, which -- on the Unread
|
||||||
|
// tab -- would drop the item you have open the moment reading it took it off the filter.
|
||||||
|
// Keep it until you pick a different one; the next refresh after that no longer protects it.
|
||||||
|
// The items turned past on the way to it are kept too, so a swipe back still finds them.
|
||||||
|
const kept=e=>e.guid===S.sel||turned.has(e.guid);
|
||||||
|
if(S.filter==='unread') entries=entries.filter(e=>!e.read||kept(e));
|
||||||
|
if(!append && S.sel){
|
||||||
|
const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>n.guid===e.guid));
|
||||||
|
entries=[...gone,...entries];
|
||||||
|
}
|
||||||
|
S.entries = entries;
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
$('#count').textContent=
|
||||||
|
`${f?(f.title||f.id):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){
|
||||||
|
box.innerHTML=`<p class="empty">${S.q?'Nothing matches that search.':'Nothing here yet — try Scan now.'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for(const e of S.entries) box.appendChild(epEl(e));
|
||||||
|
syncPlayButtons();
|
||||||
|
if(pane) pane.scrollTop=top;
|
||||||
|
if(S.entries.length < S.total){
|
||||||
|
const b=document.createElement('button');
|
||||||
|
b.className='btn'; b.id='more'; b.textContent=`Load more (${S.entries.length} of ${S.total})`;
|
||||||
|
b.onclick=()=>{S.offset+=LIMIT;loadEntries(true)};
|
||||||
|
box.appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function epEl(e){
|
||||||
|
// An item may carry several files. The row summarises the one you would act on --
|
||||||
|
// the playable one, else anything already downloaded, else the first -- and says how
|
||||||
|
// many others there are; the pane below lists them all.
|
||||||
|
const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0];
|
||||||
|
const has=!!(enc&&enc.path);
|
||||||
|
const playable=isPlayable(enc);
|
||||||
|
const others=e.enclosures.length-1;
|
||||||
|
const el=document.createElement('div');
|
||||||
|
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'');
|
||||||
|
el.dataset.guid=e.guid;
|
||||||
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
|
const left = e.position>10 && e.duration ? `${clock(e.duration-e.position)} left` : (e.duration?clock(e.duration):'');
|
||||||
|
const name=entryName(e);
|
||||||
|
el.innerHTML=`
|
||||||
|
<button class="st" data-a="read" title="Mark ${e.read?'unread':'read'}">${
|
||||||
|
player.guid===e.guid?EQ:(e.read?'':'●')}</button>
|
||||||
|
<button class="fl${e.flagged?' on':''}" data-a="flag" aria-pressed="${!!e.flagged}" title="${
|
||||||
|
e.flagged?'Unpin':'Pin, so it is never deleted'}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
|
<div class="body">
|
||||||
|
<span class="t${name.derived?' notitle':''}">${esc(name.text)}</span>
|
||||||
|
<div class="line">${[
|
||||||
|
num&&`<span>${num}</span>`,
|
||||||
|
left&&`<span>${left}</span>`,
|
||||||
|
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
|
||||||
|
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
|
||||||
|
].filter(Boolean).join('<span class="dot"></span>')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="fd">${esc(feedName(e.feed_id))}</span>
|
||||||
|
<div class="file">
|
||||||
|
${enc?kindIcon(enc):''}
|
||||||
|
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||||
|
</div>
|
||||||
|
<span class="size">${enc?mb(enc.length):''}</span>
|
||||||
|
<span class="date">${dateOf(e.published)}</span>
|
||||||
|
<div class="rowacts">
|
||||||
|
${playable?`<button class="iconbtn" data-a="play" title="Play" aria-label="Play">${ICON.play}</button>`:
|
||||||
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
||||||
|
enc.state==='skipped'?kindOf(enc):'file'}">${ICON.download}</button>`:'')}
|
||||||
|
${has?`<button class="iconbtn" data-a="del" title="Delete file" aria-label="Delete file">${ICON.trash}</button>`:''}
|
||||||
|
</div>`;
|
||||||
|
el.onclick=()=>selectEntry(e);
|
||||||
|
$$('button[data-a]',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||||
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
|
/// is just a broken player.
|
||||||
|
function isPlayable(x){
|
||||||
|
if(!x || !x.path) return false;
|
||||||
|
const m=(x.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
|
if(m) return false;
|
||||||
|
// No declared type: fall back to the file's extension.
|
||||||
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
||||||
|
.test((x.path||x.url||'').split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an enclosure is, for a row that is not media: "image", "pdf", "document".
|
||||||
|
function kindOf(enc){
|
||||||
|
const m=(enc.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('image/')) return 'image';
|
||||||
|
if(m.startsWith('video/')) return 'video';
|
||||||
|
if(m.startsWith('audio/')) return 'audio';
|
||||||
|
if(m.includes('pdf')) return 'pdf';
|
||||||
|
if(m.includes('torrent')) return 'torrent';
|
||||||
|
const ext=(enc.url||'').split('?')[0].split('.').pop();
|
||||||
|
return (ext && ext.length<=5) ? ext.toLowerCase() : 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a file is, as one icon coloured by whether it is here: green once downloaded, red when
|
||||||
|
/// the download failed, plain otherwise, so a file waiting and one deleted read alike. One icon
|
||||||
|
/// either way keeps the column lined up; the words are in its tooltip.
|
||||||
|
function kindIcon(enc){
|
||||||
|
const k=kindOf(enc);
|
||||||
|
const i={audio:ICON.audio,video:ICON.video,image:ICON.image,pdf:ICON.doc,torrent:ICON.torrent}[k]||ICON.file;
|
||||||
|
const [cls,label]=enc.path ? [' here',`${k}, downloaded`]
|
||||||
|
: enc.state==='error' ? [' bad',`${k}, download failed${enc.last_error?': '+enc.last_error:''}`]
|
||||||
|
: ['',k];
|
||||||
|
return `<span class="kind${cls}" title="${esc(label)}" aria-label="${esc(label)}">${i}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedArt(id=S.feed){ const f=S.feeds.find(x=>x.id===id); return f&&f.image; }
|
||||||
|
const feedName=id=>{ const f=S.feeds.find(x=>x.id===id); return f?(f.title||f.id):id; };
|
||||||
|
|
||||||
|
/// What an item is called. Its title, or for one published without (RSS 2.0 makes it optional,
|
||||||
|
/// and Scripting News titles almost none of its posts) the opening of its text, then its file's
|
||||||
|
/// name, then its feed and date: "(untitled)" fifty times down a list said nothing about any of
|
||||||
|
/// them. `derived` marks a name that is not a title, which the list sets as text, not heading.
|
||||||
|
function entryName(e): {text: string, derived: boolean}{
|
||||||
|
if(e.title&&e.title.trim()) return {text:e.title, derived:false};
|
||||||
|
// An inert document: nothing in it loads or runs, where a detached element fetches its images.
|
||||||
|
const words=e.description
|
||||||
|
? (new DOMParser().parseFromString(e.description,'text/html').body.textContent||'').replace(/\s+/g,' ').trim()
|
||||||
|
: '';
|
||||||
|
if(words){
|
||||||
|
const cut=words.length>120 ? words.slice(0,120).replace(/\s+\S*$/,'')+'…' : words;
|
||||||
|
return {text:cut, derived:true};
|
||||||
|
}
|
||||||
|
const file=((e.enclosures||[])[0]?.url||'').split(/[?#]/)[0].split('/').pop().replace(/\.[a-z0-9]{1,5}$/i,'');
|
||||||
|
if(file){ try{ return {text:decodeURIComponent(file), derived:true}; }catch{ return {text:file, derived:true}; } }
|
||||||
|
return {text:[feedName(e.feed_id), dateOf(e.published)].filter(Boolean).join(', '), derived:true};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||||
|
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||||
|
function swapRow(e){
|
||||||
|
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||||
|
if(row){ row.replaceWith(epEl(e)); syncPlayButtons(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and unread as this page last set them, and when the server had it. A list asked for
|
||||||
|
/// before then answers with the old state: it put the dot back on an item just read, until
|
||||||
|
/// the next refresh took it off again (loadEntries).
|
||||||
|
const readWrites=new Map();
|
||||||
|
const readKey=e=>e.feed_id+'\n'+e.guid;
|
||||||
|
function setRead(e,read){
|
||||||
|
e.read=read;
|
||||||
|
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); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||||
|
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||||
|
function markRead(e){
|
||||||
|
if(e.read) return;
|
||||||
|
setRead(e,true).catch(err=>{ e.read=false; readWrites.delete(readKey(e)); toast(err.message,true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On the Unread tab, the items read while turning from one to the next (a swipe, j and k), kept
|
||||||
|
/// in the list until the reader closes or another item is picked from the list. Dropped as each
|
||||||
|
/// was left, a swipe back had nothing to go back to: the item just read was already gone.
|
||||||
|
const turned=new Set<string>();
|
||||||
|
function dropTurned(keep){
|
||||||
|
const gone=S.entries.filter(x=>turned.has(x.guid)&&x.read&&x.guid!==keep);
|
||||||
|
turned.clear();
|
||||||
|
if(!gone.length) return;
|
||||||
|
S.entries=S.entries.filter(x=>!gone.includes(x)); S.total-=gone.length;
|
||||||
|
for(const x of gone) $(`#eps .ep[data-guid="${CSS.escape(x.guid)}"]`)?.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectEntry(e,turning=false){
|
||||||
|
// On the Unread tab the item you were reading goes as you move on, not whenever a refresh
|
||||||
|
// next happens to come along, which left a few read ones in the list for a while.
|
||||||
|
if(S.filter==='unread' && S.sel && S.sel!==e.guid){
|
||||||
|
turned.add(S.sel);
|
||||||
|
if(!turning) dropTurned(e.guid);
|
||||||
|
}
|
||||||
|
S.sel=e.guid;
|
||||||
|
markRead(e);
|
||||||
|
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
||||||
|
swapRow(e);
|
||||||
|
showDetail(e);
|
||||||
|
const d=$('#detail'); if(d) d.scrollTop=0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drag the divider between the item list and the item text.
|
||||||
|
function dragSplit(pane){
|
||||||
|
const grab=$('#grab',pane);
|
||||||
|
if(!grab) return;
|
||||||
|
const move=ev=>{
|
||||||
|
const box=pane.getBoundingClientRect();
|
||||||
|
const pct=Math.min(80,Math.max(12,((ev.clientY-box.top)/box.height)*100));
|
||||||
|
pane.style.setProperty('--listh',pct.toFixed(1)+'%');
|
||||||
|
};
|
||||||
|
const stop=()=>{
|
||||||
|
document.removeEventListener('mousemove',move);
|
||||||
|
document.removeEventListener('mouseup',stop);
|
||||||
|
document.body.style.userSelect='';
|
||||||
|
try{ localStorage.setItem('ipx.listh', pane.style.getPropertyValue('--listh')); }catch{}
|
||||||
|
};
|
||||||
|
grab.onmousedown=ev=>{
|
||||||
|
ev.preventDefault();
|
||||||
|
document.body.style.userSelect='none';
|
||||||
|
document.addEventListener('mousemove',move);
|
||||||
|
document.addEventListener('mouseup',stop);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which item the toolbar's play, read and keep buttons act on.
|
||||||
|
// ponytail: matched by guid alone; two feeds sharing a guid in All Subscriptions would pick
|
||||||
|
// the first. Key rows by feed as well if that ever happens.
|
||||||
|
const cur=()=>S.entries.find(x=>x.guid===S.sel);
|
||||||
|
function syncTools(e){
|
||||||
|
$('#tbPlay').disabled=!(e&&e.enclosures.some(isPlayable));
|
||||||
|
$('#tbRead').disabled=$('#tbFlag').disabled=!e;
|
||||||
|
// The same icons as the item's own buttons beside its title, so the two never disagree. Each
|
||||||
|
// shows what is, as the pin always did: read showed what a click would do, so the two side by
|
||||||
|
// side said opposite things (#74). The shape carries it, not the colour.
|
||||||
|
$('#tbRead').innerHTML=e&&!e.read?ICON.unread:ICON.check;
|
||||||
|
$('#tbRead').setAttribute('aria-pressed',String(!!(e&&e.read)));
|
||||||
|
$('#tbFlag').setAttribute('aria-pressed',String(!!(e&&e.flagged)));
|
||||||
|
$('#tbFlag').innerHTML=e&&e.flagged?ICON.pinOn:ICON.pin;
|
||||||
|
if(e){
|
||||||
|
$('#tbRead').title=`Mark ${e.read?'unread':'read'}`;
|
||||||
|
$('#tbFlag').title=e.flagged?'Unpin':'Pin, so it is never deleted';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The item's text in the pane below the list, and its files in the pane beside it.
|
||||||
|
function showDetail(e){
|
||||||
|
const box=$('#detail'), files=$('#files'); if(!box) return;
|
||||||
|
// A pane that can only say "No files" gives its width to the list instead.
|
||||||
|
if(files) files.hidden=!(e&&e.enclosures.length);
|
||||||
|
document.body.classList.toggle('reading',!!e);
|
||||||
|
syncTools(e);
|
||||||
|
if(!e){
|
||||||
|
dropTurned(S.sel);
|
||||||
|
box.innerHTML='<p class="empty">Pick an item to read it.</p>';
|
||||||
|
if(files) files.innerHTML='<p class="empty">No files</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Nothing when there are no files: the pane that would say so is hidden above, and on a phone,
|
||||||
|
// where the files sit over the text, a box saying "No files" only pushed the text down.
|
||||||
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
box.innerHTML=detailHtml(e);
|
||||||
|
if(files) files.innerHTML=narrow?'':`<div class="fhd">Files</div>${encs}`;
|
||||||
|
$('#dback').onclick=()=>showDetail(null);
|
||||||
|
for(const root of [box,files]) if(root) $$('button[data-a]',root).forEach(b=>
|
||||||
|
b.onclick=()=>epAction(b.dataset.a,e,null,b.dataset.enc?Number(b.dataset.enc):null));
|
||||||
|
syncPlayButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the reader shows for an item. Its own function because a phone also draws the next or
|
||||||
|
/// previous item beside the reader while it is swiped (gestures.ts).
|
||||||
|
function detailHtml(e){
|
||||||
|
const encs=e.enclosures.map(encBox).join('');
|
||||||
|
// A phone has no room for the files pane, so the files go above the text there instead.
|
||||||
|
// Below it, a long set of show notes pushed play and delete screens down, and on an iPhone
|
||||||
|
// it looked as if a downloaded file could not be deleted at all (issue #21).
|
||||||
|
const narrow=!!window.matchMedia?.('(max-width:820px)')?.matches;
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
|
||||||
|
// description was sanitized server-side with ammonia before it ever reached here
|
||||||
|
return `
|
||||||
|
<button class="btn ico" id="dback" title="Back to the items" aria-label="Back to the items">${ICON.left}</button>
|
||||||
|
${/* A post without a title starts with its text: its own first words as a heading above
|
||||||
|
themselves would read as a mistake. */ e.title&&e.title.trim() ? `<h3 class="dt">${esc(e.title)}</h3>` : ''}
|
||||||
|
<div class="dmeta">
|
||||||
|
${/* Joined, so a missing date or number leaves no stray dot behind. */
|
||||||
|
[f&&esc(f.title||f.id), num, dateOf(e.published), e.duration&&clock(e.duration)]
|
||||||
|
.filter(Boolean).map(s=>`<span>${s}</span>`).join('<span class="dot"></span>')}
|
||||||
|
<button class="btn ico" data-a="read" title="Mark ${e.read?'unread':'read'}"
|
||||||
|
aria-label="Mark ${e.read?'unread':'read'}" aria-pressed="${!!e.read}">${e.read?ICON.check:ICON.unread}</button>
|
||||||
|
<button class="btn ico" data-a="flag" title="${e.flagged?'Pinned: never deleted. Unpin':'Pin, so it is never deleted'}"
|
||||||
|
aria-label="${e.flagged?'Unpin':'Pin'}" aria-pressed="${!!e.flagged}">${e.flagged?ICON.pinOn:ICON.pin}</button>
|
||||||
|
${e.link?`<a class="btn ico" href="${esc(e.link)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
title="Open the original" aria-label="Open the original">${ICON.open}</a>
|
||||||
|
<button class="btn ico" data-a="share" title="Share" aria-label="Share">${ICON.share}</button>`:''}
|
||||||
|
</div>
|
||||||
|
${narrow?encs:''}
|
||||||
|
<div class="dbody">${(e.description&&e.description.trim())||'<em>No show notes.</em>'}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One enclosure: a play button when the file is here, otherwise what it is and a way to get it.
|
||||||
|
function encBox(x){
|
||||||
|
const size=x.length?mb(x.length):'';
|
||||||
|
// One file serves everyone reading the feed, so deleting is not a private act.
|
||||||
|
const f=S.feeds.find(y=>y.id===x.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
// Icons, with the words in the tooltip and for screen readers.
|
||||||
|
const delLabel=shared
|
||||||
|
? `Delete for everyone (shared with ${f.subscribers-1} other ${f.subscribers===2?'person':'people'} reading this feed)`
|
||||||
|
: 'Delete file';
|
||||||
|
const delBtn=`<button class="btn ico danger" data-a="del" data-enc="${x.id}" title="${delLabel}" aria-label="${delLabel}">${ICON.trash}</button>`;
|
||||||
|
const saveBtn=`<a class="btn ico" href="/media/${x.id}" download title="Save to this computer" aria-label="Save to this computer">${ICON.save}</a>`;
|
||||||
|
// The publisher's address, not this server's copy, which only someone signed in here can open.
|
||||||
|
const shareBtn=`<button class="btn ico" data-a="share" data-enc="${x.id}" title="Share the file" aria-label="Share the file">${ICON.share}</button>`;
|
||||||
|
if(x.path && !isPlayable(x)){
|
||||||
|
// On disk, but not audio or video: view it, keep it, or remove it -- no player.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
<a class="btn ico" href="/media/${x.id}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>
|
||||||
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
if(x.path){
|
||||||
|
// One player, the bar at the bottom. This pane had an <audio> of its own, and playing it
|
||||||
|
// started the bar as well, so the same file played twice at once.
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||||
|
${saveBtn}
|
||||||
|
${shareBtn}
|
||||||
|
${delBtn}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
// Nothing on disk. For an image or a PDF you usually just want to look at it, so link
|
||||||
|
// straight to the publisher's copy in a new tab -- no download, and nothing proxied
|
||||||
|
// through here, which would make ipx a fetch-anything relay.
|
||||||
|
const viewable = !isPlayable(x) && x.state !== 'pending' && x.state !== 'held';
|
||||||
|
return `<div class="encbox">
|
||||||
|
${kindIcon(x)}
|
||||||
|
<span class="meta" style="flex:1">${size}</span>
|
||||||
|
${x.state==='error'&&x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
|
||||||
|
${viewable?`<a class="btn ico" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer" title="View in a new tab" aria-label="View in a new tab">${ICON.open}</a>`:''}
|
||||||
|
${shareBtn}
|
||||||
|
<button class="btn ico" data-a="get" data-enc="${x.id}" title="Download to the server" aria-label="Download to the server">${ICON.download}</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function epAction(a: string, e, el, encId?: number){
|
||||||
|
// Swap the row in place and refresh the text below when it is the one being read.
|
||||||
|
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
||||||
|
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||||
|
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||||
|
try{
|
||||||
|
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||||
|
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link, el);
|
||||||
|
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||||
|
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||||
|
if(a==='get'){
|
||||||
|
if(!enc) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
|
||||||
|
toast('Queued: '+entryName(e).text);
|
||||||
|
}
|
||||||
|
if(a==='del'){
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
const shared=f&&f.subscribers>1;
|
||||||
|
if(!confirm(shared
|
||||||
|
? `Delete this file?\n\nThere is one copy, shared with ${f.subscribers-1} other `
|
||||||
|
+`${f.subscribers===2?'person':'people'} reading this feed. The item stays listed `
|
||||||
|
+`and will not be downloaded again automatically.`
|
||||||
|
: 'Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
|
||||||
|
try{
|
||||||
|
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
|
||||||
|
}catch(err){
|
||||||
|
// 409: somebody else has it starred or unplayed. Their reason, their words.
|
||||||
|
if(!/one copy of this file/.test(err.message)) throw err;
|
||||||
|
if(!confirm(err.message+'\n\nDelete it anyway?')) return;
|
||||||
|
await api(`/api/enclosures/${enc.id}?force=true`,{method:'DELETE'});
|
||||||
|
}
|
||||||
|
toast('Deleted'); loadEntries(); loadFeeds(true);
|
||||||
|
}
|
||||||
|
}catch(err){ toast(err.message,true); }
|
||||||
|
}
|
||||||
|
|
||||||
15
web/src/login.ts
Normal file
15
web/src/login.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
document.getElementById('f').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const msg = document.getElementById('msg');
|
||||||
|
msg.textContent = '';
|
||||||
|
const r = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: (document.getElementById('name') as HTMLInputElement).value,
|
||||||
|
password: (document.getElementById('pw') as HTMLInputElement).value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (r.ok) location.href = '/';
|
||||||
|
else msg.textContent = await r.text() || 'Sign in failed';
|
||||||
|
};
|
||||||
177
web/src/native.ts
Normal file
177
web/src/native.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/* ---------------- native shell bridge ---------------- */
|
||||||
|
// Inside the iOS or Android app this page is a WebView, and the audio it plays has to come from
|
||||||
|
// the host's own player instead of this element: CarPlay and Android Auto are template surfaces
|
||||||
|
// that cannot render a WebView at all, and the only audio they will control is the host's.
|
||||||
|
//
|
||||||
|
// So the host's player takes over, and the element keeps its face. Everything in player.ts speaks
|
||||||
|
// to `audio` through a small surface -- play, pause, src, currentTime, duration, paused,
|
||||||
|
// readyState, volume, playbackRate, and the events it fires -- so replacing that surface on the
|
||||||
|
// element leaves the player bar, the row buttons, the EQ bars and the keyboard shortcuts working
|
||||||
|
// exactly as they do in a browser, with nothing in player.ts changed.
|
||||||
|
//
|
||||||
|
// In a browser none of this installs and the page is untouched.
|
||||||
|
|
||||||
|
/// How the host is reached. iOS puts a handler on `webkit.messageHandlers`; Android's
|
||||||
|
/// `addJavascriptInterface` gives a plain object with a `postMessage(string)`. Null in a browser,
|
||||||
|
/// which is what switches the whole file off.
|
||||||
|
const ipxHost: ((m: any) => void) | null = (() => {
|
||||||
|
const w = window as any;
|
||||||
|
const ios = w.webkit?.messageHandlers?.ipx;
|
||||||
|
if (ios) return (m: any) => ios.postMessage(m);
|
||||||
|
const android = w.ipxAndroid;
|
||||||
|
if (android?.postMessage) return (m: any) => android.postMessage(JSON.stringify(m));
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (ipxHost) installNativePlayback();
|
||||||
|
|
||||||
|
function installNativePlayback(){
|
||||||
|
const post = ipxHost!;
|
||||||
|
const M = HTMLMediaElement.prototype;
|
||||||
|
const own = (k: string) => Object.getOwnPropertyDescriptor(M, k)!;
|
||||||
|
const realPlay = M.play, realPause = M.pause, realLoad = M.load;
|
||||||
|
// Bound before anything is redefined, because the src setter below has to drop the element's
|
||||||
|
// file without that counting as closing the player: removeAttribute is overridden further down
|
||||||
|
// to mean exactly that, and going through it there switched the shim straight back off.
|
||||||
|
const realRemoveAttribute = audio.removeAttribute.bind(audio);
|
||||||
|
const src = own('src'), currentTime = own('currentTime'), duration = own('duration');
|
||||||
|
const paused = own('paused'), readyState = own('readyState');
|
||||||
|
const volume = own('volume'), playbackRate = own('playbackRate');
|
||||||
|
|
||||||
|
// What the host last told us. `on` is the whole switch: false means this element is playing for
|
||||||
|
// itself, which is still the case for video -- the host plays audio, and a native video layer
|
||||||
|
// under a WebView buys nothing when CarPlay is audio-only either way.
|
||||||
|
const N = {on:false, cur:0, dur:NaN, paused:true, ready:0};
|
||||||
|
|
||||||
|
const fire = (name: string) => audio.dispatchEvent(new Event(name));
|
||||||
|
|
||||||
|
const define = (k: string, d: PropertyDescriptor) =>
|
||||||
|
Object.defineProperty(audio, k, {configurable:true, ...d});
|
||||||
|
|
||||||
|
define('play', {value(){
|
||||||
|
if(!N.on) return realPlay.call(audio);
|
||||||
|
post({t:'play'});
|
||||||
|
// player.ts does audio.play().catch(...) to toast a failure. A failure here arrives as a
|
||||||
|
// message from the host instead, so there is nothing to reject.
|
||||||
|
return Promise.resolve();
|
||||||
|
}});
|
||||||
|
define('pause', {value(){
|
||||||
|
if(!N.on) return realPause.call(audio);
|
||||||
|
post({t:'pause'});
|
||||||
|
}});
|
||||||
|
|
||||||
|
define('src', {
|
||||||
|
get(){ return N.on ? '' : src.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
// has-video is set immediately before the src in play(), so it is already right here.
|
||||||
|
if(document.body.classList.contains('has-video')){
|
||||||
|
stop();
|
||||||
|
src.set!.call(audio, v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
N.on = true; N.cur = 0; N.dur = NaN; N.paused = true; N.ready = 0;
|
||||||
|
// Let go of whatever the element was holding, or a video just closed keeps its buffer and
|
||||||
|
// its audio track. removeAttribute alone does not: it takes a load() to act on it.
|
||||||
|
realPause.call(audio);
|
||||||
|
realRemoveAttribute('src');
|
||||||
|
realLoad.call(audio);
|
||||||
|
const e = player.entry, f = player.feed;
|
||||||
|
post({t:'load', url:v, enc:player.enc, feedId:f, guid:player.guid,
|
||||||
|
title:e ? entryName(e).text : '', feedTitle:feedName(f),
|
||||||
|
artwork:(e && e.image) || feedArt(f) || null,
|
||||||
|
// Where the host starts is not this: the seek to where you left off is player.ts's, on
|
||||||
|
// loadedmetadata, so one piece of code decides it. This is for the host's now-playing
|
||||||
|
// display before the file has loaded.
|
||||||
|
position:(e && e.position) || 0, duration:(e && e.duration) || null,
|
||||||
|
rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('currentTime', {
|
||||||
|
get(){ return N.on ? N.cur : currentTime.get!.call(audio); },
|
||||||
|
set(v){
|
||||||
|
if(!N.on){ currentTime.set!.call(audio, v); return; }
|
||||||
|
N.cur = v;
|
||||||
|
post({t:'seek', to:v});
|
||||||
|
// The clock and the scrubber move now rather than at the host's next tick, which is what
|
||||||
|
// makes the 15 and 30 second keys feel like they did.
|
||||||
|
fire('timeupdate');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
define('duration', {get(){ return N.on ? N.dur : duration.get!.call(audio); }});
|
||||||
|
define('paused', {get(){ return N.on ? N.paused : paused.get!.call(audio); }});
|
||||||
|
define('readyState', {get(){ return N.on ? N.ready : readyState.get!.call(audio); }});
|
||||||
|
|
||||||
|
define('volume', {
|
||||||
|
get(){ return volume.get!.call(audio); },
|
||||||
|
set(v){ volume.set!.call(audio, v); if(N.on) post({t:'volume', v}); },
|
||||||
|
});
|
||||||
|
define('playbackRate', {
|
||||||
|
get(){ return playbackRate.get!.call(audio); },
|
||||||
|
set(v){ playbackRate.set!.call(audio, v); if(N.on) post({t:'rate', v}); },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closing the player is `audio.removeAttribute('src')`, which would otherwise leave the host
|
||||||
|
// playing on with nothing on screen to stop it.
|
||||||
|
define('removeAttribute', {value(name: string){
|
||||||
|
if(name === 'src') stop();
|
||||||
|
return realRemoveAttribute(name);
|
||||||
|
}});
|
||||||
|
|
||||||
|
function stop(){
|
||||||
|
if(!N.on) return;
|
||||||
|
N.on = false; N.paused = true; N.cur = 0; N.dur = NaN; N.ready = 0;
|
||||||
|
post({t:'stop'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position belongs to the host. player.ts is emphatic about what a stale write costs -- a player
|
||||||
|
// left paused in another tab once saved its older place over where you had got to -- and a
|
||||||
|
// backgrounded WebView is exactly that tab: frozen, holding a time from minutes ago, while the
|
||||||
|
// host plays on. So the beacon becomes a request for the host to save its own time, and the host
|
||||||
|
// is also the one saving while nothing here is running at all.
|
||||||
|
const beacon = navigator.sendBeacon && navigator.sendBeacon.bind(navigator);
|
||||||
|
navigator.sendBeacon = function(url: string, data?: any){
|
||||||
|
if(N.on && /\/position$/.test(String(url))){ post({t:'position', url:String(url)}); return true; }
|
||||||
|
return beacon ? beacon(url, data) : false;
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// What the host calls back into. On `window` deliberately: the host reaches it by name through
|
||||||
|
// evaluateJavaScript, and a top-level const would work but not obviously.
|
||||||
|
(window as any).ipxNative = {
|
||||||
|
version: 1,
|
||||||
|
on(m: any){
|
||||||
|
if(!N.on) return;
|
||||||
|
switch(m.t){
|
||||||
|
case 'time':
|
||||||
|
N.cur = m.cur;
|
||||||
|
if(m.dur != null) N.dur = m.dur;
|
||||||
|
fire('timeupdate');
|
||||||
|
break;
|
||||||
|
case 'meta':
|
||||||
|
N.dur = m.dur; N.ready = 1;
|
||||||
|
fire('loadedmetadata');
|
||||||
|
break;
|
||||||
|
case 'state':
|
||||||
|
if(m.playing === !N.paused) return;
|
||||||
|
N.paused = !m.playing;
|
||||||
|
fire(m.playing ? 'play' : 'pause');
|
||||||
|
break;
|
||||||
|
case 'ended':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
fire('ended');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
N.paused = true;
|
||||||
|
fire('pause');
|
||||||
|
toast('Playback failed' + (m.message ? ': ' + m.message : ''), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// The host waits for this to know the bridge is in and which build it got: an app newer than the
|
||||||
|
// deployed page would otherwise sit there sending messages nothing answers.
|
||||||
|
post({t:'ready', version:1, rate:audio.playbackRate, volume:audio.volume});
|
||||||
|
}
|
||||||
197
web/src/player.ts
Normal file
197
web/src/player.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/* ---------------- player ---------------- */
|
||||||
|
const audio=$('#audio');
|
||||||
|
const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean,
|
||||||
|
saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false};
|
||||||
|
|
||||||
|
// Marked read when an item has actually been listened to -- at the end, or past 90%.
|
||||||
|
// NOT on play: doing that made the item vanish from the Unread list the instant
|
||||||
|
// you pressed play, which looks exactly like it went missing.
|
||||||
|
function markPlayed(){
|
||||||
|
if(!player.guid||player.marked) return;
|
||||||
|
player.marked=true;
|
||||||
|
const e=player.entry;
|
||||||
|
if(!e||e.read) return;
|
||||||
|
setRead(e,true).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays one of the item's files in the player bar: the one asked for, or its first playable one.
|
||||||
|
function play(e,enc=e.enclosures.find(isPlayable)){
|
||||||
|
if(!isPlayable(enc)){
|
||||||
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The same file carries on where it was; another of the item's files starts from its top.
|
||||||
|
const resuming = player.guid===e.guid && player.enc===enc.id;
|
||||||
|
// Every play button is a pause button for what is playing, as the player bar's is.
|
||||||
|
if(resuming && !audio.paused){ audio.pause(); return; }
|
||||||
|
if(!resuming){
|
||||||
|
player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false;
|
||||||
|
document.body.classList.toggle('has-video', kindOf(enc)==='video');
|
||||||
|
audio.src=`/media/${enc.id}`;
|
||||||
|
audio.currentTime=0;
|
||||||
|
if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true});
|
||||||
|
// Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art.
|
||||||
|
$('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id));
|
||||||
|
$('#ptitle').textContent=entryName(e).text;
|
||||||
|
const f=S.feeds.find(x=>x.id===e.feed_id);
|
||||||
|
$('#pfeed').textContent=f?(f.title||f.id):'';
|
||||||
|
$('#player').classList.add('on');
|
||||||
|
mediaSession(e,f);
|
||||||
|
player.marked=false;
|
||||||
|
}
|
||||||
|
audio.play().catch(err=>toast('Playback failed: '+err.message,true));
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
function mediaSession(e,f){
|
||||||
|
if(!('mediaSession' in navigator)) return;
|
||||||
|
navigator.mediaSession.metadata=new MediaMetadata({
|
||||||
|
title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'',
|
||||||
|
artwork:(e.image||(f&&f.image))?[{src:artSrc(e.image||f.image),sizes:'512x512'}]:[],
|
||||||
|
});
|
||||||
|
const h={play:()=>audio.play(),pause:()=>audio.pause(),
|
||||||
|
seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30};
|
||||||
|
for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} }
|
||||||
|
}
|
||||||
|
audio.addEventListener('timeupdate',()=>{
|
||||||
|
const d=audio.duration||player.entry?.duration||0;
|
||||||
|
$('#pcur').textContent=clock(audio.currentTime);
|
||||||
|
$('#pdur').textContent=clock(d);
|
||||||
|
if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
||||||
|
// Only playing counts as moving: the seek to where you left off happens paused, and saving
|
||||||
|
// that would write back whatever the list said, however old.
|
||||||
|
if(!audio.paused) player.moved=true;
|
||||||
|
// Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used
|
||||||
|
// to wait for the next pause to be saved.
|
||||||
|
if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); }
|
||||||
|
if(d && audio.currentTime/d >= 0.9) markPlayed();
|
||||||
|
});
|
||||||
|
function savePos(){
|
||||||
|
// Before the file has loaded, currentTime is 0 rather than where you are: saving it then --
|
||||||
|
// a failed load, or a pause before the seek to where you left off -- wiped the position.
|
||||||
|
// Nor from a player nobody has played since it last saved: one left paused in another tab
|
||||||
|
// saved its older place as that tab reloaded, over where you had got to since.
|
||||||
|
if(!player.guid||!audio.readyState||!player.moved) return;
|
||||||
|
player.moved=false;
|
||||||
|
player.saveAt=audio.currentTime;
|
||||||
|
if(player.entry) player.entry.position=Math.floor(audio.currentTime);
|
||||||
|
// The measured length stands in for one the feed left out: without it Currently Listening
|
||||||
|
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
|
||||||
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
|
navigator.sendBeacon?.(
|
||||||
|
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
|
||||||
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
|
}
|
||||||
|
audio.addEventListener('pause',savePos);
|
||||||
|
audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play});
|
||||||
|
// body.playing is what sets the EQ bars moving.
|
||||||
|
audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
||||||
|
audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); });
|
||||||
|
for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons);
|
||||||
|
|
||||||
|
/// Every play button for what is playing shows pause, like the player bar's: a row's, the files
|
||||||
|
/// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played.
|
||||||
|
function syncPlayButtons(){
|
||||||
|
const on=(guid,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
||||||
|
const paint=(b,now,idle)=>{
|
||||||
|
const label=now?'Pause':idle;
|
||||||
|
if(b.title===label) return;
|
||||||
|
b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play;
|
||||||
|
};
|
||||||
|
for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep').dataset.guid),'Play');
|
||||||
|
for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]'))
|
||||||
|
paint(b,on(S.sel,Number(b.dataset.enc)),'Play');
|
||||||
|
const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item');
|
||||||
|
}
|
||||||
|
for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening);
|
||||||
|
window.addEventListener('beforeunload',savePos);
|
||||||
|
$('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
||||||
|
$('#pback').onclick=()=>audio.currentTime-=15;
|
||||||
|
$('#pfwd').onclick=()=>audio.currentTime+=30;
|
||||||
|
$('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000};
|
||||||
|
$('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)};
|
||||||
|
$('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)};
|
||||||
|
$('#pclose').onclick=()=>{savePos();audio.pause();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries();
|
||||||
|
// Called here, not left to the pause event: closing a player already paused fires none.
|
||||||
|
syncListening()};
|
||||||
|
(function restore(){
|
||||||
|
const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol');
|
||||||
|
if(r){$('#rate').value=r;audio.playbackRate=+r}
|
||||||
|
if(v){$('#vol').value=v;audio.volume=Number(v)/100}
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('keydown',ev=>{
|
||||||
|
// Escape leaves a dialog even from inside one of its boxes. It used to sit below the check
|
||||||
|
// that follows, so Add feed, which opens with the cursor in its URL box, ignored it.
|
||||||
|
if(ev.key==='Escape'){closeModal();nav(false);return}
|
||||||
|
// The rest are single keys that would otherwise eat what you type.
|
||||||
|
if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return;
|
||||||
|
if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()}
|
||||||
|
else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15}
|
||||||
|
else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30}
|
||||||
|
else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()}
|
||||||
|
else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a
|
||||||
|
// letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's
|
||||||
|
// own shortcuts still work, or while a dialog is open.
|
||||||
|
const GO={a:':all',d:':directory',p:':popular',l:':listening'};
|
||||||
|
let gAt=0;
|
||||||
|
function stepEntry(by){
|
||||||
|
if(VIEWS[S.feed]?.url||!S.entries.length) return;
|
||||||
|
const i=S.entries.findIndex(x=>x.guid===S.sel);
|
||||||
|
const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))];
|
||||||
|
selectEntry(e,true);
|
||||||
|
$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
function stepFeed(by){
|
||||||
|
const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id;
|
||||||
|
if(!id) return;
|
||||||
|
selectFeed(id);
|
||||||
|
$(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'});
|
||||||
|
}
|
||||||
|
const KEYS={
|
||||||
|
j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1),
|
||||||
|
J:()=>stepFeed(1), K:()=>stepFeed(-1),
|
||||||
|
// The toolbar's own buttons, so a key does exactly what the click does, and nothing while
|
||||||
|
// they are disabled.
|
||||||
|
o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(),
|
||||||
|
v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); },
|
||||||
|
A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(),
|
||||||
|
r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } },
|
||||||
|
'[':()=>matchMedia('(max-width:820px)').matches
|
||||||
|
? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'),
|
||||||
|
'?':()=>keysModal(),
|
||||||
|
g:()=>{ gAt=Date.now(); },
|
||||||
|
};
|
||||||
|
function typed(ev){
|
||||||
|
// The second key of a g pair counts only if it follows within a second and a half.
|
||||||
|
const pair=Date.now()-gAt<1500; gAt=0;
|
||||||
|
const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key];
|
||||||
|
if(!fn) return;
|
||||||
|
ev.preventDefault(); fn();
|
||||||
|
}
|
||||||
|
/// What ? shows: every key, grouped as Feedly's own list is.
|
||||||
|
function keysModal(){
|
||||||
|
const k=s=>`<kbd>${esc(s)}</kbd>`, g=c=>k('g')+' '+k(c);
|
||||||
|
const rows=[
|
||||||
|
['Go to'],
|
||||||
|
[g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'],
|
||||||
|
[g('l'),'Currently Listening'],[g('s'),'Settings'],
|
||||||
|
[k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'],
|
||||||
|
[k('/'),'Search items'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'],
|
||||||
|
['Items'],
|
||||||
|
[k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'],
|
||||||
|
[k('Shift')+' '+k('A'),'Mark all read'],
|
||||||
|
['The selected item'],
|
||||||
|
[k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'],
|
||||||
|
[k('v'),'Open the original in a new tab'],
|
||||||
|
['The player'],
|
||||||
|
[k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'],
|
||||||
|
['Anywhere'],
|
||||||
|
[k('?'),'This list'],[k('Esc'),'Close a dialog'],
|
||||||
|
];
|
||||||
|
openModal(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
||||||
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>`);
|
||||||
|
}
|
||||||
|
|
||||||
63
web/src/theme.ts
Normal file
63
web/src/theme.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/* ---------------- theme ---------------- */
|
||||||
|
// A theme, and for those that come in both, light, dark or Auto, chosen in Settings and kept in a
|
||||||
|
// cookie, so each device has its own: Glass on a phone, Dracula on a desktop (issue #69). The
|
||||||
|
// server reads it and writes it onto the page's <html> tag (data-theme, data-choice) so the page
|
||||||
|
// is drawn in it from the start. The
|
||||||
|
// page gets data-mode, light or dark, which is all the CSS reads: Auto is worked out here, from
|
||||||
|
// the system, so no palette is written twice.
|
||||||
|
const THEMES: Record<string, {name: string, modes: boolean}> = {
|
||||||
|
adwaita: {name: 'Adwaita', modes: true},
|
||||||
|
catppuccin: {name: 'Catppuccin', modes: true},
|
||||||
|
classic: {name: 'Classic', modes: false},
|
||||||
|
dracula: {name: 'Dracula', modes: true},
|
||||||
|
flatremix: {name: 'Flat Remix', modes: true},
|
||||||
|
glass: {name: 'Glass', modes: true},
|
||||||
|
gruvbox: {name: 'Gruvbox', modes: true},
|
||||||
|
contrast: {name: 'High contrast', modes: true},
|
||||||
|
material: {name: 'Material', modes: true},
|
||||||
|
modern: {name: 'Modern', modes: true},
|
||||||
|
nordic: {name: 'Nordic', modes: true},
|
||||||
|
paper: {name: 'Paper', modes: false},
|
||||||
|
solarized: {name: 'Solarized', modes: true},
|
||||||
|
};
|
||||||
|
const MODES: Record<string, string> = {auto: 'Auto (matches your system)', light: 'Light', dark: 'Dark'};
|
||||||
|
// Before themes came in light and dark, ipx.theme in localStorage held one of these.
|
||||||
|
const OLD_THEMES: Record<string, [string, string]> = {dark: ['modern', 'dark'], light: ['modern', 'light'], auto: ['modern', 'auto']};
|
||||||
|
const systemDark = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||||
|
const theme = {name: 'modern', mode: 'dark'};
|
||||||
|
|
||||||
|
/// `save` for a choice made in Settings, which goes to this browser's cookie; not for applying one.
|
||||||
|
function setTheme(name = theme.name, mode = theme.mode, save = false){
|
||||||
|
theme.name = THEMES[name] ? name : 'modern';
|
||||||
|
theme.mode = MODES[mode] ? mode : 'dark';
|
||||||
|
const both = THEMES[theme.name].modes;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.dataset.theme = theme.name;
|
||||||
|
// A theme with one palette has it whatever the mode; both of those are light.
|
||||||
|
root.dataset.mode = !both ? 'light'
|
||||||
|
: theme.mode === 'auto' ? (systemDark && !systemDark.matches ? 'light' : 'dark') : theme.mode;
|
||||||
|
// The tab's icon in the same variant as the logo on the page.
|
||||||
|
const fav = $('#favicon'); if(fav) fav.href = fav.dataset[root.dataset.mode];
|
||||||
|
const sel = $('#stheme'); if(sel) sel.value = theme.name;
|
||||||
|
const ms = $('#smode'); if(ms) ms.value = theme.mode;
|
||||||
|
const mf = $('#smodefield'); if(mf) mf.hidden = !both;
|
||||||
|
if(save) saveTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kept a year, for the whole site: the admin page is drawn in it too.
|
||||||
|
function saveTheme(){
|
||||||
|
document.cookie = `ipx_theme=${theme.name}.${theme.mode}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
systemDark?.addEventListener?.('change', () => { if(theme.mode === 'auto') setTheme(); });
|
||||||
|
(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
// Without a cookie, the server sent the theme the account kept from before; this browser takes
|
||||||
|
// it as its own, once, so nobody has to choose again.
|
||||||
|
if(root.dataset.choice) return setTheme(root.dataset.theme, root.dataset.choice, !/(^|; )ipx_theme=/.test(document.cookie));
|
||||||
|
// Nothing on the account either. A theme this browser kept in localStorage, from before that,
|
||||||
|
// becomes its cookie, once.
|
||||||
|
let name: string | null = null, mode: string | null = null;
|
||||||
|
try{ name = localStorage.getItem('ipx.theme'); mode = localStorage.getItem('ipx.mode'); }catch{}
|
||||||
|
if(OLD_THEMES[name]) [name, mode] = OLD_THEMES[name];
|
||||||
|
setTheme(name ?? undefined, mode ?? undefined, !!name);
|
||||||
|
})();
|
||||||
204
web/src/util.ts
Normal file
204
web/src/util.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
'use strict';
|
||||||
|
// `any`: the page reads .value, .dataset and .onclick off whatever it looks up, and the
|
||||||
|
// smoke test, not the type checker, is what makes sure a selector exists.
|
||||||
|
const $ = (s: string, r: ParentNode = document): any => r.querySelector(s);
|
||||||
|
const $$ = (s: string, r: ParentNode = document): any[] => [...r.querySelectorAll(s)];
|
||||||
|
const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
// Icons: Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com
|
||||||
|
// License - https://fontawesome.com/license/free (Icons: CC BY 4.0). Embedded as SVG, only the
|
||||||
|
// 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,body)=>`<svg class="i" viewBox="${box}" aria-hidden="true">${body}</svg>`;
|
||||||
|
const ICON={
|
||||||
|
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
|
||||||
|
circleMinus:fa('0 0 512 512','<path fill="currentColor" d="M512 256A256 256 0 1 0 0 256a256 256 0 1 0 512 0zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"/>'), // solid/circle-minus, for Unsubscribe
|
||||||
|
play:fa('0 0 448 512','<path fill="currentColor" d="M91.2 36.9c-12.4-6.8-27.4-6.5-39.6 .7S32 57.9 32 72l0 368c0 14.1 7.5 27.2 19.6 34.4s27.2 7.5 39.6 .7l336-184c12.8-7 20.8-20.5 20.8-35.1s-8-28.1-20.8-35.1l-336-184z"/>'), // solid/play
|
||||||
|
check:fa('0 0 448 512','<path fill="currentColor" d="M434.8 70.1c14.3 10.4 17.5 30.4 7.1 44.7l-256 352c-5.5 7.6-14 12.3-23.4 13.1s-18.5-2.7-25.1-9.3l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l101.5 101.5 234-321.7c10.4-14.3 30.4-17.5 44.7-7.1z"/>'), // solid/check
|
||||||
|
checks:fa('0 0 384 512','<path fill="currentColor" d="M249.9 66.8c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-106 145.7-37.5-37.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l64 64c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l128-176zm128 136c10.4-14.3 7.2-34.3-7.1-44.7s-34.3-7.2-44.7 7.1l-170 233.7-69.5-69.5c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c6.6 6.6 15.8 10 25.1 9.3s17.9-5.5 23.4-13.1l192-264z"/>'), // solid/check-double
|
||||||
|
// Pinned is the solid thumbtack; not pinned, the same shape outlined, as the flag had its regular
|
||||||
|
// and solid pair (Font Awesome's free set has no regular thumbtack). Both share a viewBox padded
|
||||||
|
// 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
|
||||||
|
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
|
||||||
|
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||||
|
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||||
|
settings:fa('0 0 512 512','<path fill="currentColor" d="M195.1 9.5C198.1-5.3 211.2-16 226.4-16l59.8 0c15.2 0 28.3 10.7 31.3 25.5L332 79.5c14.1 6 27.3 13.7 39.3 22.8l67.8-22.5c14.4-4.8 30.2 1.2 37.8 14.4l29.9 51.8c7.6 13.2 4.9 29.8-6.5 39.9L447 233.3c.9 7.4 1.3 15 1.3 22.7s-.5 15.3-1.3 22.7l53.4 47.5c11.4 10.1 14 26.8 6.5 39.9l-29.9 51.8c-7.6 13.1-23.4 19.2-37.8 14.4l-67.8-22.5c-12.1 9.1-25.3 16.7-39.3 22.8l-14.4 69.9c-3.1 14.9-16.2 25.5-31.3 25.5l-59.8 0c-15.2 0-28.3-10.7-31.3-25.5l-14.4-69.9c-14.1-6-27.2-13.7-39.3-22.8L73.5 432.3c-14.4 4.8-30.2-1.2-37.8-14.4L5.8 366.1c-7.6-13.2-4.9-29.8 6.5-39.9l53.4-47.5c-.9-7.4-1.3-15-1.3-22.7s.5-15.3 1.3-22.7L12.3 185.8c-11.4-10.1-14-26.8-6.5-39.9L35.7 94.1c7.6-13.2 23.4-19.2 37.8-14.4l67.8 22.5c12.1-9.1 25.3-16.7 39.3-22.8L195.1 9.5zM256.3 336a80 80 0 1 0 -.6-160 80 80 0 1 0 .6 160z"/>'), // solid/gear
|
||||||
|
close:fa('0 0 384 512','<path fill="currentColor" d="M55.1 73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L147.2 256 9.9 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192.5 301.3 329.9 438.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.8 256 375.1 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192.5 210.7 55.1 73.4z"/>'), // solid/xmark
|
||||||
|
menu:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/>'), // solid/bars
|
||||||
|
directory:fa('0 0 448 512','<path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32l320 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zm64 0l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 224l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64zM64 352l0 64 64 0 0-64-64 0zm320 0l-192 0 0 64 192 0 0-64z"/>'), // solid/table-list
|
||||||
|
popular:fa('0 0 576 512','<path fill="currentColor" d="M309.5-18.9c-4.1-8-12.4-13.1-21.4-13.1s-17.3 5.1-21.4 13.1L193.1 125.3 33.2 150.7c-8.9 1.4-16.3 7.7-19.1 16.3s-.5 18 5.8 24.4l114.4 114.5-25.2 159.9c-1.4 8.9 2.3 17.9 9.6 23.2s16.9 6.1 25 2L288.1 417.6 432.4 491c8 4.1 17.7 3.3 25-2s11-14.2 9.6-23.2L441.7 305.9 556.1 191.4c6.4-6.4 8.6-15.8 5.8-24.4s-10.1-14.9-19.1-16.3L383 125.3 309.5-18.9z"/>'), // solid/star
|
||||||
|
all:fa('0 0 512 512','<path fill="currentColor" d="M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"/>'), // solid/layer-group
|
||||||
|
unread:fa('0 0 512 512','<path fill="currentColor" d="M48 64c-26.5 0-48 21.5-48 48 0 15.1 7.1 29.3 19.2 38.4l208 156c17.1 12.8 40.5 12.8 57.6 0l208-156c12.1-9.1 19.2-23.3 19.2-38.4 0-26.5-21.5-48-48-48L48 64zM0 196L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-188-198.4 148.8c-34.1 25.6-81.1 25.6-115.2 0L0 196z"/>'), // solid/envelope: a closed letter, not a record button
|
||||||
|
audio:fa('0 0 448 512','<path fill="currentColor" d="M64 224c0-88.4 71.6-160 160-160s160 71.6 160 160l0 37.5c-10-3.5-20.8-5.5-32-5.5l-16 0c-26.5 0-48 21.5-48 48l0 128c0 26.5 21.5 48 48 48l16 0c53 0 96-43 96-96l0-160C448 100.3 347.7 0 224 0S0 100.3 0 224L0 384c0 53 43 96 96 96l16 0c26.5 0 48-21.5 48-48l0-128c0-26.5-21.5-48-48-48l-16 0c-11.2 0-22 1.9-32 5.5L64 224z"/>'), // solid/headphones
|
||||||
|
video:fa('0 0 576 512','<path fill="currentColor" d="M96 64c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L96 64zM464 336l73.5 58.8c4.2 3.4 9.4 5.2 14.8 5.2 13.1 0 23.7-10.6 23.7-23.7l0-240.6c0-13.1-10.6-23.7-23.7-23.7-5.4 0-10.6 1.8-14.8 5.2L464 176 464 336z"/>'), // solid/video
|
||||||
|
image: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-320c0-35.3-28.7-64-64-64L64 32zm64 80a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM272 224c8.4 0 16.1 4.4 20.5 11.5l88 144c4.5 7.4 4.7 16.7 .5 24.3S368.7 416 360 416L88 416c-8.9 0-17.2-5-21.3-12.9s-3.5-17.5 1.6-24.8l56-80c4.5-6.4 11.8-10.2 19.7-10.2s15.2 3.8 19.7 10.2l26.4 37.8 61.4-100.5c4.4-7.1 12.1-11.5 20.5-11.5z"/>'), // solid/image
|
||||||
|
doc:fa('0 0 576 512','<path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l80 0 0-112c0-35.3 28.7-64 64-64l176 0 0-165.5c0-17-6.7-33.3-18.7-45.3L290.7 18.7C278.7 6.7 262.5 0 245.5 0L96 0zM357.5 176L264 176c-13.3 0-24-10.7-24-24L240 58.5 357.5 176zM240 380c-11 0-20 9-20 20l0 128c0 11 9 20 20 20s20-9 20-20l0-28 12 0c33.1 0 60-26.9 60-60s-26.9-60-60-60l-32 0zm32 80l-12 0 0-40 12 0c11 0 20 9 20 20s-9 20-20 20zm96-80c-11 0-20 9-20 20l0 128c0 11 9 20 20 20l32 0c28.7 0 52-23.3 52-52l0-64c0-28.7-23.3-52-52-52l-32 0zm20 128l0-88 12 0c6.6 0 12 5.4 12 12l0 64c0 6.6-5.4 12-12 12l-12 0zm88-108l0 128c0 11 9 20 20 20s20-9 20-20l0-44 28 0c11 0 20-9 20-20s-9-20-20-20l-28 0 0-24 28 0c11 0 20-9 20-20s-9-20-20-20l-48 0c-11 0-20 9-20 20z"/>'), // solid/file-pdf
|
||||||
|
torrent:fa('0 0 448 512','<path fill="currentColor" d="M0 176L0 288C0 411.7 100.3 512 224 512S448 411.7 448 288l0-112-128 0 0 112c0 53-43 96-96 96s-96-43-96-96l0-112-128 0zm0-48l128 0 0-64c0-17.7-14.3-32-32-32L32 32C14.3 32 0 46.3 0 64l0 64zm320 0l128 0 0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32l0 64z"/>'), // solid/magnet
|
||||||
|
file:fa('0 0 384 512','<path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-277.5c0-17-6.7-33.3-18.7-45.3L258.7 18.7C246.7 6.7 230.5 0 213.5 0L64 0zM325.5 176L232 176c-13.3 0-24-10.7-24-24L208 58.5 325.5 176z"/>'), // solid/file
|
||||||
|
copy:fa('0 0 448 512','<path fill="currentColor" d="M192 0c-35.3 0-64 28.7-64 64l0 256c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-200.6c0-17.4-7.1-34.1-19.7-46.2L370.6 17.8C358.7 6.4 342.8 0 326.3 0L192 0zM64 128c-35.3 0-64 28.7-64 64L0 448c0 35.3 28.7 64 64 64l192 0c35.3 0 64-28.7 64-64l0-16-64 0 0 16-192 0 0-256 16 0 0-64-16 0z"/>'), // solid/copy
|
||||||
|
signout:fa('0 0 512 512','<path fill="currentColor" d="M505 273c9.4-9.4 9.4-24.6 0-33.9L361 95c-6.9-6.9-17.2-8.9-26.2-5.2S320 102.3 320 112l0 80-112 0c-26.5 0-48 21.5-48 48l0 32c0 26.5 21.5 48 48 48l112 0 0 80c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2L505 273zM160 96c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 32C43 32 0 75 0 128L0 384c0 53 43 96 96 96l64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l64 0z"/>'), // solid/right-from-bracket
|
||||||
|
back:fa('0 0 512 512','<path fill="currentColor" d="M24 192l144 0c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-46.7-46.7c75.3-58.6 184.3-53.3 253.5 15.9 75 75 75 196.5 0 271.5s-196.5 75-271.5 0c-10.2-10.2-19-21.3-26.4-33-9.5-14.9-29.3-19.3-44.2-9.8s-19.3 29.3-9.8 44.2C49.7 408.7 61.4 423.5 75 437 175 537 337 537 437 437S537 175 437 75C342.8-19.3 193.3-24.7 92.7 58.8L41 7C34.1 .2 23.8-1.9 14.8 1.8S0 14.3 0 24L0 168c0 13.3 10.7 24 24 24z"/>'), // solid/rotate-left
|
||||||
|
fwd:fa('0 0 512 512','<path fill="currentColor" d="M488 192l-144 0c-9.7 0-18.5-5.8-22.2-14.8s-1.7-19.3 5.2-26.2l46.7-46.7c-75.3-58.6-184.3-53.3-253.5 15.9-75 75-75 196.5 0 271.5s196.5 75 271.5 0c8.2-8.2 15.5-16.9 21.9-26.1 10.1-14.5 30.1-18 44.6-7.9s18 30.1 7.9 44.6c-8.5 12.2-18.2 23.8-29.1 34.7-100 100-262.1 100-362 0S-25 175 75 75c94.3-94.3 243.7-99.6 344.3-16.2L471 7c6.9-6.9 17.2-8.9 26.2-5.2S512 14.3 512 24l0 144c0 13.3-10.7 24-24 24z"/>'), // solid/rotate-right
|
||||||
|
pause:fa('0 0 384 512','<path fill="currentColor" d="M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"/>'), // solid/pause
|
||||||
|
alert:fa('0 0 128 512','<path fill="currentColor" d="M64 432c22.1 0 40 17.9 40 40s-17.9 40-40 40-40-17.9-40-40c0-22.1 17.9-40 40-40zM64 0c26.5 0 48 21.5 48 48 0 .6 0 1.1 0 1.7l-16 304c-.9 17-15 30.3-32 30.3S33 370.7 32 353.7L16 49.7c0-.6 0-1.1 0-1.7 0-26.5 21.5-48 48-48z"/>'), // solid/exclamation
|
||||||
|
admin:fa('0 0 576 512','<path fill="currentColor" d="M70.8-6.7c5.4-5.4 13.8-6.2 20.2-2L209.9 70.5c8.9 5.9 14.2 15.9 14.2 26.6l0 49.6 90.8 90.8c33.3-15 73.9-8.9 101.2 18.5L542.2 382.1c18.7 18.7 18.7 49.1 0 67.9l-60.1 60.1c-18.7 18.7-49.1 18.7-67.9 0L288.1 384c-27.4-27.4-33.5-67.9-18.5-101.2l-90.8-90.8-49.6 0c-10.7 0-20.7-5.3-26.6-14.2L23.4 58.9c-4.2-6.3-3.4-14.8 2-20.2L70.8-6.7zm145 303.5c-6.3 36.9 2.3 75.9 26.2 107.2l-94.9 95c-28.1 28.1-73.7 28.1-101.8 0s-28.1-73.7 0-101.8l135.4-135.5 35.2 35.1zM384.1 0c20.1 0 39.4 3.7 57.1 10.5 10 3.8 11.8 16.5 4.3 24.1L388.8 91.3c-3 3-4.7 7.1-4.7 11.3l0 41.4c0 8.8 7.2 16 16 16l41.4 0c4.2 0 8.3-1.7 11.3-4.7l56.7-56.7c7.6-7.5 20.3-5.7 24.1 4.3 6.8 17.7 10.5 37 10.5 57.1 0 43.2-17.2 82.3-45 111.1l-49.1-49.1c-33.1-33-78.5-45.7-121.1-38.4l-56.8-56.8 0-29.7-.2-5c-.8-12.4-4.4-24.3-10.5-34.9 29.4-35 73.4-57.2 122.7-57.3z"/>'), // solid/screwdriver-wrench
|
||||||
|
caret:fa('0 0 256 512','<path fill="currentColor" d="M249.3 235.8c10.2 12.6 9.5 31.1-2.2 42.8l-128 128c-9.2 9.2-22.9 11.9-34.9 6.9S64.5 396.9 64.5 384l0-256c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l128 128 2.2 2.4z"/>'), // solid/caret-right
|
||||||
|
left:fa('0 0 512 512','<path fill="currentColor" d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.3 288 480 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-370.7 0 105.4-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/>'), // solid/arrow-left
|
||||||
|
subbed:fa('0 0 512 512','<path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/>'), // solid/circle-check
|
||||||
|
};
|
||||||
|
// What is playing, as the icon's EQ bars; the stylesheet moves them.
|
||||||
|
const EQ='<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>';
|
||||||
|
// One meaning per icon: minus unsubscribes, x closes or cancels, plus adds or subscribes, and a
|
||||||
|
// dialog's confirm button carries the icon of what it does. Words go in the tooltip.
|
||||||
|
// The page's own buttons name their icon; this draws it in, ahead of any label they carry.
|
||||||
|
for(const b of $$('[data-icon]')) b.insertAdjacentHTML('afterbegin',ICON[b.dataset.icon]);
|
||||||
|
|
||||||
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
|
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
||||||
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
|
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
||||||
|
return r.status===204?null:r.json().catch(()=>null);
|
||||||
|
}
|
||||||
|
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||||
|
// address it is undefined, so fall back to the old selection-based copy.
|
||||||
|
async function copyText(text,btn){
|
||||||
|
const flash=ok=>{
|
||||||
|
if(!btn) return;
|
||||||
|
// The button is an icon, so it is the markup that has to come back, not just its text.
|
||||||
|
const was=btn.innerHTML;
|
||||||
|
btn.textContent=ok?'Copied':'Failed';
|
||||||
|
setTimeout(()=>btn.innerHTML=was,1300);
|
||||||
|
};
|
||||||
|
try{
|
||||||
|
if(navigator.clipboard&&window.isSecureContext){
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
}else{
|
||||||
|
const ta=document.createElement('textarea');
|
||||||
|
ta.value=text; ta.setAttribute('readonly','');
|
||||||
|
ta.style.cssText='position:fixed;top:-1000px;opacity:0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select(); ta.setSelectionRange(0,ta.value.length);
|
||||||
|
const ok=document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
if(!ok) throw new Error('copy rejected');
|
||||||
|
}
|
||||||
|
flash(true);
|
||||||
|
}catch(e){
|
||||||
|
flash(false);
|
||||||
|
toast('Could not copy automatically — select the URL and copy it manually',true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The system's share sheet where there is one (phones, Safari, Edge), the clipboard elsewhere.
|
||||||
|
/// Asks first when the address looks like it carries your own access to a paid or private
|
||||||
|
/// feed: a Patreon feed's token, or a key, token or password in it, which would hand whoever
|
||||||
|
/// gets it your subscription (issue #48).
|
||||||
|
///
|
||||||
|
/// ponytail: a guess from the address. A private feed whose key has another name is shared
|
||||||
|
/// without asking; add its pattern here when one turns up.
|
||||||
|
async function share(title: string, url: string, btn?){
|
||||||
|
if(/patreon\.com\/|\/\/[^/]*@|[?&][^=]*(auth|token|key|secret|pass|sig)[^=]*=/i.test(url)
|
||||||
|
&& !confirm('This address looks like it includes your own access to the feed, and anyone you '
|
||||||
|
+'send it to could use it as you.\n\nShare it anyway?')) return;
|
||||||
|
if(navigator.share){
|
||||||
|
// Cancelling the sheet rejects too, and is not a failure worth a word.
|
||||||
|
try{ await navigator.share({title, url}); }
|
||||||
|
catch(e){ if(e.name!=='AbortError') toast(e.message,true); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await copyText(url,btn);
|
||||||
|
toast('Link copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg: string, bad?: boolean){
|
||||||
|
const t=document.createElement('div');
|
||||||
|
t.className='toast'+(bad?' bad':''); t.textContent=msg;
|
||||||
|
$('#toasts').appendChild(t);
|
||||||
|
setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),320)},bad?6000:3200);
|
||||||
|
}
|
||||||
|
const clock = s => {
|
||||||
|
s=Math.max(0,Math.floor(s||0));
|
||||||
|
const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;
|
||||||
|
return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;
|
||||||
|
};
|
||||||
|
const ago = t => {
|
||||||
|
if(!t) return 'never';
|
||||||
|
const d=(Date.now()/1000)-t;
|
||||||
|
if(d<3600) return Math.max(1,Math.round(d/60))+'m ago';
|
||||||
|
if(d<86400) return Math.round(d/3600)+'h ago';
|
||||||
|
if(d<2592000) return Math.round(d/86400)+'d ago';
|
||||||
|
return new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'});
|
||||||
|
};
|
||||||
|
const dateOf = t => t?new Date(t*1000).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}):'';
|
||||||
|
// A podcast episode is tens of MB, an article's image a few KB: whole MB made the small ones "0 MB".
|
||||||
|
const mb = n => !n?'' : n<1048576?Math.max(1,Math.round(n/1024))+' KB'
|
||||||
|
: n<1073741824?Math.round(n/1048576)+' MB' : (n/1073741824).toFixed(1)+' GB';
|
||||||
|
const initials = s => (s||'?').replace(/[^A-Za-z0-9 ]/g,'').split(/\s+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'?';
|
||||||
|
const plural=(n,word)=>`${n} ${word}${n===1?'':'s'}`;
|
||||||
|
// An initials tile takes one of these, by a hash of the name, so neighbours rarely match.
|
||||||
|
const TINTS=['var(--accent)','var(--good)','var(--dim)','color-mix(in srgb,var(--accent),var(--good))'];
|
||||||
|
const tint=name=>{ let h=0; for(const c of name||'?') h=(h*31+c.charCodeAt(0))>>>0; return TINTS[h%TINTS.length]; };
|
||||||
|
function tileHTML(name,cls){
|
||||||
|
return `<div class="art ini ${cls||''}" style="--tint:${tint(name)}">${esc(initials(name))}</div>`;
|
||||||
|
}
|
||||||
|
/// Every image a feed names comes from iPX, which keeps a copy: fast after the first time, and
|
||||||
|
/// no publisher's server asked on every visit. It began with http-only artwork, which the https
|
||||||
|
/// page could not load (#90).
|
||||||
|
function artSrc(url: string){
|
||||||
|
return /^https?:\/\//i.test(url) ? '/api/art?u='+encodeURIComponent(url) : url;
|
||||||
|
}
|
||||||
|
function artHTML(url: string | null, name: string, cls?: string){
|
||||||
|
return url
|
||||||
|
? `<img class="art ${cls||''}" src="${esc(artSrc(url))}" alt="" loading="lazy" onerror="this.outerHTML=${esc(JSON.stringify(tileHTML(name,cls)))}">`
|
||||||
|
: tileHTML(name,cls);
|
||||||
|
}
|
||||||
|
/// A folder's tile is its first four shows' art. With fewer than four to show, the folder's own.
|
||||||
|
function folderArt(f,kids){
|
||||||
|
const art=kids.filter(c=>c.image).slice(0,4);
|
||||||
|
if(art.length<4) return artHTML(f.image,f.title||f.id);
|
||||||
|
// Tinted underneath, so art that fails to load leaves colour behind rather than a hole.
|
||||||
|
return `<div class="art ini mosaic" style="--tint:${tint(f.title||f.id)}">${art.map(c=>
|
||||||
|
`<img src="${esc(artSrc(c.image))}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sidebar slides over the page on a phone, so it needs a scrim to tap away.
|
||||||
|
function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=!on; }
|
||||||
|
|
||||||
|
/* ---------------- state ---------------- */
|
||||||
|
const S = {
|
||||||
|
feeds:[],
|
||||||
|
// Which feed (or place) and which tab were open last time, so a refresh lands back where
|
||||||
|
// you were instead of jumping to the first feed alphabetically.
|
||||||
|
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
|
||||||
|
entries:[], total:0, offset:0,
|
||||||
|
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
|
||||||
|
q:'', sel:null, me:null,
|
||||||
|
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
|
||||||
|
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
|
||||||
|
||{col:'published',dir:'desc'},
|
||||||
|
};
|
||||||
|
const LIMIT = 50;
|
||||||
|
|
||||||
|
const UNITS = [['m','minutes'],['h','hours'],['d','days'],['w','weeks']];
|
||||||
|
const UNIT_MINS = {m:1, h:60, d:1440, w:10080};
|
||||||
|
|
||||||
|
/// Largest unit that divides evenly, so 120 reads "2 hours" not "120 minutes".
|
||||||
|
function splitEvery(m){
|
||||||
|
if(!m) return {n:1, u:'h'};
|
||||||
|
for(const u of ['w','d','h']) if(m % UNIT_MINS[u] === 0) return {n:m/UNIT_MINS[u], u};
|
||||||
|
return {n:m, u:'m'};
|
||||||
|
}
|
||||||
|
function unitOptions(sel){
|
||||||
|
return UNITS.map(([v,l]) =>
|
||||||
|
`<option value="${v}"${sel===v?' selected':''}>${l}</option>`).join('');
|
||||||
|
}
|
||||||
|
function everyText(m){
|
||||||
|
if(!m) return '\u2014';
|
||||||
|
const {n,u} = splitEvery(m);
|
||||||
|
const name = {m:'min', h:'hour', d:'day', w:'week'}[u];
|
||||||
|
return n + ' ' + name + (u!=='m' && n!==1 ? 's' : '');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user