Compare commits

...
21 Commits
Author SHA1 Message Date
peter c430558ab3 release: 0.114.3 (improvement/e49-refusal-contract) 2026-11-01 00:30:51 +01:00
peter be38d652a5 chore(release): 0.114.3 notes 2026-11-01 00:18:59 +01:00
peter 31a75f4985 fix(i18n): write each refusal where it is sent, and read only that form
A refusal's detail now stands where it is sent (HTTPException's detail,
a JSONResponse's "detail") or is returned by a factory that only ever is,
with any spread before its code and reason, so no spread can replace
them. The scan follows no variable and no kwargs any more; what it cannot
read in a reason (a lambda's or comprehension's name, a nonlocal or
global, a local that reaches itself, a constant or map changed elsewhere,
an app import it cannot resolve) is reported. The detail guard reads the
same slots and fails on anything but a code dict or a factory's call.
2026-10-31 23:54:55 +01:00
peter f8a8e905da fix(i18n): read refusals along a closed list of shapes, report the rest
The scan followed many shapes and called the rest unread, but each shape it learned opened a silent
branch: a parameter rebound in the body, a second visit of a followed helper, an `import app.x`, a name
bound twice or under an `if`, a spread or a store that replaces the code, a detail rebuilt from its
variable, a None reason. It now reads only what the app writes and reports everything else: a
branch the app never takes is gone (an `or`, a default, a base class's attribute, string and union
annotations, a bare module constant), a spread is clean only from an untouched `**kwargs` no call puts
a `code` in, and the detail's variable may only grow by `|=` or `["reason"]`. The real tree reads
exactly as before; every exit and every kept branch has a self-test that a mutant of it fails.

The detail test also fails a dict without a `code`, a JSONResponse sentence and a detail-less
HTTPException; the two that are no refusal (the setup gate's 503, an unmatched api path's 404) are
listed with why. The contract's assertion that compared the reason map with itself is gone.
2026-10-31 18:45:02 +01:00
peter 5e8589bbf8 fix(i18n): read every refusal from the backend's syntax tree, not by regex
The contract read Python with regexes, and each guard added for a missed shape had shapes of its own
it missed: a reason after another key, a status passed by name, a second `e.reason` handler with the
same text, a pin naming a type nothing checked.

backend/tests/refusal_sites.py (stdlib `ast`, run by the contract on the host's Python) reads every
`{"code": ...}` detail and follows each reason to its values: literals, both arms of a conditional,
module constants, the `Literal` on the parameter or attribute it travels in (on the class an `except`
catches), every call of a `str` helper with its defaults, a map's values with its keys held to the
index's type. What it cannot follow fails the contract, unless no screen shows that code; no pins.

The reason attributes are typed with their Literal, so a subclass or an assignment cannot widen
them; InvalidSetting takes a SettingReason, and the settings map is held to it (a reason the map
lacked was a 500). The per-family reverse checks are one check by prefix (the admin actions
included), and the leaf-key filter applies only to the suffixed sign-in family.
2026-10-31 15:07:38 +01:00
peter ceadf69e43 fix(i18n): hold every refusal reason by type, and refuse what the scan cannot read
Review round 1 of the refusal contract: it claimed more than it held.

- The other reason carriers get their Literal too (BlockedUrl, ClipError,
  FolderError, the playlist and sign-in refusal helpers), so the type
  checker refuses a reason passed by variable or an unknown literal, and
  the contract reads one shape.
- A literal reason in a detail dict built anywhere counts as a reason of
  its code; a code the scan cannot read, a refused() whose reason it
  cannot read, and a new reason passed by variable each fail the contract
  until listed with what holds them.
- A sign-in refusal key the backend no longer sends is caught too.
- localizeDetail's numbers (download cap in GB, upload cap in bytes) and
  the shared-sentence fallbacks are tested in both languages.
- One Literal parser in the contract; a stale comment fixed.
2026-10-31 01:00:26 +01:00
peter 4379f76747 feat(i18n): every server refusal is said by a record the contract holds
A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
2026-10-30 23:36:25 +01:00
peter b3be568e36 release: 0.114.2 (bug/joserfc-cve) 2026-10-30 22:39:07 +01:00
peter a8cbc7b338 chore(release): 0.114.2 2026-10-30 22:28:39 +01:00
peter cf8dc80428 fix(deps): joserfc 1.7.4 -> 1.7.6 for CVE-2026-119283 and CVE-2026-119284
One pin bumped in place (Authlib's dependency); pip check passes and pip-audit finds nothing.
2026-10-30 22:28:39 +01:00
peter da74b8b9bf release: 0.114.1 (bug/notice-language) 2026-10-30 03:10:42 +01:00
peter 6944622085 chore(release): 0.114.1 2026-10-30 02:58:51 +01:00
peter 2bdcebdb34 fix(notifications): a confirm names its user and channel again; t() and a notice's text are kept apart by a contract
The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe
confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a
syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice.
The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in).
seasonTitleText's no-number branches are tested.
2026-10-30 01:54:00 +01:00
peter 046dde44a4 test(notifications): the center re-says its rows after a switch, a repeat coalesces by content; e2e helper fails with a cause
The center's rows (title, message, refusal, live button) were never rendered across a language
switch, and nothing held the structural compare that coalescing now needs. The channel-link helper
swallowed whatever ended its poll into a bare false: a link that goes away mid-look now counts as
not hit, and no hittable link fails with the poll's own message.
2026-10-30 00:17:46 +01:00
peter 74f58ea001 fix(notifications): the English log formats its numbers in English; the ui/ contract sees msg() keys
A formatted value took the reader's language even when the notice was said in English for the
log. The platform-namespace contract matched only t("…"), so a domain key passed through msg()
in components/ui/ went unseen. Three comments pointed at localizeDetail's old home.
2026-10-30 00:15:22 +01:00
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00
peter 4a0d4b0964 fix(notifications): a notice value is no plain string, so a translated name cannot freeze in one language
A name goes in as raw(), a translated word as its own msg(): tsc now lists every value, and the
ones that froze are said when shown — Watch later and an untitled channel in the pin and channel
notices, a Plex season's name in its removal toast (a new seasonTitleText, which seasonTitle says).
2026-10-30 00:03:48 +01:00
peter cb8b99e0e7 fix(notifications): keep the history under a key of its own, so an older build never reads its new shape
A build from before the change renders history rows as text; an object there crashed its
Notifications page after a rollback. The old key is read once while the new one does not exist,
and never written.
2026-10-29 23:54:48 +01:00
peter e5c75702e8 fix(views,tags): a rename onto a taken name names the taken one, not the row's old name
The rollback after the conflict puts the old name back on the row, so the refusal read
"you already have a view named 'hh'" when the taken name was 'gaming'.
2026-10-29 23:07:54 +01:00
peter 4ef772d37b fix(notifications): a notice keeps what to say and says it in the reader's language when shown 2026-10-29 22:37:14 +01:00
peter 6986eb1937 test(e2e): look for a hittable channel link until one is, not once on a fresh load 2026-10-29 22:37:14 +01:00
176 changed files with 3780 additions and 1035 deletions
+18
View File
@@ -7,6 +7,24 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.114.3 — 2026-11-01
### Fixed
- Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error.
## 0.114.2 — 2026-10-30
### Fixed
- Security: a library the sign-in relies on is updated, closing two published vulnerabilities.
## 0.114.1 — 2026-10-30
### Fixed
- Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too.
## 0.114.0 — 2026-10-29 ## 0.114.0 — 2026-10-29
### New ### New
+1 -1
View File
@@ -105,7 +105,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits | | Ownership check | `userscope.owned_by` (a filter) / `owned_row` (a row by id): a unit's `queries.own_x` returns None and its route answers `not_found`; a core route `owned_or_404`. A composite rule (own or shared, built-in or own) says so in its `queries` | an inline owner filter — `Model.user_id == …`, an alias's, `.in_()`, `filter_by(user_id=…)` (fence-held, every module) — or a `db.get` and a compare where `owned_row` fits |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export | | Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | | Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | | User-facing error | structured `detail` (`{code, reason}`), its sentence named in `lib/api/refusalCodes.ts` (contract-held) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` | | Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) | | Transaction commit | the handler commits; a unit's `service` and every helper `flush` (fence-held, every module) | a helper that commits — only a listed one (what owns its session, or a core step that must land whatever its caller does next), each with its reason: the fence's `UNIT_COMMITS` / `CORE_COMMITS`, a route's `COMMITTING_HELPERS` (`test_route_conventions`) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) | | Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
+1 -1
View File
@@ -1 +1 @@
0.114.0 0.114.3
+18 -2
View File
@@ -22,6 +22,7 @@ import json
import math import math
import re import re
from pathlib import Path from pathlib import Path
from typing import Literal
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
from app.plex.sync import HDR_TRANSFERS from app.plex.sync import HDR_TRANSFERS
@@ -57,12 +58,27 @@ _CROP_ASPECTS = (0.25, 4.0)
_EDGE_SLACK = 1e-3 _EDGE_SLACK = 1e-3
# Every reason a clip is refused; the UI names each one (`clip.errors.<reason>`, held by the frontend's
# backendIds contract, which reads this Literal).
ClipReason = Literal[
"bad_source",
"bad_range",
"too_short",
"too_long",
"bad_audio",
"bad_crop",
"not_found",
"media_missing",
"past_end",
]
class ClipError(Exception): class ClipError(Exception):
"""A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its """A clip request that cannot be cut; `reason` is an i18n-mappable key, `params` the numbers its
message names (the cap itself, so the client never says a stale one).""" message names (the cap itself, so the client never says a stale one)."""
def __init__(self, reason: str, **params: float): def __init__(self, reason: ClipReason, **params: float):
self.reason = reason self.reason: ClipReason = reason
self.params = params self.params = params
super().__init__(reason) super().__init__(reason)
+10
View File
@@ -25,6 +25,7 @@ Security controls enforced here:
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -163,9 +164,18 @@ def get_credential(db: Session, user_id: int, host_key: str) -> DownloadCredenti
).scalar_one_or_none() ).scalar_one_or_none()
# Every reason a pasted cookies.txt is refused; the UI names each one (`downloads.errors.cookies.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
CookieReason = Literal["empty", "format"]
class InvalidCookies(ValueError): class InvalidCookies(ValueError):
"""The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route.""" """The pasted cookies aren't a usable Netscape cookies.txt — surfaced as a 400 by the route."""
def __init__(self, reason: CookieReason):
super().__init__(reason)
self.reason: CookieReason = reason
def validate_cookies(raw: str) -> str: def validate_cookies(raw: str) -> str:
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads """Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
+13 -2
View File
@@ -24,6 +24,7 @@ kept it — and its `ready_at`, the moment it finished (`stamp_done`).
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import StrEnum from enum import StrEnum
from typing import Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from sqlalchemy import select from sqlalchemy import select
@@ -181,12 +182,22 @@ def stamp_done(db: Session, job: DownloadJob) -> None:
job.expiry_warned = False job.expiry_warned = False
# Every folder change the rules refuse, answered as a generic refusal; the UI names each one
# (`errors.refused.<reason>`, held by the frontend's backendIds contract, which reads this Literal).
FolderReason = Literal[
"file_folder_gone",
"file_folder_name_taken",
"file_folder_not_its_source",
"file_folder_not_own",
]
class FolderError(Exception): class FolderError(Exception):
"""A folder change the rules refuse; `reason` is the refusal the route answers with.""" """A folder change the rules refuse; `reason` is the refusal the route answers with."""
def __init__(self, reason: str) -> None: def __init__(self, reason: FolderReason) -> None:
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: FolderReason = reason
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder: def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
+10 -4
View File
@@ -20,6 +20,7 @@ their output's real size before it is placed. Edit jobs do not consult it at all
""" """
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -50,11 +51,16 @@ class QuotaLimits:
unlimited: bool unlimited: bool
class QuotaExceeded(Exception): # Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client).""" # frontend's backendIds contract, which reads this Literal).
QuotaReason = Literal["max_jobs", "max_bytes"]
def __init__(self, reason: str, limit: int, current: int):
self.reason = reason class QuotaExceeded(Exception):
"""Raised by check_enqueue; `reason` names the cap it hit (an i18n key on the client)."""
def __init__(self, reason: QuotaReason, limit: int, current: int):
self.reason: QuotaReason = reason
self.limit = limit self.limit = limit
self.current = current self.current = current
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})") super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
+10 -4
View File
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal
from sqlalchemy import delete as delete_rows from sqlalchemy import delete as delete_rows
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
@@ -39,11 +40,16 @@ from app.plex import paths as plex_paths
from app.userscope import owned_by, owned_row from app.userscope import owned_by, owned_row
class EditError(Exception): # Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key.""" # the frontend's backendIds contract, which reads this Literal).
EditReason = Literal["source_not_ready", "not_media", "empty_edit"]
def __init__(self, reason: str):
self.reason = reason class EditError(Exception):
"""Bad edit request (empty spec / source not ready / not media); `reason` names the sentence."""
def __init__(self, reason: EditReason):
self.reason: EditReason = reason
super().__init__(reason) super().__init__(reason)
+17 -8
View File
@@ -33,24 +33,33 @@ user-controlled destination to guard.
import ipaddress import ipaddress
import socket import socket
from typing import Literal
from urllib.parse import urlparse from urllib.parse import urlparse
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API # Reason codes travel to the client inside the structured 422 detail, so they are part of the API
# vocabulary (frontend `localizeDetail`) — keep them stable. # vocabulary — keep them stable. The UI names each one (`downloads.errors.urlBlocked.<reason>`, held by
REASON_SCHEME = "scheme" # not http(s) # the frontend's backendIds contract, which reads this Literal).
REASON_NO_HOST = "no_host" # URL carries no hostname UrlBlockedReason = Literal["scheme", "no_host", "malformed", "unresolvable", "private"]
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it REASON_SCHEME: UrlBlockedReason = "scheme" # not http(s)
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked") REASON_NO_HOST: UrlBlockedReason = "no_host" # URL carries no hostname
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from REASON_MALFORMED: UrlBlockedReason = (
"malformed" # unparseable authority or port; urlparse raises late
)
REASON_UNRESOLVABLE: UrlBlockedReason = (
"unresolvable" # DNS said nothing (not the same as "blocked")
)
REASON_PRIVATE: UrlBlockedReason = (
"private" # resolves into a network the server must not fetch from
)
class BlockedUrl(Exception): class BlockedUrl(Exception):
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the """A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
REASON_* codes above.""" REASON_* codes above."""
def __init__(self, reason: str): def __init__(self, reason: UrlBlockedReason):
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: UrlBlockedReason = reason
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
+8 -3
View File
@@ -9,7 +9,7 @@ unique key, a row lock taken before a presence check. The comfort shelf's writes
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import cast from typing import Literal, cast
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -301,12 +301,17 @@ def set_progress(db: Session, user: User, video_id: str, position: int) -> None:
# --- playlists ------------------------------------------------------------------------------- # --- playlists -------------------------------------------------------------------------------
# Every reason a move is refused; the UI names each one (`playlists.moveErrors.<reason>`, held by the
# frontend's backendIds contract, which reads this Literal).
MoveReason = Literal["same_playlist", "not_in_playlist"]
class MoveRefused(Exception): class MoveRefused(Exception):
"""A move between playlists that cannot happen: `reason` is the code the UI translates.""" """A move between playlists that cannot happen: `reason` is the code the UI translates."""
def __init__(self, reason: str): def __init__(self, reason: MoveReason):
super().__init__(reason) super().__init__(reason)
self.reason = reason self.reason: MoveReason = reason
def touch(pl: Playlist) -> None: def touch(pl: Playlist) -> None:
+20 -10
View File
@@ -1,29 +1,39 @@
"""A request the app refuses, said in a way the UI can put in the reader's language. """A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI translates it (`lib/api/refusalText.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes (`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated. (`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail, A test (`test_http_details.py`) reads every place an `HTTPException` or a `JSONResponse` sends a detail
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both and fails on one the client cannot say: a sentence, a dict without a `code`, a variable, none at all.
languages — so the rule is enforced, not described. `tests/refusal_sites.py` reads every `{"code": …}` detail from the syntax tree, written where it is sent
(or returned by a factory that only ever is), with any `**` spread before its `code` and `reason`, and
follows each reason along a closed list of shapes: a literal, a `Literal` type on the parameter or
attribute it travels in, every call of a helper like `refused` below, a map's values; anything else is
reported, never guessed. The frontend's contract test
(`i18n/backendIds.contract.test.ts`) runs it and requires each code in one record of
`lib/api/refusalCodes.ts`, and each reason's sentence in both languages; a code or reason it cannot
follow fails it, unless no screen shows that code. So the rule is enforced, not described.
""" """
from fastapi import HTTPException from fastapi import HTTPException
from app.sysconfig import InvalidSetting, SettingReason
def refused(status: int, reason: str, **params: object) -> HTTPException: def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in """The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.""" the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params}) They are spread first, so a `code` or `reason` among them could never replace these two."""
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's # `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal
# contract test can read every reason this can produce. # scan reads every reason this can produce, and holds its keys to `SettingReason`.
_SETTING_REASONS = { _SETTING_REASONS: dict[SettingReason, str] = {
"not_a_number": "setting_not_a_number", "not_a_number": "setting_not_a_number",
"too_low": "setting_too_low", "too_low": "setting_too_low",
"too_high": "setting_too_high", "too_high": "setting_too_high",
@@ -32,6 +42,6 @@ _SETTING_REASONS = {
} }
def setting_refused(exc) -> HTTPException: def setting_refused(exc: InvalidSetting) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`).""" """A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params) return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
+19 -10
View File
@@ -976,7 +976,16 @@ def _resend_verify_if_eligible(email: str) -> None:
send_verification_link(db, user) send_verification_link(db, user)
def _login_refused(status: int, reason: str, user: User | None = None, **extra) -> HTTPException: # Every refusal a password sign-in answers with; the UI names each one (`welcome.auth.blocked.<reason>`,
# held by the frontend's backendIds contract, which reads this Literal).
LoginRefusal = Literal[
"rate_limited", "invalid", "suspended", "email_unverified", "pending_approval"
]
def _login_refused(
status: int, reason: LoginRefusal, user: User | None = None, **extra
) -> HTTPException:
"""Every refusal password_login can answer with, in the codebase's structured `{code, reason}` """Every refusal password_login can answer with, in the codebase's structured `{code, reason}`
shape so the SPA can say it in the user's own language (the old bare English strings were shape so the SPA can say it in the user's own language (the old bare English strings were
unreadable under a bilingual UI, and unusable as anything but a one-line error). unreadable under a bilingual UI, and unusable as anything but a one-line error).
@@ -992,15 +1001,15 @@ def _login_refused(status: int, reason: str, user: User | None = None, **extra)
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence. rather than assuming a sequence.
""" """
detail: dict = {"code": "login", "reason": reason} account = (
if user is not None: {"email": user.email, "email_verified": user.email_verified, "approved": user.is_active}
detail |= { if user is not None
"email": user.email, else {}
"email_verified": user.email_verified, )
"approved": user.is_active, given = {k: v for k, v in extra.items() if v is not None}
} return HTTPException(
detail |= {k: v for k, v in extra.items() if v is not None} status_code=status, detail={**account, **given, "code": "login", "reason": reason}
return HTTPException(status_code=status, detail=detail) )
@router.post("/password-login") @router.post("/password-login")
+4 -3
View File
@@ -208,7 +208,7 @@ def set_credential(
try: try:
cleaned = dlcreds.validate_cookies(body.cookies) cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e: except dlcreds.InvalidCookies as e:
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e raise HTTPException(status_code=400, detail={"code": "cookies", "reason": e.reason}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned) dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record( audit.record(
db, db,
@@ -359,8 +359,9 @@ def enqueue_clip(
job = service.enqueue_clip(db, user.id, spec, display_name=display_name) job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
except clipmod.ClipError as e: except clipmod.ClipError as e:
status = 404 if e.reason == "not_found" else 422 status = 404 if e.reason == "not_found" else 422
detail = {"code": "clip", "reason": e.reason, **e.params} raise HTTPException(
raise HTTPException(status_code=status, detail=detail) from e status_code=status, detail={**e.params, "code": "clip", "reason": e.reason}
) from e
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e raise HTTPException(status_code=422, detail=quota_detail(e)) from e
db.commit() db.commit()
+1 -1
View File
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
def quota_detail(e: quota.QuotaExceeded) -> dict: def quota_detail(e: quota.QuotaExceeded) -> dict:
"""Structured error the trilingual client localizes (reason key + numbers), instead of a """Structured error the trilingual client localizes (reason key + numbers), instead of a
pre-baked English string with a dot-decimal GB value. See lib/api.ts localizeDetail().""" pre-baked English string with a dot-decimal GB value. See lib/api/refusalText.ts localizeDetail()."""
return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current} return {"code": "quota", "reason": e.reason, "limit": e.limit, "current": e.current}
+17 -2
View File
@@ -3,7 +3,7 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope.""" out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -53,7 +53,22 @@ def _check_pull_cooldown(user: User) -> None:
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
def _refused(status: int, reason: str) -> HTTPException: # Every playlist action the server refuses; the UI names each one (`playlists.errors.<reason>`, held by
# the frontend's backendIds contract, which reads this Literal).
PlaylistRefusal = Literal[
"name_required",
"not_on_youtube",
"quota_exceeded",
"sync_all_busy",
"sync_busy",
"watch_later_not_pushable",
"watch_later_undeletable",
"youtube_editing_off",
"youtube_read_scope",
]
def _refused(status: int, reason: PlaylistRefusal) -> HTTPException:
"""A playlist action the server refuses, as a reason the UI translates """A playlist action the server refuses, as a reason the UI translates
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim.""" (`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason}) return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
+2 -1
View File
@@ -128,7 +128,8 @@ def update_view(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "view_name_taken", name=view.name) from None # The name asked for: the rollback has put the view's old name back on `view`.
raise refused(409, "view_name_taken", name=name) from None
return _serialize(view) return _serialize(view)
+2 -1
View File
@@ -107,7 +107,8 @@ def update_tag(
db.commit() db.commit()
except IntegrityError: except IntegrityError:
db.rollback() db.rollback()
raise refused(409, "tag_name_taken", name=tag.name) from None # The name asked for: the rollback has put the tag's old name back on `tag`.
raise refused(409, "tag_name_taken", name=name) from None
return { return {
"ok": True, "ok": True,
"id": tag.id, "id": tag.id,
+7 -2
View File
@@ -443,15 +443,20 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr) return getattr(settings, s.default_attr)
# Every reason `set_value` refuses with. `refusal.setting_refused` looks each one up in its map, which
# the refusal scan holds to exactly these keys: a reason the map lacks would answer a 500.
SettingReason = Literal["not_a_number", "too_low", "too_high", "not_a_choice", "no_encryption_key"]
class InvalidSetting(ValueError): class InvalidSetting(ValueError):
"""A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`) """A value `set_value` refuses, as a reason the UI translates (`errors.refused.setting_<reason>`)
plus what the sentence needs — the English message is for logs only. A ValueError still, so a plus what the sentence needs — the English message is for logs only. A ValueError still, so a
caller that only needs "refused or not" keeps working.""" caller that only needs "refused or not" keeps working."""
def __init__(self, key: str, reason: str, **params: object): def __init__(self, key: str, reason: SettingReason, **params: object):
super().__init__(f"{key}: {reason} {params}") super().__init__(f"{key}: {reason} {params}")
self.key = key self.key = key
self.reason = reason self.reason: SettingReason = reason
self.params = params self.params = params
+2 -2
View File
@@ -7,7 +7,7 @@
# `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19 # `pip install -r requirements.lock && pip check` must pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177); yt-dlp 2026.7.4 -> 2026.8.19
# (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403); # (it marks the clients that need a stream PO token, so the downloader stops fetching into a 403);
# certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322); # certifi 2026.7.22 -> 2026.10.19 (GHSA-6q48-6c7w-9gw2); brotli 1.2.0 -> 1.2.1 (PYSEC-2026-4322);
# Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616). # Authlib 1.7.2 -> 1.9.1 (CVE-2026-96760, CVE-2026-114616); joserfc 1.7.4 -> 1.7.6 (CVE-2026-119283, CVE-2026-119284).
APScheduler==3.11.3 APScheduler==3.11.3
Authlib==1.9.1 Authlib==1.9.1
Mako==1.4.1 Mako==1.4.1
@@ -39,7 +39,7 @@ httptools==0.8.0
httpx==0.28.1 httpx==0.28.1
idna==3.18 idna==3.18
itsdangerous==2.2.0 itsdangerous==2.2.0
joserfc==1.7.4 joserfc==1.7.6
markdown-it-py==4.2.0 markdown-it-py==4.2.0
mdurl==0.1.2 mdurl==0.1.2
mutagen==1.48.1 mutagen==1.48.1
+738
View File
@@ -0,0 +1,738 @@
"""Every refusal the backend can answer, read from its syntax tree: each `{"code": …, "reason": …}` detail,
with every reason its code can be sent with.
The frontend's contract (`i18n/backendIds.contract.test.ts`) runs this and requires a sentence for each
code and reason in both languages. A scan that tries to follow every shape opens a silent gap with each one
it learns, and following how a value flows through Python (variables, scopes, aliases, a dict changed in
place) never closes. So the CODE keeps to a narrow form, and this reads a CLOSED list of it, reporting
everything else, never guessing:
- where a detail stands: where it is sent — `HTTPException`'s second argument or `detail=`, the `"detail"`
value of a `JSONResponse`'s content dict — through the arms of `a if c else b`, or as what a module-level
factory returns when every return of it is a detail and every reference to it is a call sent as one. A
detail held in a variable is not followed;
- the code: one literal. A `**` spread, or a key that is not a literal, stands BEFORE a literal `code` and
`reason`, both of which the detail then carries: the later key wins, so no spread can change them;
- a reason: a string literal (`None` sends none: the site counts as reasonless); both arms of
`a if c else b`; a function's local bound only by plain `x = …`; a module constant reached as `mod.NAME`;
a call's `Literal` return annotation;
- a parameter typed with a `Literal` (basedpyright holds every value passed in), or a `str` one of a
module-level helper (`refused`, `not_found`), followed to every call, each of which must pass it by
position or by name; a parameter its body binds again is unread;
- an attribute: `e.x` of the exception an `except C as e` catches, or of a parameter typed `C`, when a
method of `C` declares `self.x: <Literal>`;
- a map lookup (`_SETTING_REASONS[exc.reason]`): the map's values, and its keys must be exactly what the
index can be.
A name bound twice at module level, inside a module-level `if`/`try`/loop, named by a `global`, or imported
from two places is not looked up; nor is a local a lambda or a comprehension binds, or one a `nonlocal` or
`global` names. A module constant or map that anything stores into, deletes from or (a map) uses but by
`map[k]` or a read method is unread. A reference named like a followed helper that the scan cannot resolve
(`import app.x`, a relative import, an app module that does not bind it) is unread. Anything else is UNREAD,
or an unread code, and the contract fails on it unless no screen shows that code. Stdlib only: it runs on
the host, beside the frontend's tests (`python -I backend/tests/refusal_sites.py` → JSON).
"""
import ast
import json
import pathlib
import sys
from collections.abc import Iterator
from dataclasses import dataclass, field
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
# Files whose `"code"` dicts are data, not refusals: a subtitle track's status payload, TMDB country codes.
NOT_REFUSALS = {
"plex/subtitles.py": "a subtitle track's status payload (its own `code`)",
"plex/tmdb.py": "a production country's ISO code",
}
# What a map may be used by besides `map[k]`: a method that only reads it.
READS = {"get", "items", "keys", "values"}
Node = ast.AST
FUNCS = (ast.FunctionDef, ast.AsyncFunctionDef)
SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
COMPREHENSIONS = (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp)
COMPOUND = (
ast.If,
ast.Try,
ast.TryStar,
ast.For,
ast.AsyncFor,
ast.While,
ast.With,
ast.AsyncWith,
ast.Match,
)
@dataclass
class Res:
values: set[str] = field(default_factory=set)
unread: list[str] = field(default_factory=list)
none: bool = False # an arm sends no reason
def __or__(self, other: "Res") -> "Res":
return Res(self.values | other.values, self.unread + other.unread, self.none or other.none)
@dataclass
class Module:
path: str # relative to app/, posix
name: str # dotted, `app.…`
tree: ast.Module
parents: dict[Node, Node]
# local name → (module, symbol or None for the module)
imports: dict[str, tuple[str, str | None]]
top: dict[str, Node] # module-level bindings: def, class, or the assignment
ambiguous: set[
str
] # bound more than once, conditionally, by a `global`, or imported from two places
slots: set[int] # the ids of the nodes sent as a detail
def _own_scope(node: Node) -> Iterator[Node]:
"""`node` and what it holds in its own scope: a nested def, class or lambda is itself, not its body."""
stack = [node]
while stack:
n = stack.pop()
yield n
if n is not node and isinstance(n, SCOPES):
continue
stack.extend(ast.iter_child_nodes(n))
def _stored(target: Node) -> set[str]:
"""The names an assignment target binds, not the ones it reads (`LABELS[k] = v` binds none)."""
return {
n.id for n in ast.walk(target) if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Store)
}
def _binds(node: Node) -> set[str]:
"""The names one node binds in the scope it sits in."""
if isinstance(node, ast.Assign):
return set().union(*(_stored(t) for t in node.targets))
if isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.For, ast.AsyncFor, ast.NamedExpr)):
return _stored(node.target)
if isinstance(node, (ast.With, ast.AsyncWith)):
return set().union(*(_stored(i.optional_vars) for i in node.items if i.optional_vars))
if isinstance(node, (ast.Import, ast.ImportFrom)):
return {(a.asname or a.name).split(".")[0] for a in node.names}
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return {node.name}
if isinstance(node, (ast.MatchAs, ast.MatchStar, ast.ExceptHandler)) and node.name:
return {node.name}
return set()
def _sender(call: ast.Call) -> str | None:
return getattr(call.func, "id", None) or getattr(call.func, "attr", None)
def detail_slots(tree: Node) -> Iterator[tuple[ast.Call, Node | None]]:
"""Every place a detail is sent from, with what it holds: `HTTPException`'s second argument or
`detail=` (None: sent without one), and the `"detail"` value of a `JSONResponse`'s content dict (a
content without one is an answer, not a refusal)."""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
kw = {k.arg: k.value for k in node.keywords}
if _sender(node) == "HTTPException":
yield node, kw.get("detail", node.args[1] if len(node.args) > 1 else None)
elif _sender(node) == "JSONResponse":
content = kw.get("content", node.args[0] if node.args else None)
if isinstance(content, ast.Dict):
for k, v in zip(content.keys, content.values, strict=True):
if isinstance(k, ast.Constant) and k.value == "detail":
yield node, v
def _code_dict(node: Node) -> bool:
"""A `{"code": …}` dict, or a `dict(code=…)` call."""
if isinstance(node, ast.Dict):
return any(isinstance(k, ast.Constant) and k.value == "code" for k in node.keys)
return (
isinstance(node, ast.Call)
and getattr(node.func, "id", None) == "dict"
and any(k.arg == "code" for k in node.keywords)
)
def _module_name(path: str) -> str:
parts = path.removesuffix(".py").split("/")
if parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(["app", *parts])
def _load(sources: dict[str, str]) -> dict[str, Module]:
mods: dict[str, Module] = {}
for path, src in sources.items():
tree = ast.parse(src)
parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)}
origins: dict[str, set[tuple[str, str | None]]] = {}
ambiguous: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
for a in node.names:
origins.setdefault(a.asname or a.name, set()).add((node.module, a.name))
elif isinstance(node, ast.Import):
for a in node.names:
if a.asname:
origins.setdefault(a.asname, set()).add((a.name, None))
elif isinstance(node, ast.Global):
ambiguous |= set(node.names)
top: dict[str, Node] = {}
count: dict[str, int] = {}
ambiguous |= {name for name, o in origins.items() if len(o) > 1}
for stmt in tree.body:
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
top[stmt.name] = stmt
elif isinstance(stmt, ast.Assign):
for t in stmt.targets:
if isinstance(t, ast.Name):
top[t.id] = stmt
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
top[stmt.target.id] = stmt
for n in [stmt] if isinstance(stmt, SCOPES) else _own_scope(stmt):
for name in _binds(n):
count[name] = count.get(name, 0) + 1
if isinstance(stmt, COMPOUND):
ambiguous.add(name)
ambiguous |= {name for name, c in count.items() if c > 1}
imports = {name: next(iter(o)) for name, o in origins.items() if name not in ambiguous}
slots = {id(d) for _, d in detail_slots(tree) if d is not None}
name = _module_name(path)
mods[name] = Module(path, name, tree, parents, imports, top, ambiguous, slots)
return mods
class Scan:
def __init__(self, sources: dict[str, str]):
self.mods = _load(sources)
self.errors: list[str] = []
self._following: set[tuple[str, str, str]] = set()
self._followed: dict[tuple[str, str, str], Res] = {}
self._resolving: set[int] = set()
self._factory: dict[int, bool] = {}
self._changed: dict[int, bool] = {}
# --- names ---------------------------------------------------------------------------------------
def lookup(
self, m: Module, name: str, seen: frozenset[tuple[str, str]] = frozenset()
) -> tuple[Module, Node] | tuple[str, None] | None:
"""What a module-level name is: (its module, its binding), or (a module's name, None)."""
if name in m.ambiguous:
return None
if name in m.top:
return m, m.top[name]
if name in m.imports:
mod, sym = m.imports[name]
if sym is None:
return (mod, None) if mod in self.mods else None
if f"{mod}.{sym}" in self.mods:
return f"{mod}.{sym}", None
if mod in self.mods and (mod, sym) not in seen:
return self.lookup(self.mods[mod], sym, seen | {(mod, sym)})
return None
def lookup_expr(self, m: Module, e: Node) -> tuple[Module, Node] | tuple[str, None] | None:
if isinstance(e, ast.Name):
return None if self.binder(m, e) is not None else self.lookup(m, e.id)
if isinstance(e, ast.Attribute):
base = self.lookup_expr(m, e.value)
if base and base[1] is None and isinstance(base[0], str):
return self.lookup(self.mods[base[0]], e.attr)
return None
def binder(self, m: Module, e: ast.Name) -> Node | None:
"""The innermost scope `e` is bound in: a function (a parameter, or a binding in its own body), a
lambda (a parameter), a comprehension (its target); None: a module-level name."""
node = m.parents.get(e)
while node is not None:
if isinstance(node, (*FUNCS, ast.Lambda)):
a = node.args
params = [*a.posonlyargs, *a.args, *a.kwonlyargs, a.vararg, a.kwarg]
if e.id in {p.arg for p in params if p}:
return node
if isinstance(node, FUNCS) and self.assignments(node, e.id):
return node
elif isinstance(node, COMPREHENSIONS) and any(
e.id in _stored(g.target) for g in node.generators
):
return node
node = m.parents.get(node)
return None
def at(self, m: Module, e: Node) -> str:
return f"{m.path}:{getattr(e, 'lineno', '?')}: {ast.unparse(e)}"
def enclosing(self, m: Module, e: Node, kind: type | tuple[type, ...]) -> Node | None:
node = m.parents.get(e)
while node is not None and not isinstance(node, kind):
node = m.parents.get(node)
return node
def assignments(self, fn: Node, name: str) -> list[Node]:
"""What binds `name` in this function's own body (not in a nested one)."""
return [n for n in _own_scope(fn) if n is not fn and name in _binds(n)]
def caught_as(self, m: Module, e: Node, name: str) -> ast.ExceptHandler | None:
node = m.parents.get(e)
while node is not None and not isinstance(node, FUNCS):
if isinstance(node, ast.ExceptHandler) and node.name == name:
return node
node = m.parents.get(node)
return None
def references(
self, m: Module, target: Node
) -> Iterator[tuple[Module, Node, ast.Call | None, bool]]:
"""Every reference to a module-level def or class: the call it makes (None: not a call), and
whether it resolved. A reference named like it that the scan cannot resolve is yielded too."""
name = getattr(target, "name", None)
for cm in self.mods.values():
for node in ast.walk(cm.tree):
if not isinstance(node, (ast.Name, ast.Attribute)) or not isinstance(
node.ctx, ast.Load
):
continue
if (node.id if isinstance(node, ast.Name) else node.attr) != name:
continue
parent = cm.parents.get(node)
if isinstance(parent, ast.Attribute) and parent.value is node:
continue
found = self.lookup_expr(cm, node)
if found:
if not (found[0] is m and found[1] is target):
continue
elif not self.unresolved(cm, node):
continue
call = parent if isinstance(parent, ast.Call) and parent.func is node else None
yield cm, node, call, found is not None
def unresolved(self, m: Module, node: Node) -> bool:
"""A name, or a dotted path, the app could mean that the scan cannot resolve: not a local, nor an
outside module's (an attribute of an object the function holds is not one)."""
root = node
while isinstance(root, ast.Attribute):
root = root.value
if not isinstance(root, ast.Name) or self.binder(m, root) is not None:
return False
if root.id in m.ambiguous or (root.id not in m.top and root.id not in m.imports):
return True
if root.id in m.top:
return False
if root is node: # imported from an app module that does not bind it where the scan can see
mod = m.imports[root.id][0]
return (mod == "app" or mod.startswith("app.")) and self.lookup(m, root.id) is None
found = self.lookup(m, root.id)
return (
found is not None and found[1] is None
) # an app module whose dotted path went nowhere
def changed(self, m: Module, stmt: Node, name: str, *, only_read: bool) -> bool:
"""Something in the app stores into, deletes or rebinds the module-level `name` that `stmt` binds,
or (`only_read`, a map) uses it but by `map[k]` or a method that only reads it."""
key = id(stmt) * 2 + only_read
if key in self._changed:
return self._changed[key]
own = stmt.targets if isinstance(stmt, ast.Assign) else [getattr(stmt, "target", None)]
out = False
for cm in self.mods.values():
for node in ast.walk(cm.tree):
if out:
break
if not (
isinstance(node, ast.Name)
and node.id == name
or isinstance(node, ast.Attribute)
and node.attr == name
) or (cm is m and node in own):
continue
found = self.lookup_expr(cm, node)
if not (found and found[0] is m and found[1] is stmt):
continue
if not isinstance(node.ctx, ast.Load):
out = True
elif only_read:
p = cm.parents.get(node)
out = not (
isinstance(p, ast.Subscript)
and p.value is node
and isinstance(p.ctx, ast.Load)
or isinstance(p, ast.Attribute)
and p.attr in READS
)
self._changed[key] = out
return out
# --- types ---------------------------------------------------------------------------------------
def ann_values(self, m: Module, ann: Node | None, depth: int = 0) -> set[str] | None:
"""The values a `Literal[…]` annotation, or an alias of one, allows; None when it is not one."""
if isinstance(ann, ast.Subscript) and getattr(ann.value, "id", None) == "Literal":
items = ann.slice.elts if isinstance(ann.slice, ast.Tuple) else [ann.slice]
if all(isinstance(i, ast.Constant) and isinstance(i.value, str) for i in items):
return {i.value for i in items}
return None
if isinstance(ann, (ast.Name, ast.Attribute)) and depth < 8:
found = self.lookup_expr(m, ann)
if found and isinstance(found[0], Module) and isinstance(found[1], ast.Assign):
return self.ann_values(found[0], found[1].value, depth + 1)
return None
def classes(self, m: Module, e: Node | None) -> list[tuple[Module, ast.ClassDef]] | None:
found = self.lookup_expr(m, e) if e is not None else None
if found and isinstance(found[0], Module) and isinstance(found[1], ast.ClassDef):
return [(found[0], found[1])]
return None
def typed(self, m: Module, at: Node, name: str) -> list[tuple[Module, ast.ClassDef]] | None:
"""The class `name` holds where `at` sits: the one an `except C as name` catches, or a parameter's
annotation (a parameter its body binds again holds no known class)."""
handler = self.caught_as(m, at, name)
if handler is not None:
return self.classes(m, handler.type)
fn = self.enclosing(m, at, FUNCS)
if fn is None:
return None
params = {a.arg: a for a in [*fn.args.posonlyargs, *fn.args.args, *fn.args.kwonlyargs]}
if name in params and not self.assignments(fn, name):
return self.classes(m, params[name].annotation)
return None
def attr_values(self, cm: Module, cls: ast.ClassDef, attr: str) -> set[str] | None:
"""An attribute's `Literal` values, from `self.attr: T` in a method of the class."""
for node in ast.walk(cls):
if (
isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Attribute)
and node.target.attr == attr
and getattr(node.target.value, "id", None) == "self"
):
return self.ann_values(cm, node.annotation)
return None
# --- reasons -------------------------------------------------------------------------------------
def resolve(self, m: Module, e: Node) -> Res:
if isinstance(e, ast.Constant):
if e.value is None:
return Res(none=True)
if isinstance(e.value, str):
return Res({e.value})
if isinstance(e, ast.IfExp):
return self.resolve(m, e.body) | self.resolve(m, e.orelse)
if isinstance(e, ast.Name):
return self.resolve_name(m, e)
if isinstance(e, ast.Attribute):
return self.resolve_attr(m, e)
if isinstance(e, ast.Call):
found = self.lookup_expr(m, e.func)
if found and isinstance(found[0], Module) and isinstance(found[1], FUNCS):
v = self.ann_values(found[0], found[1].returns)
if v is not None:
return Res(v)
if isinstance(e, ast.Subscript):
return self.resolve_lookup(m, e)
return Res(unread=[self.at(m, e)])
def resolve_name(self, m: Module, e: ast.Name) -> Res:
if self.caught_as(m, e, e.id) is not None:
return Res(unread=[self.at(m, e)]) # the exception itself, not a reason
fn = self.binder(m, e)
if fn is None:
return Res(unread=[self.at(m, e)])
if not isinstance(fn, FUNCS):
return Res(unread=[f"{self.at(m, e)} (bound by a lambda or a comprehension)"])
if any(isinstance(n, (ast.Nonlocal, ast.Global)) and e.id in n.names for n in ast.walk(fn)):
return Res(unread=[f"{self.at(m, e)} (named by a nonlocal or a global)"])
a = fn.args
params = {p.arg: p for p in [*a.posonlyargs, *a.args, *a.kwonlyargs]}
assigned = self.assignments(fn, e.id)
if e.id in params:
if assigned:
return Res(unread=[f"{self.at(m, e)} (a parameter its body binds again)"])
return self.resolve_param(m, fn, params[e.id], e)
out = Res(unread=[] if assigned else [self.at(m, e)]) # `*args`, `**kwargs`
for s in assigned:
if not (isinstance(s, ast.Assign) and all(isinstance(t, ast.Name) for t in s.targets)):
out = out | Res(unread=[self.at(m, e)])
elif id(s) in self._resolving:
out = out | Res(unread=[f"{self.at(m, e)} (reached again while it is resolved)"])
else:
self._resolving.add(id(s))
out = out | self.resolve(m, s.value)
self._resolving.discard(id(s))
return out
def resolve_param(self, m: Module, fn: Node, param: ast.arg, e: Node) -> Res:
v = self.ann_values(m, param.annotation)
if v is not None:
return Res(v)
if param.annotation is not None and getattr(param.annotation, "id", None) != "str":
return Res(unread=[self.at(m, e)])
return self.follow(m, fn, param)
def follow(self, m: Module, fn: Node, param: ast.arg) -> Res:
"""A `str` parameter of a helper: what every call of the helper passes it."""
assert isinstance(fn, FUNCS)
key = (m.name, fn.name, param.arg)
if key in self._followed:
return self._followed[key]
where = f"{m.path}:{fn.lineno}: {fn.name}({param.arg})"
if key in self._following:
return Res(unread=[f"{where} reached again while it is followed"])
if m.top.get(fn.name) is not fn or fn.name in m.ambiguous:
return Res(unread=[f"{where} is not a module-level helper"])
self._following.add(key)
positional = [a.arg for a in [*fn.args.posonlyargs, *fn.args.args]]
by_name = param not in fn.args.posonlyargs
out = Res()
for cm, node, call, resolved in self.references(m, fn):
if not resolved:
out = out | Res(
unread=[f"{self.at(cm, node)} (a reference the scan cannot resolve)"]
)
continue
if call is None:
out = out | Res(unread=[f"{self.at(cm, node)} (the helper used as a value)"])
continue
kw = {k.arg: k.value for k in call.keywords}
given = param.arg in positional and positional.index(param.arg) < len(call.args)
if by_name and param.arg in kw:
out = out | self.resolve(cm, kw[param.arg])
elif any(isinstance(a, ast.Starred) for a in call.args):
out = out | Res(unread=[self.at(cm, call)])
elif given:
out = out | self.resolve(cm, call.args[positional.index(param.arg)])
else:
out = out | Res(unread=[f"{self.at(cm, call)} (does not pass {param.arg})"])
self._followed[key] = out # once followed, its key answers from here: never reached again
return out
def resolve_attr(self, m: Module, e: ast.Attribute) -> Res:
base = e.value
if isinstance(base, ast.Name):
typed = self.typed(m, e, base.id)
if typed is not None:
out = Res()
for cm, cls in typed:
v = self.attr_values(cm, cls, e.attr)
out = out | (
Res(v)
if v is not None
else Res(
unread=[f"{self.at(m, e)} ({cls.name}.{e.attr} has no Literal type)"]
)
)
return out
found = self.lookup_expr(m, e)
if (
found
and isinstance(found[0], Module)
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
and isinstance(found[1].value, ast.Constant)
):
if self.changed(found[0], found[1], e.attr, only_read=False):
return Res(unread=[f"{self.at(m, e)} (bound again or deleted elsewhere)"])
return self.resolve(found[0], found[1].value)
return Res(unread=[self.at(m, e)])
def resolve_lookup(self, m: Module, e: ast.Subscript) -> Res:
found = self.lookup_expr(m, e.value)
if not (
found
and isinstance(found[0], Module)
and isinstance(found[1], (ast.Assign, ast.AnnAssign))
):
return Res(unread=[self.at(m, e)])
table = found[1].value
if not isinstance(table, ast.Dict) or not all(
isinstance(k, ast.Constant) and isinstance(k.value, str) for k in table.keys
):
return Res(unread=[self.at(m, e)])
name = e.value.attr if isinstance(e.value, ast.Attribute) else getattr(e.value, "id", "")
if self.changed(found[0], found[1], name, only_read=True):
return Res(unread=[f"{self.at(m, e)} (the map is changed after it is built)"])
out = Res()
for v in table.values:
out = out | self.resolve(found[0], v)
index = self.resolve(m, e.slice)
keys = {k.value for k in table.keys if isinstance(k, ast.Constant)}
if index.unread or index.none or index.values != keys:
self.errors.append(
f"{self.at(m, e)}: the map's keys {sorted(keys)} are not what its index can be "
f"{sorted(index.values)}{' + unread ' + str(index.unread) if index.unread else ''}"
)
return out
# --- sites ---------------------------------------------------------------------------------------
def is_detail(self, node: Node | None) -> bool:
if isinstance(node, ast.IfExp):
return self.is_detail(node.body) and self.is_detail(node.orelse)
return _code_dict(node)
def factory(self, m: Module, fn: Node) -> bool:
"""A module-level function each return of which is a detail, and each reference to which is a
call sent as one (`quota_detail`)."""
assert isinstance(fn, FUNCS)
if id(fn) in self._factory:
return self._factory[id(fn)]
returns = [n for n in _own_scope(fn) if isinstance(n, ast.Return)]
ok = (
m.top.get(fn.name) is fn # a name bound twice: its calls are unresolved below
and bool(returns)
and all(self.is_detail(r.value) for r in returns)
)
for cm, _, call, resolved in self.references(m, fn) if ok else []:
ok = resolved and id(call) in cm.slots # a bare reference is no call: never sent
if not ok:
break
self._factory[id(fn)] = ok
return ok
def placed(self, m: Module, d: Node) -> bool:
"""`d` is sent as a detail: from a sender's slot, through the arms of `a if c else b`, or as what a
factory returns."""
holder, p = d, m.parents.get(d)
while isinstance(p, ast.IfExp) and holder is not p.test:
holder, p = p, m.parents.get(p)
if id(holder) in m.slots:
return True
fn = self.enclosing(m, p, FUNCS) if isinstance(p, ast.Return) else None
return fn is not None and self.factory(m, fn)
def sayable(self, m: Module, d: Node | None) -> bool:
"""What a slot holds is a detail the client can say: a code dict, or a factory's call."""
if isinstance(d, ast.IfExp):
return self.sayable(m, d.body) and self.sayable(m, d.orelse)
if _code_dict(d):
return True
found = self.lookup_expr(m, d.func) if isinstance(d, ast.Call) else None
return bool(
found
and isinstance(found[0], Module)
and isinstance(found[1], FUNCS)
and self.factory(found[0], found[1])
)
def entries(self, d: Node) -> list[tuple[str | None, Node]]:
"""A dict's (key, value) pairs: None for a `**` spread, "?" for a key that is not a literal."""
if isinstance(d, ast.Dict):
return [
(
k.value
if isinstance(k, ast.Constant) and isinstance(k.value, str)
else None
if k is None
else "?",
v,
)
for k, v in zip(d.keys, d.values, strict=True)
]
assert isinstance(d, ast.Call)
return [(None, a) for a in d.args] + [(k.arg, k.value) for k in d.keywords]
def site(self, m: Module, d: Node, out: dict) -> None:
items = self.entries(d)
code = Res()
for v in (v for k, v in items if k == "code"):
code = code | self.resolve(m, v)
problems = [
f"{self.at(m, v)} (a spread in dict())"
for k, v in items
if k is None and isinstance(d, ast.Call)
] + [
f"{self.at(m, v)} (a spread or a computed key the code and the reason do not follow)"
for i, (k, v) in enumerate(items)
if k in (None, "?") and not {"code", "reason"} <= {k2 for k2, _ in items[i + 1 :]}
]
if not self.placed(m, d):
problems.append(f"{self.at(m, d)} (a detail put where the scan cannot follow it)")
if code.unread or code.none or len(code.values) != 1 or problems:
out["unreadCodes"] += problems or [self.at(m, d)]
return
entry = out["codes"].setdefault(
next(iter(code.values)), {"reasons": set(), "unread": [], "sites": 0, "reasonless": []}
)
entry["sites"] += 1
res, has_reason = Res(), False
for k, v in items:
if k == "reason":
res, has_reason = res | self.resolve(m, v), True
if not has_reason or res.none:
entry["reasonless"].append(f"{m.path}:{d.lineno}")
entry["reasons"] |= res.values
entry["unread"] += res.unread
def other_details(self) -> list[tuple[str, int, str | None]]:
"""Each slot that sends what the client cannot say — nothing, or anything but a code dict or a
factory's call: (its file, its line, the function it is sent from)."""
out = []
for m in self.mods.values():
for call, d in detail_slots(m.tree):
if self.sayable(m, d):
continue
fn = self.enclosing(m, call, FUNCS)
out.append((m.path, call.lineno, getattr(fn, "name", None)))
return sorted(out)
def run(self) -> dict:
out: dict = {
"codes": {},
"unreadCodes": [],
"errors": self.errors,
"excluded": {},
"aliases": {},
"notRefusals": sorted(NOT_REFUSALS),
}
for m in self.mods.values():
for name, stmt in m.top.items():
if (
isinstance(stmt, ast.Assign)
and isinstance(stmt.value, ast.Subscript)
and (v := self.ann_values(m, stmt.value))
):
out["aliases"].setdefault(m.path, {})[name] = sorted(v)
for node in ast.walk(m.tree):
if not _code_dict(node):
continue
if m.path in NOT_REFUSALS:
out["excluded"][m.path] = out["excluded"].get(m.path, 0) + 1
continue
self.site(m, node, out)
for entry in out["codes"].values():
entry["reasons"] = sorted(entry["reasons"])
return out
def scan(sources: dict[str, str]) -> dict:
return Scan(sources).run()
def app_sources(app: pathlib.Path = APP) -> dict[str, str]:
return {
p.relative_to(app).as_posix(): p.read_text(encoding="utf-8")
for p in sorted(app.rglob("*.py"))
if "__pycache__" not in p.parts
}
def scan_app(app: pathlib.Path = APP) -> dict:
return scan(app_sources(app))
if __name__ == "__main__":
json.dump(
scan_app(pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else APP),
sys.stdout,
indent=1,
sort_keys=True,
)
+72 -37
View File
@@ -1,53 +1,88 @@
"""No refusal answers in a hand-written English sentence. """No refusal answers in a way the client cannot say.
The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it The UI is bilingual and translates a structured `detail` (`{code, reason, …}`), but a string detail it
can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error can only print as it came — so ~150 of them reached the Hungarian UI verbatim, in the blocking error
dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they dialog and on every inline surface that shows `HttpError.detail`. `app/refusal.py` is the family they
became; this test is what keeps a new one from being written the old way. Pure: it reads the sources. became; this test is what keeps a new one from being written the old way. It reads the same slots the
refusal scan does (`refusal_sites.detail_slots`), so the two cannot disagree on where a detail is sent.
Pure: it reads the sources.
""" """
import ast import textwrap
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app" from tests.refusal_sites import Scan, app_sources
# Answers that are no refusal of the app's, by the function they are sent from: the client shows a
# detail only when it is an object (`lib/api/core.ts`), so these read as its generic line, and no screen
# needs more from them.
NO_REFUSAL = {
("main.py", "setup_gate"): "the 503 before setup: the setup screen is what the user sees",
("main.py", "spa_fallback"): "a bare 404 for an api path no route matches: no screen calls one",
("main.py", "_validation_error"): "FastAPI's own 422 list: a body the client itself got wrong",
("routes/diagnostics.py", "_parsed_events"): "the client-events beacon's 422: nothing shows it",
}
def _is_text(node: ast.AST) -> bool: def _offenders(sources: dict[str, str]) -> list[str]:
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`.""" return [
if isinstance(node, ast.Constant) and isinstance(node.value, str): f"{path}:{line}"
return True for path, line, fn in Scan(sources).other_details()
if isinstance(node, ast.JoinedStr): if (path, fn) not in NO_REFUSAL
return True ]
if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "str":
return True
if isinstance(node, ast.BoolOp):
return any(_is_text(v) for v in node.values)
if isinstance(node, ast.BinOp):
return _is_text(node.left) or _is_text(node.right)
return False
def test_no_http_exception_carries_a_sentence(): def test_every_detail_is_one_the_client_can_say():
offenders = [] offenders = _offenders(app_sources())
for path in sorted(APP.rglob("*.py")):
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
if not (
isinstance(node, ast.Call)
and getattr(node.func, "id", getattr(node.func, "attr", "")) == "HTTPException"
):
continue
kw = {k.arg: k.value for k in node.keywords}
detail = kw.get("detail", node.args[1] if len(node.args) > 1 else None)
if detail is not None and _is_text(detail):
offenders.append(f"{path.relative_to(APP).as_posix()}:{node.lineno}")
assert offenders == [], ( assert offenders == [], (
"a refusal with an English sentence for a detail — raise `refused(status, reason)` " "a refusal the client can only answer with its generic line — send `{code, reason}` "
f"(app/refusal.py) and name the reason in both locales: {offenders}" f"(`refused(status, reason)`), or list it in NO_REFUSAL with why: {offenders}"
) )
def test_the_scan_sees_a_sentence_when_there_is_one(): def test_the_detail_scan_sees_each_shape_the_client_cannot_say():
"""A guard that matches nothing is green over nothing.""" """A guard that matches nothing is green over nothing."""
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value src = textwrap.dedent(
assert _is_text(sample.keywords[1].value) """
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value) def f(x):
raise HTTPException(409, detail={"reason": "view_name_taken"})
def g(x):
raise HTTPException(409, dict(reason="taken"))
def h(x):
raise HTTPException(status_code=404)
def i(x):
return JSONResponse({"detail": "This instance isn't set up yet."}, status_code=503)
def j(x):
return JSONResponse(content={"detail": {"reason": "x"}}, status_code=409)
def k(x):
raise HTTPException(400, detail=f"bad {x}")
def l(x):
raise HTTPException(404, None)
def n(x, msg):
raise HTTPException(400, detail=msg)
def o(x):
raise HTTPException(400, detail=["a sentence"])
def p(x):
raise HTTPException(400, detail=describe(x))
def q(x):
raise HTTPException(400, detail={"code": "a"} if x else "text")
def r(x):
raise HTTPException(400, detail=nothing(x))
def nothing(x):
pass
def ok(x):
raise HTTPException(409, {"code": "refused", "reason": "x"})
raise HTTPException(409, detail=quota(x))
raise HTTPException(409, detail={"code": "a"} if x else {"code": "b"})
return JSONResponse({"detail": {"code": "too_large"}}, status_code=413)
return JSONResponse({"ok": True})
def quota(x):
return {"code": "quota", "reason": "files"}
def setup_gate(x):
return JSONResponse({"detail": "listed"}, status_code=503)
"""
)
lines = (3, 5, 7, 9, 11, 13, 15, 17, 19, 21, 23, 25)
assert _offenders({"main.py": src}) == [f"main.py:{n}" for n in lines]
# A file whose code dicts are data still answers its refusals in a way the client can say.
data = 'def f():\n raise HTTPException(400, "text")\n'
assert _offenders({"plex/tmdb.py": data}) == ["plex/tmdb.py:2"]
+46
View File
@@ -0,0 +1,46 @@
"""A rename onto a name already taken says WHICH name is taken: the one asked for, not the row's
old name (the rollback that follows the conflict puts the old name back on the row). DB lane.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import User
def _client(db, monkeypatch, override_dep):
u = User(email="taken@example.com")
db.add(u)
db.commit()
monkeypatch.setattr(
state, "_configured_cache", True
) # the request gate 503s an unconfigured app
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: u)
return TestClient(app)
def test_renaming_a_view_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
gaming = c.post("/api/saved-views", json={"name": "gaming", "filters": {}, "source": "feed"})
assert gaming.status_code == 200
hh = c.post("/api/saved-views", json={"name": "hh", "filters": {}, "source": "feed"}).json()
r = c.patch(f"/api/saved-views/{hh['id']}", json={"name": "gaming"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "view_name_taken", "name": "gaming"}
def test_renaming_a_tag_onto_a_taken_name_names_the_taken_one(db, monkeypatch, override_dep):
c = _client(db, monkeypatch, override_dep)
assert c.post("/api/tags", json={"name": "music"}).status_code == 200
news = c.post("/api/tags", json={"name": "news"}).json()
r = c.patch(f"/api/tags/{news['id']}", json={"name": "music"})
assert r.status_code == 409
assert r.json()["detail"] == {"code": "refused", "reason": "tag_name_taken", "name": "music"}
+770
View File
@@ -0,0 +1,770 @@
"""The refusal scan (`refusal_sites.py`) on small sources: each shape on its closed list is followed to its
values, and each exit from that list is reported, unread or as an unread code. A scan that silently drops a
shape is a guard green over nothing, so every exit is pinned here, not only the paths the app takes. Pure:
no app import, no database."""
import textwrap
from tests.refusal_sites import scan, scan_app
def _scan(**files: str) -> dict:
return scan(
{f"{name.replace('__', '/')}.py": textwrap.dedent(src) for name, src in files.items()}
)
def _code(result: dict, code: str) -> dict:
return result["codes"][code]
def _unread(result: dict, code: str) -> list[str]:
return [u.split(": ", 1)[1] for u in _code(result, code)["unread"]]
REFUSAL = """
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
return HTTPException(status_code=status, detail={**params, "code": "refused", "reason": reason})
"""
def test_a_literal_wherever_it_sits_in_the_dict():
r = _scan(
a="""
def f(x):
raise HTTPException(409, {"code": "clip", "limit_s": 5, "reason": "too_long"})
def g(x):
raise HTTPException(409, {"reason": "before", "code": "clip"})
def h(x):
raise HTTPException(409, {
"code": "clip", # a comment between
"reason": "after_comment",
})
def i(x):
raise HTTPException(409, detail=dict(code="clip", reason="by_call"))
"""
)
assert _code(r, "clip")["reasons"] == ["after_comment", "before", "by_call", "too_long"]
assert _code(r, "clip")["unread"] == []
def test_both_arms_of_a_conditional_and_a_none_arm_makes_the_site_reasonless():
r = _scan(
a="""
def f(x):
raise HTTPException(409, {"code": "edit", "reason": "a" if x else "b"})
def g(x):
raise HTTPException(409, {"code": "playlist", "reason": "lit" if x else None})
def h(x):
raise HTTPException(409, {"code": "busy", "reason": None})
"""
)
assert _code(r, "edit")["reasons"] == ["a", "b"] and _code(r, "edit")["reasonless"] == []
assert _code(r, "playlist")["reasons"] == ["lit"]
assert _code(r, "playlist")["reasonless"] == ["a.py:5"]
assert _code(r, "busy")["reasonless"] == ["a.py:7"]
def test_what_the_list_does_not_hold_is_unread():
r = _scan(
a="""
def f(x, svc):
raise HTTPException(409, {"code": "edit", "reason": x.why or "c"})
def g(svc):
raise HTTPException(409, {"code": "edit", "reason": svc.reason()})
def h():
raise HTTPException(409, {"code": "edit", "reason": 5})
def i(e):
raise HTTPException(409, {"code": "edit", "reason": f"x{e}"})
"""
)
assert _unread(r, "edit") == ["x.why or 'c'", "svc.reason()", "5", "f'x{e}'"]
assert _code(r, "edit")["reasons"] == []
def test_a_helper_is_followed_to_every_call_however_it_is_written():
r = _scan(
refusal=REFUSAL,
routes__a="""
from http import HTTPStatus
from app import refusal
from app.refusal import refused
def f(status):
raise refused(status, "by_name_status")
def g():
raise refused(HTTPStatus.CONFLICT, reason="by_keyword")
def h():
raise refusal.refused(409, "qualified")
def i(x):
raise refused(409, "a" if x else "second_arm")
""",
routes__b="""
from other.lib import refused
def f():
refused("an outside library's, not the app's")
""",
)
assert _code(r, "refused")["reasons"] == [
"a",
"by_keyword",
"by_name_status",
"qualified",
"second_arm",
]
assert _code(r, "refused")["unread"] == []
def test_a_call_the_helper_cannot_be_followed_through_is_unread():
r = _scan(
refusal=REFUSAL,
routes__a="""
from app.refusal import refused
def f(args, kw):
pick = refused
raise refused(*args)
def g(kw):
raise refused(409, **kw)
def h():
raise refused(409)
""",
routes__b="""
import app.refusal
def f():
raise app.refusal.refused(409, "dotted")
""",
routes__c="""
from ..refusal import refused
def f():
raise refused(409, "relative")
""",
)
unread = _unread(r, "refused")
assert len(unread) == 6
assert sum("the helper used as a value" in u for u in unread) == 1
assert sum("a reference the scan cannot resolve" in u for u in unread) == 2
assert sum("does not pass reason" in u for u in unread) == 2 # `**kw`, and nothing at all
assert any(u == "refused(*args)" for u in unread)
assert _code(r, "refused")["reasons"] == []
def test_a_default_is_not_followed_a_call_must_pass_the_reason():
r = _scan(
routes___common="""
def not_found(what: str = "generic"):
return HTTPException(404, {"code": "not_found", "reason": what})
def get_or_404(db, id_, *, what: str = "generic"):
raise not_found(what)
""",
routes__a="""
from app.routes._common import get_or_404, not_found
def f(db):
get_or_404(db, 1, what="user")
get_or_404(db, 2)
raise not_found("debug_session")
def g():
raise not_found()
""",
)
assert _code(r, "not_found")["reasons"] == ["debug_session", "user"]
assert [u.endswith("(does not pass what)") for u in _unread(r, "not_found")] == [True, True]
def test_a_positional_only_reason_passed_by_name_is_not_bound_to_it():
r = _scan(
a="""
def deny(reason: str, /, **params):
return HTTPException(409, {"code": "playlist", "reason": reason})
def f():
raise deny("by_position")
def g():
raise deny(reason="lands_in_params")
"""
)
assert _code(r, "playlist")["reasons"] == ["by_position"]
assert _unread(r, "playlist") == ["deny(reason='lands_in_params') (does not pass reason)"]
def test_a_helper_reaching_two_sites_gives_both_its_reasons_and_one_reached_again_is_unread():
r = _scan(
a="""
def deny(reason: str, admin):
if admin:
return HTTPException(409, {"code": "activate", "reason": reason})
return HTTPException(409, {"code": "playlist", "reason": reason})
def again(reason: str, n):
if n:
return again(reason, n - 1)
return HTTPException(409, {"code": "edit", "reason": reason})
def f():
raise deny("foo", True)
def g():
raise again("bar", 3)
"""
)
assert _code(r, "activate")["reasons"] == ["foo"]
assert _code(r, "playlist")["reasons"] == ["foo"]
assert _code(r, "edit")["reasons"] == ["bar"]
assert any("reached again while it is followed" in u for u in _unread(r, "edit"))
def test_a_parameter_its_body_binds_again_is_unread():
r = _scan(
a="""
from typing import Literal
def refused(status, reason: str = ""):
reason = reason or "fallback"
return HTTPException(status, {"code": "refused", "reason": reason})
def typed(reason: Literal["a"]):
if not reason:
reason = "b"
return HTTPException(409, {"code": "edit", "reason": reason})
def f():
raise refused(409, "x")
"""
)
assert _code(r, "refused")["reasons"] == []
assert _unread(r, "refused") == ["reason (a parameter its body binds again)"]
assert _unread(r, "edit") == ["reason (a parameter its body binds again)"]
def test_a_local_is_followed_only_through_plain_assignments():
r = _scan(
a="""
from typing import Literal
def f(x):
why = "first"
if x:
why = "second" if x > 1 else "third"
raise HTTPException(409, {"code": "edit", "reason": why})
def g(svc):
hit, why = svc.fetch()
raise HTTPException(409, {"code": "playlist", "reason": why})
def h(items):
for why in items:
pass
raise HTTPException(409, {"code": "playlist", "reason": why})
def i():
why: Literal["typed"] = pick()
raise HTTPException(409, {"code": "playlist", "reason": why})
def j(items):
LABELS[why] = "NotAReason"
raise HTTPException(409, {"code": "playlist", "reason": why})
def k(*why):
raise HTTPException(409, {"code": "playlist", "reason": why})
"""
)
assert _code(r, "edit")["reasons"] == ["first", "second", "third"]
assert _code(r, "playlist")["reasons"] == []
assert _unread(r, "playlist") == [
"why",
"why",
"why",
"why",
"why",
] # unpacked, a loop, annotated, unbound, `*args`
def test_a_call_answers_with_its_literal_return_type():
r = _scan(
a="""
from typing import Literal
def why(x) -> Literal["quota", "gone"]:
return "quota"
def how(x) -> str:
return "x"
def f(x):
raise HTTPException(409, {"code": "youtube", "reason": why(x)})
def g(x):
raise HTTPException(409, {"code": "youtube", "reason": how(x)})
"""
)
assert _code(r, "youtube")["reasons"] == ["gone", "quota"]
assert _unread(r, "youtube") == ["how(x)"]
def test_a_module_constant_is_read_only_as_mod_name_and_only_when_bound_once():
r = _scan(
urlguard="""
REASON_MALFORMED = "malformed"
TWICE = "a"
TWICE = "b"
if FLAG:
LATER = "c"
""",
routes__a="""
from app import urlguard
BARE = "bare"
def f():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.REASON_MALFORMED})
def g():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.TWICE})
def h():
raise HTTPException(409, {"code": "url_blocked", "reason": urlguard.LATER})
def i():
raise HTTPException(409, {"code": "url_blocked", "reason": BARE})
""",
)
assert _code(r, "url_blocked")["reasons"] == ["malformed"]
assert _unread(r, "url_blocked") == ["urlguard.TWICE", "urlguard.LATER", "BARE"]
def test_a_name_imported_from_two_places_or_under_an_if_is_not_looked_up():
r = _scan(
m1='R = Literal["one"]',
m2='R = Literal["two"]',
a="""
def f():
from app.m1 import R
def g():
from app.m2 import R
def h(reason: R):
return HTTPException(409, {"code": "edit", "reason": reason})
""",
b="""
if FLAG:
from app.m1 import R
def h(reason: R):
return HTTPException(409, {"code": "playlist", "reason": reason})
""",
c="""
from app.m1 import R
def h(reason: R):
return HTTPException(409, {"code": "clip", "reason": reason})
""",
)
assert _code(r, "edit")["reasons"] == [] and _unread(r, "edit") == ["reason"]
assert _code(r, "playlist")["reasons"] == [] and _unread(r, "playlist") == ["reason"]
assert _code(r, "clip")["reasons"] == ["one"]
def test_a_caught_exception_answers_with_the_literal_its_class_declares_on_self():
r = _scan(
errors="""
from typing import Literal
ClipReason = Literal["bad_range", "too_short"]
EditReason = Literal["not_media"]
class ClipError(Exception):
def __init__(self, reason: ClipReason, **params: float):
self.reason: ClipReason = reason
self.params = params
class EditError(Exception):
reason: EditReason
class Untyped(Exception):
def __init__(self, reason: str):
self.reason = reason
class Narrower(ClipError):
pass
""",
routes__a="""
from app import errors
def f():
try:
pass
except errors.ClipError as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except (errors.ClipError, errors.EditError) as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.EditError as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.Untyped as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.Narrower as e:
raise HTTPException(422, {"code": "clip", "reason": e.reason})
except errors.ClipError as e:
raise HTTPException(422, {"code": "clip", "reason": e})
""",
)
assert _code(r, "clip")["reasons"] == ["bad_range", "too_short"]
assert _unread(r, "clip") == [
"e.reason", # a tuple of classes
"e.reason (EditError.reason has no Literal type)", # declared on the class body only
"e.reason (Untyped.reason has no Literal type)",
"e.reason (Narrower.reason has no Literal type)", # a subclass: not looked up the bases
"e", # the exception itself
]
def test_a_parameter_answers_with_its_literal_or_its_class_and_any_other_type_is_unread():
r = _scan(
a="""
from typing import Literal
Why = Literal["one", "two"]
class Err(Exception):
def __init__(self, reason: Literal["three"]):
self.reason: Literal["three"] = reason
def typed(reason: Why):
return HTTPException(409, {"code": "playlist", "reason": reason})
def inline(reason: Literal["four"]):
return HTTPException(409, {"code": "playlist", "reason": reason})
def by_class(exc: Err):
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
def rebound(exc: Err):
exc = other()
return HTTPException(409, {"code": "playlist", "reason": exc.reason})
def optional(reason: Literal["five"] | None = None):
return HTTPException(409, {"code": "playlist", "reason": reason})
def quoted(reason: "Why"):
return HTTPException(409, {"code": "playlist", "reason": reason})
def number(reason: int):
return HTTPException(409, {"code": "playlist", "reason": reason})
class C:
def method(self, reason: str):
return HTTPException(409, {"code": "playlist", "reason": reason})
"""
)
assert _code(r, "playlist")["reasons"] == ["four", "one", "three", "two"]
unread = _unread(r, "playlist")
# a parameter bound again, `| None`, a quoted annotation, `int`
assert unread[:4] == ["exc.reason", "reason", "reason", "reason"]
assert "method(reason) is not a module-level helper" in unread[4]
assert len(unread) == 5
def test_a_str_helper_nobody_calls_sends_nothing():
r = _scan(
a="""
def helper(reason: str):
return HTTPException(409, {"code": "playlist", "reason": reason})
"""
)
assert _code(r, "playlist") == {"reasons": [], "unread": [], "sites": 1, "reasonless": []}
def test_a_map_lookup_is_its_values_and_its_keys_must_be_exactly_what_the_index_can_be():
src = """
from typing import Literal
SettingReason = Literal["too_low", "too_high"]
class InvalidSetting(ValueError):
def __init__(self, key: str, reason: SettingReason, **params: object):
self.reason: SettingReason = reason
self.params = params
_MAP: dict[SettingReason, str] = {{{keys}}}
def refused(status: int, reason: str, **params: object):
return HTTPException(status, {{**params, "code": "refused", "reason": reason}})
def setting_refused(exc: InvalidSetting):
return refused(400, _MAP[{index}], **exc.params)
"""
both = '"too_low": "setting_too_low", "too_high": "setting_too_high"'
whole = _scan(a=src.format(keys=both, index="exc.reason"))
assert _code(whole, "refused")["reasons"] == ["setting_too_high", "setting_too_low"]
assert whole["errors"] == [] and whole["unreadCodes"] == []
for keys, index in [
('"too_low": "setting_too_low"', "exc.reason"), # a key short
(both + ', "gone": "setting_gone"', "exc.reason"), # a key no index can be
(both, "exc.key"), # an index the scan cannot read
]:
r = _scan(a=src.format(keys=keys, index=index))
assert len(r["errors"]) == 1 and "not what its index can be" in r["errors"][0]
def test_a_lookup_in_anything_but_a_module_dict_of_literal_keys_is_unread():
r = _scan(
a="""
BUILT = make()
NUMBERED = {1: "one"}
def f(table, k):
raise HTTPException(409, {"code": "edit", "reason": table[k]})
def g(k):
raise HTTPException(409, {"code": "edit", "reason": BUILT[k]})
def h(k):
raise HTTPException(409, {"code": "edit", "reason": NUMBERED[k]})
"""
)
assert _unread(r, "edit") == ["table[k]", "BUILT[k]", "NUMBERED[k]"]
assert r["errors"] == []
def test_a_code_must_be_one_literal():
r = _scan(
a="""
BUSY = "busy"
def f(code):
raise HTTPException(409, {"code": "busy"})
def g(code):
raise HTTPException(409, {"code": code})
def h():
raise HTTPException(409, {"code": BUSY})
def i(x):
raise HTTPException(409, {"code": "a" if x else "b"})
def j(x):
raise HTTPException(409, {"code": "a" if x else None})
def k(key):
raise HTTPException(409, {"code": "edit", key: "late"})
"""
)
assert r["codes"]["busy"]["reasonless"] == ["a.py:4"]
assert set(r["codes"]) == {"busy"}
assert len(r["unreadCodes"]) == 5
assert r["unreadCodes"][-1].endswith(
"(a spread or a computed key the code and the reason do not follow)"
)
def test_a_spread_or_a_computed_key_stands_before_both_the_code_and_the_reason():
def unread_codes(detail: str) -> list[str]:
return _scan(a=f"def f(x, more, key):\n raise HTTPException(409, {detail})\n")[
"unreadCodes"
]
assert unread_codes('{**more, "code": "refused", "reason": "a"}') == []
assert unread_codes('{key: 1, **more, "reason": "a", "code": "refused"}') == []
for late in [
'{"code": "refused", "reason": "a", **more}', # the spread may replace both
'{"code": "refused", **more, "reason": "a"}', # … the code
'{"reason": "a", **more, "code": "refused"}', # … the reason
'{**more, "code": "refused"}', # no reason after it: the spread may carry one
'{"code": "refused", "reason": "a", key: "x"}', # a key that may be either
'dict(**more, code="refused", reason="a")', # dict() raises on a repeated key
'dict(more, code="refused", reason="a")',
]:
assert unread_codes(late), late
def test_a_detail_stands_only_where_it_is_sent():
def result(body: str, extra: str = "") -> dict:
return _scan(a=f"def f(x, cached, fix):\n{textwrap.indent(body, ' ')}\n{extra}")
for body in [
'detail = {"code": "login", "reason": "a"}\nraise HTTPException(401, detail)',
'if (detail := {"code": "login", "reason": "a"}):\n raise HTTPException(401, detail)',
'raise HTTPException(401, {"code": "login", "reason": "a"}.copy())',
'raise HTTPException(401, fix({"code": "login", "reason": "a"}))',
'raise HTTPException(401, cached or {"code": "login", "reason": "a"})',
'return JSONResponse({"code": "login", "reason": "a"}, status_code=401)', # the whole body
'raise HTTPException(401, None, {"code": "login", "reason": "a"})', # the headers
'raise HTTPException(401, headers={"code": "login", "reason": "a"})',
'raise HTTPException(401, "a" if {"code": "login", "reason": "a"} else "b")',
]:
r = result(body)
assert r["unreadCodes"] and "login" not in r["codes"], body
assert "(a detail put where the scan cannot follow it)" in r["unreadCodes"][0], body
held = _scan(a='class C:\n detail = {"code": "login"}\n')
assert "(a detail put where the scan cannot follow it)" in held["unreadCodes"][0]
sent = result(
'raise HTTPException(401, {"code": "login", "reason": "a"} if x else {"code": "login"})\n'
'raise HTTPException(401, detail=dict(code="login", reason="b"))\n'
'return JSONResponse(content={"detail": {"code": "login", "reason": "c"}})'
)
assert sent["unreadCodes"] == [] and _code(sent, "login")["reasons"] == ["a", "b", "c"]
def test_a_factory_is_a_detail_only_when_each_return_is_one_and_each_call_is_sent():
factory = """
def quota(e) -> dict:
return {"code": "quota", "reason": "files"}
"""
sent = "def f(e):\n raise HTTPException(422, detail=quota(e))\n"
assert _scan(a=factory + sent)["codes"]["quota"]["reasons"] == ["files"]
for change in [
"def g(e):\n d = quota(e)\n d['code'] = 'other'\n raise HTTPException(422, d)\n",
"def g(e):\n raise HTTPException(422, detail=quota(e).copy())\n",
"pick = quota\n",
]:
r = _scan(a=factory + sent + change)
assert r["unreadCodes"] and "quota" not in r["codes"], change
twice = _scan(a="quota = None\n" + factory + sent) # the def is the last of two bindings
assert twice["unreadCodes"] and "quota" not in twice["codes"]
for other in [
" if e:\n return None\n",
" if e:\n return cached\n",
" if e:\n return {'code': 'quota', 'reason': 'x'} if e > 1 else None\n",
" log({'code': 'quota', 'reason': 'logged'})\n", # a detail it does not return
]:
bent = factory.replace("-> dict:\n", "-> dict:\n" + other)
assert _scan(a=bent + sent)["unreadCodes"], other
method = "class C:\n def quota(self):\n return {'code': 'quota', 'reason': 'm'}\n"
assert _scan(a=method)["unreadCodes"]
dotted = _scan(
a=factory,
routes__b="import app.a\n\ndef g(e):\n raise HTTPException(422, detail=app.a.quota(e))\n",
)
assert dotted["unreadCodes"] and "quota" not in dotted["codes"]
def test_a_name_a_lambda_a_comprehension_a_nonlocal_or_a_global_binds_is_unread():
r = _scan(
a="""
def f(svc):
why = "invalid"
make = lambda why: HTTPException(401, {"code": "login", "reason": why})
raise make(svc.state)
def g(svc):
why = "invalid"
raise [HTTPException(401, {"code": "login", "reason": why}) for why in svc.all][0]
def h(svc):
why = "invalid"
def inner():
nonlocal why
why = svc.reason
inner()
raise HTTPException(401, {"code": "login", "reason": why})
def i(svc):
global why
why = "invalid"
raise HTTPException(401, {"code": "login", "reason": why})
"""
)
assert _code(r, "login")["reasons"] == []
assert sorted(_unread(r, "login")) == [
"why (bound by a lambda or a comprehension)",
"why (bound by a lambda or a comprehension)",
"why (named by a nonlocal or a global)",
"why (named by a nonlocal or a global)",
]
def test_a_local_that_reaches_itself_is_unread():
r = _scan(
a="""
def f(x):
why = "a"
if x:
why = why if x > 1 else "b"
raise HTTPException(409, {"code": "edit", "reason": why})
def g(x):
a = "z"
b = a
a = b
raise HTTPException(409, {"code": "playlist", "reason": a})
def h(x):
why = "c"
if x:
raise HTTPException(409, {"code": "busy", "reason": why})
raise HTTPException(409, {"code": "busy", "reason": why})
"""
)
assert _code(r, "busy")["reasons"] == ["c"] and _code(r, "busy")["unread"] == []
assert _code(r, "edit")["reasons"] == ["a", "b"]
assert _unread(r, "edit") == ["why (reached again while it is resolved)"]
assert _code(r, "playlist")["reasons"] == ["z"]
assert any("reached again while it is resolved" in u for u in _unread(r, "playlist"))
def test_a_module_constant_or_a_map_changed_elsewhere_is_unread():
def reasons(urlguard: str, route: str = "", **more: str) -> tuple[list[str], list[str]]:
r = _scan(
**more,
urlguard="REASON = 'malformed'\n_MAP = {'a': 'map_a'}\n" + urlguard,
routes__a="from app import urlguard\n"
+ route
+ "\ndef f(k: Literal['a']):\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
+ " raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard._MAP[k]})\n",
)
return _code(r, "url_blocked")["reasons"], _unread(r, "url_blocked")
assert reasons("def get(k):\n return _MAP.get(k)\n") == (["malformed", "map_a"], [])
other = "from app import other\ndef g():\n other.REASON = 'x'\n" # another module's REASON
assert reasons("", other, other="REASON = 'y'\n") == (["malformed", "map_a"], [])
shadow = (
"def h(*urlguard):\n"
" raise HTTPException(409, {'code': 'url_blocked', 'reason': urlguard.REASON})\n"
)
assert reasons("", shadow)[1] == ["urlguard.REASON"]
for urlguard, route in [
("def reset():\n global REASON\n REASON = 'x'\n", ""),
("", "def g():\n urlguard.REASON = 'x'\n"),
("", "def g():\n del urlguard.REASON\n"),
]:
assert reasons(urlguard, route)[0] == ["map_a"], (urlguard, route)
for urlguard in [
"_MAP['a'] = compute()\n",
"def init(x):\n _MAP['a'] = x\n",
"_MAP.update(a='other')\n",
"def drop():\n del _MAP['a']\n",
"register(_MAP)\n",
]:
got, unread = reasons(urlguard)
assert got == ["malformed"] and "(the map is changed after it is built)" in unread[0]
def test_a_helper_bound_twice_is_not_followed():
r = _scan(
refusal="refused = None\n" + textwrap.dedent(REFUSAL),
routes__a="from app.refusal import refused\n\ndef f():\n raise refused(409, 'lost')\n",
)
assert _code(r, "refused")["reasons"] == []
assert _unread(r, "refused")[0].endswith("refused(reason) is not a module-level helper")
def test_a_dotted_path_through_an_app_module_that_goes_nowhere_is_unread():
r = scan(
{
"__init__.py": "",
"refusal.py": textwrap.dedent(REFUSAL),
"routes/a.py": "import app as a\n\ndef f():\n raise a.refusal.refused(409, 'dotted')\n",
}
)
assert _unread(r, "refused") == ["a.refusal.refused (a reference the scan cannot resolve)"]
def test_a_helper_reached_through_an_app_module_that_does_not_bind_it_is_unread():
for shim in [
"from app.refusal import *\n",
"try:\n from app.refusal import refused\nexcept ImportError:\n refused = None\n",
]:
r = _scan(
refusal=REFUSAL,
shim=shim,
routes__a="from app.shim import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
)
assert _unread(r, "refused") == ["refused (a reference the scan cannot resolve)"], shim
package = scan(
{
"pkg/refusal.py": textwrap.dedent(REFUSAL),
"pkg/__init__.py": "from .refusal import refused\n",
"routes/a.py": "from app.pkg import refused\n\ndef f():\n raise refused(409, 'hidden')\n",
}
)
assert _unread(package, "refused") == ["refused (a reference the scan cannot resolve)"]
def test_the_data_files_are_excluded_by_name_only():
r = _scan(
plex__tmdb='def f():\n return {"code": "HU", "name": "Hungary"}',
plex__other='def f():\n return {"code": "HU"}',
)
assert r["excluded"] == {"plex/tmdb.py": 1}
assert "HU" in r["codes"]
def test_the_app_scans():
"""The real tree, on the interpreter the backend runs: every refusal it holds is read (the
frontend's contract judges what is read)."""
result = scan_app()
assert {"refused", "not_found", "login", "clip"} <= set(result["codes"])
assert result["errors"] == [] and result["unreadCodes"] == []
+2 -1
View File
@@ -144,7 +144,8 @@ npm run typecheck # tsc x3 (app / node / e2e)
npm run lint # eslint (typed rules included; warnings fail) npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier npm run format:check # prettier
npm run knip # unused files/exports/deps npm run knip # unused files/exports/deps
npm run test # vitest (incl. the compiler and gate-register contracts) npm run test # vitest (incl. the compiler and gate-register contracts; the refusal contract runs
# the host's `python` on backend/tests/refusal_sites.py, stdlib only)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot) npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
``` ```
+1 -1
View File
@@ -196,7 +196,7 @@ test.describe("a bookmark reopens the view it was taken of", () => {
test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => { test("a channel page's own tab, over the page it was opened from", async ({ page, browser }) => {
await openApp(page, "/?page=feed"); await openApp(page, "/?page=feed");
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
expect(await clickVisibleChannelLink(page)).toBe(true); await clickVisibleChannelLink(page);
const title = await page.getByTestId("channel-title").textContent(); const title = await page.getByTestId("channel-title").textContent();
await tab(page, "About").click(); await tab(page, "About").click();
const fresh = await bookmarked(browser, page); const fresh = await bookmarked(browser, page);
+1 -1
View File
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
// `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into- // `.first()` here would grab an overscan card ABOVE the viewport, and Playwright's scroll-into-
// view would move the feed before navigating, so Back would restore that moved position, not // view would move the feed before navigating, so Back would restore that moved position, not
// the one under test. (This is what made the test fail while the app was in fact correct.) // the one under test. (This is what made the test fail while the app was in fact correct.)
expect(await clickVisibleChannelLink(page)).toBe(true); await clickVisibleChannelLink(page);
await expect(page.getByTestId("channel-title")).toBeVisible(); await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click(); await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible(); await expect(page.getByTestId("video-card").first()).toBeVisible();
+32 -14
View File
@@ -33,23 +33,41 @@ export async function gotoPage(page: Page, id: string): Promise<void> {
* that then asserts on where the feed was. Being inside the viewport is not enough either: a card * that then asserts on where the feed was. Being inside the viewport is not enough either: a card
* can sit under the page chrome above the scroller (the update banner, the toolbar band), and the * can sit under the page chrome above the scroller (the update banner, the toolbar band), and the
* covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24). * covered click makes Playwright scroll and retry — the same move (webkit failed 3 in 24).
* So the link must be the element hit at its own centre. Returns false if none found. * So the link must be the element hit at its own centre; none within the wait fails the test, with
* the poll's own message. Looked for again until one is, not once: a fresh load is still settling
* when the first card shows (the links move down ~55px), and webkit can answer the hit test at a
* link's new centre with the card under it — every link missed in 1 of 30 runs. A link that goes
* away mid-look (its evaluate waits for it, or throws) counts as not hit, so the next look runs.
*/ */
export async function clickVisibleChannelLink(page: Page): Promise<boolean> { export async function clickVisibleChannelLink(page: Page): Promise<void> {
const links = page.getByTestId("video-card-channel").filter({ visible: true }); const links = page.getByTestId("video-card-channel").filter({ visible: true });
const count = await links.count(); let index = -1;
for (let i = 0; i < count; i++) { const findHittable = async () => {
const hittable = await links.nth(i).evaluate((el) => { const count = await links.count();
const r = el.getBoundingClientRect(); for (let i = 0; i < count; i++) {
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); const hittable = await links
return hit !== null && el.contains(hit); .nth(i)
}); .evaluate(
if (hittable) { (el) => {
await links.nth(i).click(); const r = el.getBoundingClientRect();
return true; const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
return hit !== null && el.contains(hit);
},
undefined,
{ timeout: 1000 },
)
.catch(() => false);
if (hittable) return i;
} }
} return -1;
return false; };
await expect
.poll(async () => (index = await findHittable()), {
message: "a channel link hit at its own centre",
timeout: 5000,
})
.toBeGreaterThan(-1);
await links.nth(index).click();
} }
/** /**
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0), notify: vi.fn((_n: { level: string; message: NotifText }) => 0),
})); }));
vi.mock("./lib/notifications", async (importOriginal) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager"; import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api"; import { api, HttpError, type PlexCard } from "./lib/api";
import { qk } from "./lib/queryKeys"; import { qk } from "./lib/queryKeys";
import type { NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!), () => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
"plex.playlist.removeFromPlaylist", "plex.playlist.removeFromPlaylist",
); );
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(screen.getByText("Movie 1")).toBeTruthy(); expect(screen.getByText("Movie 1")).toBeTruthy();
}); });
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
const field = await screen.findByDisplayValue("Films"); const field = await screen.findByDisplayValue("Films");
fireEvent.change(field, { target: { value: "Movies" } }); fireEvent.change(field, { target: { value: "Movies" } });
fireEvent.keyDown(field, { key: "Enter" }); fireEvent.keyDown(field, { key: "Enter" });
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again
}); });
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
); );
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist")); fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true })); fireEvent.click(await screen.findByRole("menuitemcheckbox", { hidden: true }));
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
fireEvent.click(await screen.findByText("Gone")); fireEvent.click(await screen.findByText("Gone"));
// The server's own localized reason wins over the caller's fallback when the detail is // The server's own localized reason wins over the caller's fallback when the detail is
// STRUCTURED — same as the twin above. What is under test is that anything is said at all. // STRUCTURED — same as the twin above. What is under test is that anything is said at all.
await waitFor(() => expect(errors()).toEqual(["gone playlist"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("playlist").detailData }]));
await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(lists.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
</QueryClientProvider>, </QueryClientProvider>,
); );
fireEvent.click(await screen.findByLabelText("tagManager.delete")); fireEvent.click(await screen.findByLabelText("tagManager.delete"));
await waitFor(() => expect(errors()).toEqual(["gone tag"])); await waitFor(() => expect(errors()).toEqual([{ detail: gone("tag").detailData }]));
await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1)); await waitFor(() => expect(tags.mock.calls.length).toBeGreaterThan(1));
}); });
}); });
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true })); vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ const { notify } = vi.hoisted(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0), notify: vi.fn((_n: { level: string; title?: NotifText; message: NotifText }) => 0),
})); }));
vi.mock("./lib/notifications", async (importOriginal) => ({ vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()), ...(await importOriginal<typeof import("./lib/notifications")>()),
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog"; import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog"; import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api"; import { api, HttpError } from "./lib/api";
import { msg, type NotifText } from "./lib/notifications";
beforeAll(() => { beforeAll(() => {
class Stub { class Stub {
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("Marathon")); fireEvent.click(await screen.findByText("Marathon"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
}); });
it("says so when enabling editing is refused", async () => { it("says so when enabling editing is refused", async () => {
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.makeEditable")); fireEvent.click(await screen.findByText("plex.collEditor.makeEditable"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.editFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.editFailed")]));
}); });
it("says so when a CONFIRMED delete does not happen", async () => { it("says so when a CONFIRMED delete does not happen", async () => {
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
); );
mount(); mount();
fireEvent.click(await screen.findByText("plex.collEditor.delete")); fireEvent.click(await screen.findByText("plex.collEditor.delete"));
await waitFor(() => expect(errors()).toEqual(["plex.collection.deleteFailed"])); await waitFor(() => expect(errors()).toEqual([msg("plex.collection.deleteFailed")]));
}); });
}); });
@@ -144,7 +145,7 @@ describe("the upload dialog", () => {
mount(); mount();
pick(); pick();
fireEvent.click(screen.getByText("downloads.upload.submit")); fireEvent.click(screen.getByText("downloads.upload.submit"));
await waitFor(() => expect(errors()).toEqual(["downloads.upload.failed"])); await waitFor(() => expect(errors()).toEqual([msg("downloads.upload.failed")]));
}); });
it("says nothing when the user cancelled it themselves", async () => { it("says nothing when the user cancelled it themselves", async () => {
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
); );
await screen.findByText("Best"); await screen.findByText("Best");
fireEvent.click(screen.getByText("downloads.dialog.submit")); fireEvent.click(screen.getByText("downloads.dialog.submit"));
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"])); await waitFor(() => expect(errors()).toEqual([msg("errors.writeFailed.enqueueDownload")]));
}); });
}); });
@@ -21,7 +21,7 @@ vi.mock("react-i18next", async (importOriginal) => {
vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); vi.mock("../../lib/history", () => ({ useBackToClose: () => {} }));
import Modal from "./Modal"; import Modal from "./Modal";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack";
import { createPortal, flushSync } from "react-dom"; import { createPortal, flushSync } from "react-dom";
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "info", message: "Saved" })); act(() => void notify({ level: "info", message: raw("Saved") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
// The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast. // The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast.
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Nope" })); act(() => void notify({ level: "error", message: raw("Nope") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
<Body /> <Body />
</Modal>, </Modal>,
); );
act(() => void notify({ level: "error", message: "Couldn't share it." })); act(() => void notify({ level: "error", message: raw("Couldn't share it.") }));
expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it."); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it.");
}); });
}); });
+17 -17
View File
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
afterEach(() => { afterEach(() => {
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster /> <Toaster />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "Couldn't remove the video." })); act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video."); expect(screen.getByTestId("app").textContent).toContain("Couldn't remove the video.");
const overlay = render( const overlay = render(
@@ -54,7 +54,7 @@ describe("Toaster — one host shows the toasts", () => {
it("lets a click through where there is no toast and nothing docked, and not where there is", () => { it("lets a click through where there is no toast and nothing docked, and not where there is", () => {
const Docked = () => <button>dock</button>; const Docked = () => <button>dock</button>;
const stop = dockUnderToasts(Docked); const stop = dockUnderToasts(Docked);
notify({ level: "info", message: "hello" }); notify({ level: "info", message: raw("hello") });
render(<Toaster />); render(<Toaster />);
const region = document.querySelector<HTMLElement>("[data-toast-region]")!; const region = document.querySelector<HTMLElement>("[data-toast-region]")!;
// The empty part of the region (beside and above a docked control) must not swallow the page's // The empty part of the region (beside and above a docked control) must not swallow the page's
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
<Toaster overlay /> <Toaster overlay />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
fireEvent.click(screen.getByText("boom")); fireEvent.click(screen.getByText("boom"));
expect(backdrop).not.toHaveBeenCalled(); expect(backdrop).not.toHaveBeenCalled();
}); });
@@ -86,11 +86,11 @@ describe("Toaster — handing the toasts to another host", () => {
// The new host gets them as NEW nodes: a screen reader read every one out again, an error // The new host gets them as NEW nodes: a screen reader read every one out again, an error
// assertively, each time a player (or any dialog) opened or closed. // assertively, each time a player (or any dialog) opened or closed.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
render(<Toaster overlay />); render(<Toaster overlay />);
const old = screen.getByText("old news").closest(".toast-card")!; const old = screen.getByText("old news").closest(".toast-card")!;
expect(old.getAttribute("role")).toBeNull(); expect(old.getAttribute("role")).toBeNull();
act(() => void notify({ level: "error", message: "fresh" })); act(() => void notify({ level: "error", message: raw("fresh") }));
expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert"); expect(screen.getByText("fresh").closest(".toast-card")!.getAttribute("role")).toBe("alert");
}); });
@@ -99,8 +99,8 @@ describe("Toaster — handing the toasts to another host", () => {
// host last showed — re-read on every render, this host's own "first" turned silent the moment // host last showed — re-read on every render, this host's own "first" turned silent the moment
// "second" arrived (its own screen had become the handover by then). // "second" arrived (its own screen had become the handover by then).
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "error", message: "first" })); act(() => void notify({ level: "error", message: raw("first") }));
act(() => void notify({ level: "error", message: "second" })); act(() => void notify({ level: "error", message: raw("second") }));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
expect(card("first").getAttribute("role")).toBe("alert"); expect(card("first").getAttribute("role")).toBe("alert");
expect(card("second").getAttribute("role")).toBe("alert"); expect(card("second").getAttribute("role")).toBe("alert");
@@ -111,7 +111,7 @@ describe("Toaster — handing the toasts to another host", () => {
// inserted announces them anyway. React flushes effects in the SAME task as that render, so the // inserted announces them anyway. React flushes effects in the SAME task as that render, so the
// arming has to wait for the next one — a screen reader reads the region's state after the task. // arming has to wait for the next one — a screen reader reads the region's state after the task.
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
const overlay = render(<Toaster overlay />); const overlay = render(<Toaster overlay />);
const region = overlay.container.querySelector("[aria-live]")!; const region = overlay.container.querySelector("[aria-live]")!;
expect(region.textContent).toContain("saved"); // it took the toast over… expect(region.textContent).toContain("saved"); // it took the toast over…
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay /> <Toaster overlay />
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "upload queued" }); notify({ level: "success", message: raw("upload queued") });
setOpen(false); setOpen(false);
}} }}
> >
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
); );
} }
render(<Stage />); render(<Stage />);
act(() => void notify({ level: "error", message: "old news" })); act(() => void notify({ level: "error", message: raw("old news") }));
fireEvent.click(screen.getByText("upload")); fireEvent.click(screen.getByText("upload"));
const card = (text: string) => screen.getByText(text).closest(".toast-card")!; const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
@@ -168,7 +168,7 @@ describe("Toaster — handing the toasts to another host", () => {
<Toaster overlay order={2} /> <Toaster overlay order={2} />
</div>, </div>,
); );
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
expect(screen.getByTestId("dialog").textContent).toContain("boom"); expect(screen.getByTestId("dialog").textContent).toContain("boom");
// The player swaps its host (leaving true fullscreen) while the dialog stays open. // The player swaps its host (leaving true fullscreen) while the dialog stays open.
player.rerender( player.rerender(
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
const t0 = Date.now(); const t0 = Date.now();
const now = vi.spyOn(Date, "now").mockReturnValue(t0); const now = vi.spyOn(Date, "now").mockReturnValue(t0);
render(<Toaster />); render(<Toaster />);
act(() => void notify({ level: "info", message: "saved" })); act(() => void notify({ level: "info", message: raw("saved") }));
now.mockReturnValue(t0 + 4000); now.mockReturnValue(t0 + 4000);
render(<Toaster overlay />); render(<Toaster overlay />);
const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement; const bar = screen.getByText("saved").closest(".toast-card")!.lastElementChild as HTMLElement;
@@ -199,7 +199,7 @@ describe("Toaster — handing the toasts to another host", () => {
// At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's // At the app's own spot (bottom-4) the stack sat on the Plex transport row and PlayerModal's
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up. // docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
render(<Toaster overlay />); render(<Toaster overlay />);
act(() => void notify({ level: "error", message: "boom" })); act(() => void notify({ level: "error", message: raw("boom") }));
const host = hosts()[0]!.closest("[data-toast-region]")!; const host = hosts()[0]!.closest("[data-toast-region]")!;
expect(host.className).toContain("bottom-24"); expect(host.className).toContain("bottom-24");
expect(host.className).toContain("pointer-events-none"); expect(host.className).toContain("pointer-events-none");
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
() => () =>
void notify({ void notify({
level: "success", level: "success",
message: "Downloaded", message: raw("Downloaded"),
action: { label: "Copy link", onClick: copy }, action: { label: raw("Copy link"), onClick: copy },
secondaryAction: { label: "Open", onClick: open }, secondaryAction: { label: raw("Open"), onClick: open },
}), }),
); );
expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Copy link" })).toBeTruthy();
@@ -0,0 +1,104 @@
// @vitest-environment jsdom
//
// A notice is said in the reader's language when it is shown, not in the one it was raised in. The
// text used to be translated by the caller and kept so: after a language switch every toast and every
// row of the notification center stayed in the old language, and so did the history after a reload.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import i18n from "../../i18n";
import Toaster from "./Toaster";
import { clearAll, msg, notify, raw, sayNotif } from "../../lib/notifications";
beforeEach(async () => {
await i18n.changeLanguage("en");
});
afterEach(async () => {
cleanup();
act(() => clearAll());
await i18n.changeLanguage("en");
});
describe("a notice speaks the reader's language of the moment", () => {
it("a toast raised in English reads Hungarian after the switch", async () => {
render(<Toaster />);
act(() => void notify({ level: "success", message: msg("common.copy.done") }));
const en = i18n.getFixedT("en")("common.copy.done");
const hu = i18n.getFixedT("hu")("common.copy.done");
expect(en).not.toBe(hu);
expect(screen.getByText(en)).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText(hu)).toBeTruthy();
expect(screen.queryByText(en)).toBeNull();
});
it("its button is said again too, beside the text it acts on", async () => {
render(<Toaster />);
act(
() =>
void notify({
message: msg("common.copy.done"),
action: { label: msg("common.undo"), onClick: () => {} },
}),
);
expect(screen.getByRole("button", { name: "Undo" })).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByRole("button", { name: "Visszavonás" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Undo" })).toBeNull();
});
it("a server refusal is said again in the language of the moment", async () => {
render(<Toaster />);
act(
() =>
void notify({
level: "error",
message: { detail: { code: "not_found", reason: "view" } },
}),
);
expect(screen.getByText("This view doesn't exist any more")).toBeTruthy();
await act(async () => void (await i18n.changeLanguage("hu")));
expect(screen.getByText("Ez a nézet már nem létezik")).toBeTruthy();
});
it("a value inside the text is said again too, and a number is grouped the reader's way", async () => {
const text = msg("settings.takeout.read", {
name: raw("watch-history.json"),
count: 12345,
n: { number: 12345 },
});
const en = sayNotif(text);
await i18n.changeLanguage("hu");
const hu = sayNotif(text);
expect(en).toContain("12,345");
expect(hu).not.toContain("12,345");
expect(hu).toContain("watch-history.json");
expect(hu).not.toBe(en);
});
});
describe("the history kept from before", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("shows a notice stored as text as it was stored", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(
"siftlode.notifications",
JSON.stringify([
{ id: 1, level: "info", title: "Old title", message: "Old text", ts: 1, read: true },
]),
);
const notifications = await import("../../lib/notifications");
const { claimLocalData } = await import("../../lib/storage");
claimLocalData(5);
const n = notifications.getNotifications()[0]!;
expect(n.message).toEqual(raw("Old text"));
expect(n.title).toEqual(raw("Old title"));
expect(notifications.sayNotif(n.message)).toBe("Old text");
});
});
@@ -23,7 +23,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import Toaster from "./Toaster"; import Toaster from "./Toaster";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -37,7 +37,7 @@ function Harness() {
<> <>
<button <button
onClick={() => { onClick={() => {
notify({ level: "success", message: "Uploaded" }); notify({ level: "success", message: raw("Uploaded") });
setOpen(false); // …in the SAME handler: one batch, one commit setOpen(false); // …in the SAME handler: one batch, one commit
}} }}
> >
+8 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { dismiss, getActiveToasts, subscribe } from "../../lib/notifications"; import { dismiss, getActiveToasts, sayNotif, subscribe } from "../../lib/notifications";
import { createStore } from "../../lib/store"; import { createStore } from "../../lib/store";
import { useToastDock } from "../../lib/toastDock"; import { useToastDock } from "../../lib/toastDock";
import { useOverlayOrder } from "./overlayOrder"; import { useOverlayOrder } from "./overlayOrder";
@@ -162,9 +162,11 @@ export default function Toaster({
> >
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} /> <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>} {toast.title && (
<div className="text-sm font-semibold">{sayNotif(toast.title, t)}</div>
)}
<div className="text-sm wrap-break-word"> <div className="text-sm wrap-break-word">
{toast.message} {sayNotif(toast.message, t)}
{toast.repeat > 1 && ( {toast.repeat > 1 && (
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums"> <span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
×{toast.repeat} ×{toast.repeat}
@@ -174,10 +176,10 @@ export default function Toaster({
{(toast.action || toast.secondaryAction) && ( {(toast.action || toast.secondaryAction) && (
<div className="mt-1 flex flex-wrap gap-x-4"> <div className="mt-1 flex flex-wrap gap-x-4">
{[toast.action, toast.secondaryAction].map( {[toast.action, toast.secondaryAction].map(
(action) => (action, i) =>
action && ( action && (
<Button <Button
key={action.label} key={i}
variant="link" variant="link"
onClick={() => { onClick={() => {
action.onClick(); action.onClick();
@@ -185,7 +187,7 @@ export default function Toaster({
}} }}
className="text-sm" className="text-sm"
> >
{action.label} {sayNotif(action.label, t)}
</Button> </Button>
), ),
)} )}
+5 -5
View File
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react"; import { Copy, Link2 } from "lucide-react";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu"; import { type ActionMenuItem } from "./ActionMenu";
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with // "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
@@ -17,12 +17,12 @@ type Named = "title" | "name";
export function useCopyItems() { export function useCopyItems() {
const { t } = useTranslation(); const { t } = useTranslation();
const copy = (text: string, done: string) => { const copy = (text: string, done: NotifText) => {
void copyText(text).then((ok) => void copyText(text).then((ok) =>
notify( notify(
ok ok
? { level: "success", message: done } ? { level: "success", message: done }
: { level: "error", message: t("common.copy.failed") }, : { level: "error", message: msg("common.copy.failed") },
), ),
); );
}; };
@@ -43,7 +43,7 @@ export function useCopyItems() {
label: t(named === "title" ? "common.copy.title" : "common.copy.name"), label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
icon: <Copy className="w-4 h-4" />, icon: <Copy className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(text, t("common.copy.done")), onSelect: () => copy(text, msg("common.copy.done")),
}, },
] ]
: []), : []),
@@ -54,7 +54,7 @@ export function useCopyItems() {
label: linkLabel ?? t("common.copy.link"), label: linkLabel ?? t("common.copy.link"),
icon: <Link2 className="w-4 h-4" />, icon: <Link2 className="w-4 h-4" />,
inGesture: true, inGesture: true,
onSelect: () => copy(link, t("common.copy.linkDone")), onSelect: () => copy(link, msg("common.copy.linkDone")),
}, },
] ]
: []), : []),
@@ -19,8 +19,9 @@ const PLATFORM_NAMESPACES = new Set([
]); ]);
const files = readSources(__dirname, "./**/*.{ts,tsx}"); const files = readSources(__dirname, "./**/*.{ts,tsx}");
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot. // `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g; // namespace is everything before the first dot.
const KEY = /\b(?:t|msg)\(\s*["`]([A-Za-z]+)\./g;
describe("components/ui/ speaks only platform i18n namespaces", () => { describe("components/ui/ speaks only platform i18n namespaces", () => {
it("names no feature namespace", () => { it("names no feature namespace", () => {
@@ -37,5 +38,6 @@ describe("components/ui/ speaks only platform i18n namespaces", () => {
it("sees a feature namespace when one is used", () => { it("sees a feature namespace when one is used", () => {
expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]); expect([...'t("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
expect([...'msg("plex.loading")'.matchAll(KEY)].map((m) => m[1])).toEqual(["plex"]);
}); });
}); });
@@ -11,8 +11,12 @@ vi.mock("react-i18next", async (importOriginal) => ({
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
})); }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText })); vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify })); vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import { msg } from "../../lib/notifications";
import { useCopyFeedback } from "./useCopyFeedback"; import { useCopyFeedback } from "./useCopyFeedback";
afterEach(() => { afterEach(() => {
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(h.copyText).toHaveBeenCalledWith("sfl_x"); expect(h.copyText).toHaveBeenCalledWith("sfl_x");
expect(result.current.copied).toBe(true); expect(result.current.copied).toBe(true);
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" }); expect(h.notify).toHaveBeenCalledWith({ level: "success", message: msg("common.copy.done") });
act(() => void vi.advanceTimersByTime(2000)); act(() => void vi.advanceTimersByTime(2000));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
}); });
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
const { result } = renderHook(() => useCopyFeedback()); const { result } = renderHook(() => useCopyFeedback());
await act(() => result.current.copy("sfl_x")); await act(() => result.current.copy("sfl_x"));
expect(result.current.copied).toBe(false); expect(result.current.copied).toBe(false);
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" }); expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: msg("common.copy.failed") });
}); });
}); });
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../../lib/clipboard"; import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked. // How long a Copy button reads "Copied" after it worked.
const COPIED_MS = 2000; const COPIED_MS = 2000;
@@ -15,7 +14,6 @@ const COPIED_MS = 2000;
* *
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */ * A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } { export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// One reset at a time: a copy again within the moment starts it over, not cut short by the last. // One reset at a time: a copy again within the moment starts it over, not cut short by the last.
const timer = useRef<number | undefined>(undefined); const timer = useRef<number | undefined>(undefined);
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
const copy = async (value: string) => { const copy = async (value: string) => {
if (await copyText(value)) { if (await copyText(value)) {
setCopied(true); setCopied(true);
notify({ level: "success", message: t("common.copy.done") }); notify({ level: "success", message: msg("common.copy.done") });
window.clearTimeout(timer.current); window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS); timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
return; return;
} }
notify({ level: "warning", message: t("common.copy.failed") }); notify({ level: "warning", message: msg("common.copy.failed") });
}; };
return { copied, copy }; return { copied, copy };
} }
@@ -1,4 +1,28 @@
[ [
{
"version": "0.114.3",
"date": "2026-11-01",
"new": [],
"fixed": [
"Editing a file that is neither video nor audio now says why it can't be done, instead of a generic error."
]
},
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Security: a library the sign-in relies on is updated, closing two published vulnerabilities."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Notifications follow a language switch: a notice on screen and every row in the notification center are said again in the new language, their buttons too."
]
},
{ {
"version": "0.114.0", "version": "0.114.0",
"date": "2026-10-29", "date": "2026-10-29",
@@ -1,4 +1,28 @@
[ [
{
"version": "0.114.3",
"date": "2026-11-01",
"new": [],
"fixed": [
"Ha nem videó- vagy hangfájlt próbálsz szerkeszteni, az alkalmazás most megmondja, miért nem lehet, általános hibaüzenet helyett."
]
},
{
"version": "0.114.2",
"date": "2026-10-30",
"new": [],
"fixed": [
"Biztonság: frissült egy könyvtár, amelyre a bejelentkezés épül, így két nyilvános sebezhetőség záródott be."
]
},
{
"version": "0.114.1",
"date": "2026-10-30",
"new": [],
"fixed": [
"Az értesítések követik a nyelvváltást: a képernyőn lévő értesítés és az értesítési központ minden sora az új nyelven szól, a gombjaik is."
]
},
{ {
"version": "0.114.0", "version": "0.114.0",
"date": "2026-10-29", "date": "2026-10-29",
+5 -5
View File
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../../lib/api"; import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify, raw } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade"; import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
@@ -137,22 +137,22 @@ export default function TagManager({
}); });
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence // A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
// (a 404 `not_found/tag` gets no global dialog) and stay listed. // (a 404 `not_found/tag` gets no global dialog) and stay listed.
const onFailed = (e: unknown, fallback: string) => { const onFailed = (e: unknown, fallback: NotifText) => {
notifyActionError(e, fallback); notifyActionError(e, fallback);
invalidate(); invalidate();
}; };
const rename = useMutation({ const rename = useMutation({
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }), mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
onSuccess: invalidate, onSuccess: invalidate,
onError: (e) => onFailed(e, t("tagManager.renameFailed")), onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id), mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
onSuccess: (_d, v) => { onSuccess: (_d, v) => {
invalidate(); invalidate();
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) }); notify({ level: "success", message: msg("tagManager.deleted", { name: raw(v.name) }) });
}, },
onError: (e) => onFailed(e, t("tagManager.deleteFailed")), onError: (e) => onFailed(e, msg("tagManager.deleteFailed")),
}); });
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager. // Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
+13 -13
View File
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../../i18n"; import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api"; import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications"; import { msg, type NotifText, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -81,9 +81,9 @@ export interface ComfortPin {
/** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them /** `pinIdOf` and `toggle` stay module-local until a second consumer needs them: publishing them
* invites the next surface to re-derive the three-state loading dance (pinned / not pinned / * invites the next surface to re-derive the three-state loading dance (pinned / not pinned /
* not known yet) that `item` already encapsulates, which is what this file exists to prevent. */ * not known yet) that `item` already encapsulates, which is what this file exists to prevent. */
remove: (pinId: number, title: string) => void; remove: (pinId: number, title: NotifText) => void;
/** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */ /** The pin/unpin row, ready to spread into any `ActionMenuItem[]`. Empty for a null target. */
item: (target: PinTarget | null, title: string) => ActionMenuItem[]; item: (target: PinTarget | null, title: NotifText) => ActionMenuItem[];
} }
/** /**
@@ -134,37 +134,37 @@ export function useComfortPin(open: boolean): ComfortPin {
}, [qc]); }, [qc]);
const add = useCallback( const add = useCallback(
async (target: PinTarget, title: string) => { async (target: PinTarget, title: NotifText) => {
try { try {
await api.addComfortPin(target.kind, target.ref); await api.addComfortPin(target.kind, target.ref);
// The shelf lives on another page, so without this the click has no visible consequence at // The shelf lives on another page, so without this the click has no visible consequence at
// all from the feed — the one place a toast genuinely carries the whole feedback. // all from the feed — the one place a toast genuinely carries the whole feedback.
notify({ level: "success", message: t("home.pin.added", { title }) }); notify({ level: "success", message: msg("home.pin.added", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.addFailed")); notifyActionError(e, msg("home.pin.addFailed"));
} }
invalidate(); invalidate();
}, },
[invalidate, t], [invalidate],
); );
const remove = useCallback( const remove = useCallback(
(pinId: number, title: string) => { (pinId: number, title: NotifText) => {
void (async () => { void (async () => {
try { try {
await api.removeComfortPin(pinId); await api.removeComfortPin(pinId);
notify({ level: "success", message: t("home.pin.removed", { title }) }); notify({ level: "success", message: msg("home.pin.removed", { title }) });
} catch (e) { } catch (e) {
notifyActionError(e, t("home.pin.removeFailed")); notifyActionError(e, msg("home.pin.removeFailed"));
} }
invalidate(); invalidate();
})(); })();
}, },
[invalidate, t], [invalidate],
); );
const toggle = useCallback( const toggle = useCallback(
(target: PinTarget, title: string) => { (target: PinTarget, title: NotifText) => {
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
if (pinId == null) void add(target, title); if (pinId == null) void add(target, title);
else remove(pinId, title); else remove(pinId, title);
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
); );
const item = useCallback( const item = useCallback(
(target: PinTarget | null, title: string): ActionMenuItem[] => { (target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
if (!target) return []; if (!target) return [];
const pinId = pinIdOf(target); const pinId = pinIdOf(target);
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over // Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
@@ -18,7 +18,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format"; import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock"; import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
if (!id || !until) return; if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1); const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) }); notify({ level: "warning", message: msg("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id); openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() }); void qc.invalidateQueries({ queryKey: qk.me() });
}, wait); }, wait);
@@ -6,7 +6,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api"; import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder"; import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format"; import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { import {
draftSteps, draftSteps,
@@ -217,7 +217,7 @@ function ComposerBody({
meta: { failTitle: "recorder.composer.sendFailed" }, meta: { failTitle: "recorder.composer.sendFailed" },
onSuccess: (r) => { onSuccess: (r) => {
qc.setQueryData(qk.debugSession(session.id), r.session); qc.setQueryData(qk.debugSession(session.id), r.session);
notify({ level: "success", message: t("recorder.composer.sentOk") }); notify({ level: "success", message: msg("recorder.composer.sentOk") });
}, },
// A failure half-way still created the issue: show what the server now has. // A failure half-way still created the issue: show what the server now has.
onSettled: async () => { onSettled: async () => {
@@ -1,6 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api"; import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
@@ -14,7 +13,7 @@ import {
} from "../../lib/debugRecorder"; } from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics"; import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe"; import { updateMeCache, useMe } from "../../lib/useMe";
@@ -22,7 +21,6 @@ import { updateMeCache, useMe } from "../../lib/useMe";
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's * Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */ * answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() { export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const me = useMe(); const me = useMe();
const session = liveSession(me?.debug_session); const session = liveSession(me?.debug_session);
@@ -54,7 +52,7 @@ export function useDebugRecording() {
meta: { failTitle: "recorder.pill.stopFailed" }, meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => { onSuccess: (r) => {
setSession(null); setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") }); notify({ level: "success", message: msg("recorder.pill.stopped") });
openComposer(r.session.id); openComposer(r.session.id);
}, },
}); });
@@ -78,7 +76,7 @@ export function useDebugRecording() {
); );
// In the evidence too, where the step list is drafted from: which step it belongs to. // In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id }); log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") }); notify({ level: "success", message: msg("recorder.pill.shotSaved") });
}, },
}); });
@@ -95,9 +93,9 @@ export function useDebugRecording() {
const n = nextStep(sessionId); const n = nextStep(sessionId);
const text = label.trim(); const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) }); log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) }); notify({ level: "info", message: msg("recorder.pill.stepMarked", { n }) });
}, },
[sessionId, t], [sessionId],
); );
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the /** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
@@ -111,7 +109,7 @@ export function useDebugRecording() {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab(); image = await captureTab();
} catch (err) { } catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed")); notifyActionError(err, msg("recorder.pill.shotFailed"));
} }
hide(false); hide(false);
if (image) shoot.mutate({ id: session.id, image }); if (image) shoot.mutate({ id: session.id, image });
@@ -9,7 +9,7 @@ import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor")); const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab"; import { profileName } from "../../lib/vocab";
@@ -63,15 +63,15 @@ export default function DownloadDialog({
setLastProfile(chosen); setLastProfile(chosen);
notify({ notify({
level: "success", level: "success",
message: t("downloads.dialog.added"), message: msg("downloads.dialog.added"),
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) }, action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (e) { } catch (e) {
// A quota or validation refusal (422/409) is already in the global error dialog, which the // A quota or validation refusal (422/409) is already in the global error dialog, which the
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog // reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
// open with nothing said. // open with nothing said.
notifyActionError(e, t("errors.writeFailed.enqueueDownload")); notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
setBusy(false); setBusy(false);
} }
}; };
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
})); }));
import DownloadTray from "./DownloadTray"; import DownloadTray from "./DownloadTray";
import { msg, type NotifText, raw } from "../../lib/notifications";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue"; import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack"; import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
poll([job(1, "done")], view); poll([job(1, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
const notice = h.notify.mock.calls[0]![0] as { const notice = h.notify.mock.calls[0]![0] as {
title: string; title: NotifText;
message: string; message: NotifText;
action: { label: string; onClick: () => void }; action: { label: NotifText; onClick: () => void };
secondaryAction: { label: string; onClick: () => void }; secondaryAction: { label: NotifText; onClick: () => void };
}; };
expect(notice.title).toBe("tray.done.title:Clip 1"); expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
expect(notice.message).toBe("tray.done.phone"); expect(notice.message).toEqual(msg("tray.done.phone"));
expect(notice.action.label).toBe("tray.done.copyLink"); expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
notice.action.onClick(); notice.action.onClick();
expect(h.share).toHaveBeenCalled(); expect(h.share).toHaveBeenCalled();
notice.secondaryAction.onClick(); notice.secondaryAction.onClick();
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
poll([job(1, "done"), job(2, "done")], view); poll([job(1, "done"), job(2, "done")], view);
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2)); await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([ expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
"tray.done.title:Clip 1", msg("tray.done.title", { title: raw("Clip 1") }),
"tray.done.title:Clip 2", msg("tray.done.title", { title: raw("Clip 2") }),
]); ]);
}); });
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
const view = mount(); const view = mount();
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view); poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(expect.objectContaining({ message: "tray.done.file" })), expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ message: msg("tray.done.file") }),
),
); );
}); });
@@ -257,7 +261,9 @@ describe("DownloadTray", () => {
poll([job(1, "done", { source_key: "upload" })], view); poll([job(1, "done", { source_key: "upload" })], view);
await waitFor(() => await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith( expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "tray.done.uploadedTitle:Clip 1" }), expect.objectContaining({
title: msg("tray.done.uploadedTitle", { title: raw("Clip 1") }),
}),
), ),
); );
}); });
@@ -30,7 +30,7 @@ import {
} from "../../lib/downloadQueue"; } from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack"; import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav"; import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications"; import { msg, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -191,16 +191,16 @@ export default function DownloadTray() {
notify({ notify({
level: "success", level: "success",
// An upload the worker had to convert first is done here too — it was never downloaded. // An upload the worker had to convert first is done here too — it was never downloaded.
title: t(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", { title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.title", {
title, title: raw(title),
}), }),
message: t(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"), message: msg(job.asset?.media_kind === "file" ? "tray.done.file" : "tray.done.phone"),
action: { action: {
label: t("tray.done.copyLink"), label: msg("tray.done.copyLink"),
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
}, },
secondaryAction: { secondaryAction: {
label: t("tray.done.open"), label: msg("tray.done.open"),
onClick: () => openFile(job.id, job.folder_id), onClick: () => openFile(job.id, job.folder_id),
}, },
}); });
@@ -298,7 +298,7 @@ export default function DownloadTray() {
id: "copyLink", id: "copyLink",
label: t("tray.done.copyLink"), label: t("tray.done.copyLink"),
icon: <Link className="w-4 h-4" />, icon: <Link className="w-4 h-4" />,
onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }, t), onSelect: () => void quickShareLink(qc, { kind: "file", id: job.id }),
}, },
]); ]);
const menuItems = menu.target const menuItems = menu.target
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle"; import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { menuGroups } from "../../components/ui/menuGroups"; import { menuGroups } from "../../components/ui/menuGroups";
import { msg } from "../../lib/notifications";
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The // A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in // row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -56,7 +57,7 @@ export function useDownloadActions() {
? api.resumeDownload(id) ? api.resumeDownload(id)
: api.cancelDownload(id), : api.cancelDownload(id),
onSuccess: () => invalidateDownloads(qc), onSuccess: () => invalidateDownloads(qc),
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)), onError: (e, v) => notifyActionError(e, msg(`errors.writeFailed.job.${v.op}`)),
}); });
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => { const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { ownKey } from "../../lib/storage"; import { ownKey } from "../../lib/storage";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import type { SavedViewsContext, ViewSource } from "./savedViewsContext"; import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
import { msg, type NotifText } from "../../lib/notifications";
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its // Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the // own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
@@ -114,7 +115,7 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
const isDirty = displayView != null && activeView == null; const isDirty = displayView != null && activeView == null;
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) }); const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> { async function runViewMutation(fn: () => Promise<unknown>, failMsg: NotifText): Promise<boolean> {
let ok = true; let ok = true;
try { try {
await fn(); await fn();
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
if (!clean) return false; if (!clean) return false;
return runViewMutation( return runViewMutation(
() => api.createView(clean, cfg.stripForSave(cfg.filters), source), () => api.createView(clean, cfg.stripForSave(cfg.filters), source),
t("views.actionFailed"), msg("views.actionFailed"),
); );
}, },
rename: async (id, name) => { rename: async (id, name) => {
const next = name.trim(); const next = name.trim();
if (!next) return false; if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed")); return runViewMutation(() => api.updateView(id, { name: next }), msg("views.actionFailed"));
}, },
updateFilters: (id, filters) => updateFilters: (id, filters) =>
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
saveCurrent: async (id) => { saveCurrent: async (id) => {
const filters = cfg.stripForSave(cfg.filters); const filters = cfg.stripForSave(cfg.filters);
const ok = await runViewMutation( const ok = await runViewMutation(
() => api.updateView(id, { filters }), () => api.updateView(id, { filters }),
t("views.actionFailed"), msg("views.actionFailed"),
); );
return ok ? filters : null; return ok ? filters : null;
}, },
toggleDefault: (v) => toggleDefault: (v) =>
runViewMutation( runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }), () => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"), msg("views.actionFailed"),
).then(() => undefined), ).then(() => undefined),
remove: async (v) => { remove: async (v) => {
const ok = await confirm({ const ok = await confirm({
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
}); });
if (!ok) return false; if (!ok) return false;
if (stickyId === v.id) setStickyId(null); if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); return runViewMutation(() => api.deleteView(v.id), msg("views.actionFailed"));
}, },
reorder: async (nextIds) => { reorder: async (nextIds) => {
qc.setQueryData<SavedView[]>( qc.setQueryData<SavedView[]>(
qk.savedViews(source), qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
); );
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); await runViewMutation(() => api.reorderViews(nextIds), msg("views.reorderFailed"));
}, },
}; };
} }
@@ -30,10 +30,11 @@ vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTr
const notifyActionError = vi.fn(); const notifyActionError = vi.fn();
vi.mock("../../lib/actionErrors", async (importOriginal) => { vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>(); const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) }; return { ...actual, notifyActionError: (e: unknown, t: NotifText) => notifyActionError(e, t) };
}); });
import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel"; import ClipPanel, { type ClipCrop, type ClipSource } from "./ClipPanel";
import { msg, type NotifText } from "../../lib/notifications";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
display_name: "The bit", display_name: "The bit",
}); });
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "success", message: "clip.queued" }), expect.objectContaining({ level: "success", message: msg("clip.queued") }),
); );
// Its View opens the tray on the new clip. // Its View opens the tray on the new clip.
( (
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1)); await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
expect(notifyActionError.mock.calls[0]![0]).toBe(err); expect(notifyActionError.mock.calls[0]![0]).toBe(err);
expect(notifyActionError.mock.calls[0]![1]).toBe("clip.failed"); expect(notifyActionError.mock.calls[0]![1]).toEqual(msg("clip.failed"));
expect(onClose).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled();
expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe( expect((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
false, false,
+4 -4
View File
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
import { api, type ClipRequest } from "../../lib/api"; import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads"; import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads"; import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications"; import { msg, notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
@@ -197,14 +197,14 @@ export default function ClipPanel({
log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) });
notify({ notify({
level: "success", level: "success",
message: t("clip.queued"), message: msg("clip.queued"),
action: { label: t("clip.view"), onClick: () => showInTray(job.id) }, action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost // A refusal the server explains (too long, quota) is already in the error dialog; a lost
// connection or a 404 is not, and must not leave the panel looking as if nothing happened. // connection or a 404 is not, and must not leave the panel looking as if nothing happened.
notifyActionError(err, t("clip.failed")); notifyActionError(err, msg("clip.failed"));
setBusy(false); setBusy(false);
} }
}; };
@@ -1,5 +1,4 @@
import { lazy, Suspense, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type Video, type VideoStatus } from "../../lib/api"; import { type Video, type VideoStatus } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
import { useStableCallback } from "../../lib/useLatest"; import { useStableCallback } from "../../lib/useLatest";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer"; import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { msg } from "../../lib/notifications";
const PlayerModal = lazy(() => import("./PlayerModal")); const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -14,7 +14,6 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
* channel manager, where the watched-videos dialog played it; any page a link names): the video * channel manager, where the watched-videos dialog played it; any page a link names): the video
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */ * alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
export function LinkedPlayer({ hosted }: { hosted: boolean }) { export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [own] = useState(!hosted); const [own] = useState(!hosted);
const [video, setVideo] = useState<Video | null>(null); const [video, setVideo] = useState<Video | null>(null);
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
const write = writeVideoState(qc, id, status); const write = writeVideoState(qc, id, status);
write write
.then(() => qc.invalidateQueries({ queryKey: qk.feed() })) .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState"))); .catch((e: unknown) => notifyActionError(e, msg("errors.writeFailed.videoState")));
return write; return write;
}); });
const close = useStableCallback(() => setVideo(null)); const close = useStableCallback(() => setVideo(null));
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider"; import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext"; import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Me, type Video, type VideoStatus } from "../../lib/api"; import { api, type Me, type Video, type VideoStatus } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { writeVideoState } from "../video/videoWrites"; import { writeVideoState } from "../video/videoWrites";
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
onState={() => Promise.resolve()} onState={() => Promise.resolve()}
/>, />,
); );
act(() => void notify({ level: "error", message: "Couldn't remove the video." })); act(() => void notify({ level: "error", message: raw("Couldn't remove the video.") }));
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video."); expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
}); });
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage }); Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
try { try {
act(() => void document.dispatchEvent(new Event("fullscreenchange"))); act(() => void document.dispatchEvent(new Event("fullscreenchange")));
act(() => void notify({ level: "error", message: "Connection lost" })); act(() => void notify({ level: "error", message: raw("Connection lost") }));
await waitFor(() => expect(stage.textContent).toContain("Connection lost")); await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
} finally { } finally {
delete (document as { fullscreenElement?: unknown }).fullscreenElement; delete (document as { fullscreenElement?: unknown }).fullscreenElement;
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
import { VideoMenu } from "../video/VideoMenu"; import { VideoMenu } from "../video/VideoMenu";
import { announceVideoState } from "../video/stateNotice"; import { announceVideoState } from "../video/stateNotice";
import { writeVideoState, writeWatchLater } from "../video/videoWrites"; import { writeVideoState, writeWatchLater } from "../video/videoWrites";
import { msg } from "../../lib/notifications";
/** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood, /** Which list YouTube fills in — on the video's topic or with what trends in Hungary — is a mood,
* decided at the list, and the account keeps the last one (the owner's pick). */ * decided at the list, and the account keeps the last one (the owner's pick). */
@@ -100,7 +101,7 @@ export function RelatedVideos({
announceVideoState(id, status, v, () => onState(id, "new")); announceVideoState(id, status, v, () => onState(id, "new"));
}) })
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError(e, t("errors.writeFailed.videoState")); notifyActionError(e, msg("errors.writeFailed.videoState"));
settle(seq); settle(seq);
}); });
}; };
@@ -118,7 +119,7 @@ export function RelatedVideos({
.catch((e: unknown) => { .catch((e: unknown) => {
notifyActionError( notifyActionError(
e, e,
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"), msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
); );
settle(seq); settle(seq);
}); });
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
import { LinkedPlayer } from "./LinkedPlayer"; import { LinkedPlayer } from "./LinkedPlayer";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
const vid = (id: string) => ({ id, title: id }) as unknown as Video; const vid = (id: string) => ({ id, title: id }) as unknown as Video;
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
const open = vi.fn(); const open = vi.fn();
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper }); renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
await waitFor(() => await waitFor(() =>
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"), expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
); );
expect(open).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled();
// Said once: the address no longer names it, so a reload or a copied link does not say it again. // Said once: the address no longer names it, so a reload or a copied link does not say it again.
@@ -1,11 +1,11 @@
import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useEffect, useEffectEvent, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { api, type Video } from "../../lib/api"; import { api, type Video } from "../../lib/api";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState"; import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
import { clearParams } from "../../lib/urlState"; import { clearParams } from "../../lib/urlState";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { msg } from "../../lib/notifications";
/** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that /** Opens the player an address names (a reload, a bookmark — lib/playerUrlState) on the page that
* hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the * hosts it, once: read as the host mounts, opened once its list is in (`ready`). From the list when the
@@ -22,7 +22,6 @@ export function useOpenLinkedPlayer(
open: (video: Video, inList: boolean) => void, open: (video: Video, inList: boolean) => void,
scope: string | number | null = null, scope: string | number | null = null,
): void { ): void {
const { t } = useTranslation();
const [linked] = useState(() => linkedPlayer()?.watch ?? null); const [linked] = useState(() => linkedPlayer()?.watch ?? null);
const [linkedScope] = useState(scope); const [linkedScope] = useState(scope);
// Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must // Opened, said to be gone or given up, once. A ref for the effect (a StrictMode re-run of it must
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
}); });
const openIt = useEffectEvent(open); const openIt = useEffectEvent(open);
const failed = useEffectEvent((err: unknown) => { const failed = useEffectEvent((err: unknown) => {
notifyActionError(err, t("player.linkedFailed")); notifyActionError(err, msg("player.linkedFailed"));
clearParams(...PLAYER_KEYS); clearParams(...PLAYER_KEYS);
}); });
useEffect(() => { useEffect(() => {
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks"; import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title // "Add to collection…" from a movie or show card's menu: a panel beside the card that ticks the title
// into or out of its library's manual collections, and names a new one — created with this title in // into or out of its library's manual collections, and names a new one — created with this title in
@@ -67,7 +68,7 @@ export default function PlexCollectionAddAt({
else await api.plexCollectionAddItem(c.id, ratingKey); else await api.plexCollectionAddItem(c.id, ratingKey);
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id)); qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
} catch (e) { } catch (e) {
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
made = true; made = true;
} catch (e) { } catch (e) {
made = madeOnPlex(e); made = madeOnPlex(e);
notifyActionError(e, t("plex.collection.editFailed")); notifyActionError(e, msg("plex.collection.editFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -80,7 +81,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
notifyActionError( notifyActionError(
e, e,
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"), msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
); );
} }
refresh(); refresh();
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
} catch (e) { } catch (e) {
// The create may already have landed when the add fails — `refresh()` in `finally` is what // The create may already have landed when the add fails — `refresh()` in `finally` is what
// makes that half-done state visible instead of leaving a playlist the dialog never lists. // makes that half-done state visible instead of leaving a playlist the dialog never lists.
notifyActionError(e, t("plex.playlistAdd.createFailed")); notifyActionError(e, msg("plex.playlistAdd.createFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -74,6 +74,8 @@ describe("a season's name", () => {
["Season 1", 1, "plex.seasonTitle(1)"], ["Season 1", 1, "plex.seasonTitle(1)"],
["Specials", 0, "plex.specials"], ["Specials", 0, "plex.specials"],
["Book One: Water", 1, "Book One: Water"], ["Book One: Water", 1, "Book One: Water"],
["Season 4", null, "Season 4"],
["", null, "plex.playlist.seasonUnknown"],
])("Plex's %j (season %s) reads %j", (title, n, shown) => { ])("Plex's %j (season %s) reads %j", (title, n, shown) => {
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />); render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
expect(screen.getByText(shown)).toBeTruthy(); expect(screen.getByText(shown)).toBeTruthy();
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
import { useCopyItems } from "../../components/ui/copyItems"; import { useCopyItems } from "../../components/ui/copyItems";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { raw } from "../../lib/notifications";
// A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the // A Plex title's ONE action list (docs/DESIGN.md "Action placement"): the right-click, the
// long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail // long-press, Shift+F10 and the card's "…" open a menu built here, so every poster grid and rail
@@ -151,7 +152,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
] ]
: []), : []),
...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })), ...copy(c.title, null).map((it) => ({ ...it, separatorBefore: !onInfo })),
...(withPin ? pin.item(pinPlex(c), c.title) : []), ...(withPin ? pin.item(pinPlex(c), raw(c.title)) : []),
]; ];
}; };
@@ -13,6 +13,7 @@ import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton"; import { IconButton } from "../../components/ui/IconButton";
import { MembershipMenu } from "../../components/ui/MembershipMenu"; import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { SURFACE } from "../../components/ui/surfaces"; import { SURFACE } from "../../components/ui/surfaces";
import { msg } from "../../lib/notifications";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline // A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's). // "new playlist" creation. Its body is the shared MembershipMenu (as the channel tag editor's).
@@ -122,7 +123,7 @@ function MembershipPanel({
old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)), old?.map((p) => (p.id === pl.id ? { ...p, has_video: !p.has_video } : p)),
); );
} catch (e) { } catch (e) {
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed")); notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
@@ -137,7 +138,7 @@ function MembershipPanel({
await addPlaylistItem(qc, pl, videoId); await addPlaylistItem(qc, pl, videoId);
made = true; made = true;
} catch (e) { } catch (e) {
notifyActionError(e, t("playlists.addFailed")); notifyActionError(e, msg("playlists.addFailed"));
} }
refresh(); refresh();
setBusy(false); setBusy(false);
+4 -4
View File
@@ -1,5 +1,5 @@
import i18n from "../../i18n"; import i18n from "../../i18n";
import { notify, resolveVideo } from "../../lib/notifications"; import { msg, notify, raw, resolveVideo } from "../../lib/notifications";
import type { Video, VideoStatus } from "../../lib/api"; import type { Video, VideoStatus } from "../../lib/api";
/** What a notice names the video by; any of it may be unknown to the surface that marked it. */ /** What a notice names the video by; any of it may be unknown to the surface that marked it. */
@@ -24,9 +24,9 @@ export function announceVideoState(
const hidden = status === "hidden"; const hidden = status === "hidden";
notify({ notify({
message: video?.title message: video?.title
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title }) ? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"), : msg(hidden ? "feed.hidden" : "feed.markedWatched"),
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo }, action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
meta: { meta: {
kind: hidden ? "video-hidden" : "video-watched", kind: hidden ? "video-hidden" : "video-watched",
videoId: id, videoId: id,
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
import { useNavigationActions } from "../navigation/navigationContext"; import { useNavigationActions } from "../navigation/navigationContext";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp"; import { VideoFollowUp, type VideoFollowUpState } from "./VideoFollowUp";
import { raw } from "../../lib/notifications";
// A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the // A video's ONE action list (docs/DESIGN.md "Action placement"). The right-click, the
// long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page // long-press, Shift+F10 and the card's "…" all open a menu built here, so the feed, a channel page
@@ -198,7 +199,7 @@ export function useVideoActions(handlers: VideoActionHandlers, open: boolean) {
}, },
] ]
: []), : []),
...(withPin ? pin.item(pinVideo(v.id), v.title ?? "") : []), ...(withPin ? pin.item(pinVideo(v.id), raw(v.title ?? "")) : []),
]; ];
}; };
@@ -23,7 +23,7 @@ vi.mock("../../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.
import OnboardingWizard from "./OnboardingWizard"; import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../../lib/api"; import { api, type Me } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications"; import { clearAll, notify, raw } from "../../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me; const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
document.body.appendChild(outside); document.body.appendChild(outside);
outside.focus(); outside.focus();
const { unmount } = mount(); const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus(); dismissToast.focus();
expect(document.activeElement).toBe(dismissToast); expect(document.activeElement).toBe(dismissToast);
@@ -126,7 +126,7 @@ describe("the onboarding wizard as a dialog", () => {
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason. // unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount(); mount();
const panel = screen.getByRole("dialog"); const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" })); act(() => void notify({ level: "error", message: raw("offline") }));
try { try {
const dismissToast = screen.getByTitle("notifications.dismiss"); const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false); expect(panel.contains(dismissToast)).toBe(false);
+144 -137
View File
@@ -1,7 +1,15 @@
import { execFileSync } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { RUN_STATUSES } from "../lib/activityUrlState"; import { RUN_STATUSES } from "../lib/activityUrlState";
import {
REFUSAL_BY_REASON,
REFUSAL_FAMILIES,
REFUSAL_FIXED,
REFUSAL_UNSHOWN,
} from "../lib/api/refusalCodes";
import { vocabKey } from "../lib/vocab"; import { vocabKey } from "../lib/vocab";
// Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these // Every id the BACKEND owns and the UI names must have its name in both languages. The UI looks these
@@ -21,6 +29,25 @@ import { vocabKey } from "../lib/vocab";
const backend = (file: string) => const backend = (file: string) =>
readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8"); readFileSync(new URL(`../../../backend/app/${file}`, import.meta.url), "utf-8");
// Every refusal the backend can answer, read from its syntax tree by `backend/tests/refusal_sites.py`
// (stdlib `ast`, on the host's Python, as the ruff lane is): each code with every reason it can be sent
// with — a reason in a variable followed to its `Literal` type, its helper's every call, its map — and
// what it could not follow, which fails below. It also hands back every `Literal` alias it read.
type RefusalScan = {
codes: Record<string, { reasons: string[]; unread: string[]; reasonless: string[] }>;
unreadCodes: string[];
errors: string[];
notRefusals: string[];
excluded: Record<string, number>;
aliases: Record<string, Record<string, string[]>>;
};
const scan = JSON.parse(
execFileSync("python", ["-I", "backend/tests/refusal_sites.py"], {
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
encoding: "utf-8",
}),
) as RefusalScan;
const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record< const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record<
string, string,
{ default: Record<string, unknown> } { default: Record<string, unknown> }
@@ -48,12 +75,11 @@ const keysOf = (lang: string): Set<string> => {
const specs = [ const specs = [
...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g), ...backend("sysconfig.py").matchAll(/ConfigSpec\(\s*"(\w+)",\s*"(\w+)",\s*"(\w+)",\s*"(\w+)"/g),
].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! })); ].map((m) => ({ key: m[1]!, type: m[2]!, group: m[3]!, attr: m[4]! }));
// A choice key's options, as config.py spells them: `<attr>: <Alias> = …` → `<Alias> = Literal[…]`. // A choice key's options, as config.py spells them: `<attr>: <Alias> = …`, the alias's values from the scan.
const configPy = backend("config.py"); const configPy = backend("config.py");
const choicesOf = (attr: string): string[] => { const choicesOf = (attr: string): string[] => {
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1]; const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1]; return (alias && scan.aliases["config.py"]?.[alias]) || [];
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
}; };
const choiceIds = specs const choiceIds = specs
.filter((s) => s.type === "choice") .filter((s) => s.type === "choice")
@@ -84,20 +110,10 @@ const runEnum = (name: string) =>
const runStatuses = runEnum("RunStatus"); const runStatuses = runEnum("RunStatus");
const runTriggers = runEnum("RunTrigger"); const runTriggers = runEnum("RunTrigger");
const quotaActions = enumValues("quota.py"); const quotaActions = enumValues("quota.py");
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from // The admin actions' refusals → `errors.admin.<reason>`: the codes recorded under that prefix.
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict const adminCodes = Object.entries(REFUSAL_FAMILIES)
// ruff wraps over several lines still matches. .filter(([, f]) => f.prefix === "errors.admin")
const adminCodes = [ .map(([code]) => code);
...(
readFileSync(new URL("../lib/api/core.ts", import.meta.url), "utf-8").match(
/ADMIN_REFUSALS = new Set\(\[([^\]]*)\]/,
)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
const adminRefusals = [
...backend("routes/admin.py").matchAll(/\{\s*"code":\s*"(\w+)",\s*"reason":\s*"(\w+)"/g),
].filter((m) => adminCodes.includes(m[1]!));
const adminReasons = [...new Set(adminRefusals.map((m) => m[2]!))];
// The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`. // The download failure catalog's host-specific entries → `downloads.errors.fail.catalog.<detail>`.
// A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part // A code holds a dot (`facebook.login_wall`), so the reverse check strips the prefix and the part
// rather than splitting on dots. // rather than splitting on dots.
@@ -118,102 +134,46 @@ const failCategories = [
]), ]),
]; ];
// What a structured 404 says is gone → `errors.notFound.<what>`: every `not_found("x")` and every // Every backend source, by its path under app/.
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources. const appPaths = (dir = ""): string[] =>
const routeFiles = (dir = "routes"): string[] => readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
withFileTypes: true, withFileTypes: true,
}).flatMap((e) => }).flatMap((e) =>
e.isDirectory() e.isDirectory()
? routeFiles(`${dir}/${e.name}`) ? e.name === "__pycache__"
? []
: appPaths(`${dir}${e.name}/`)
: e.name.endsWith(".py") : e.name.endsWith(".py")
? [`${dir}/${e.name}`] ? [`${dir}${e.name}`]
: [], : [],
); );
const routeSources = (): string[] => routeFiles().map(backend);
// The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler // The words a job's result gives for not doing its work → `scheduler.skip.<vocabKey(word)>` (the Scheduler
// page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` / // page, the run notice and the audit row name them; lib/jobResult). Read as the `"skipped": "…"` /
// `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal // `"reason": "…"` literals of the non-HTTP backend: under routes/ (and main.py) a `reason` is a refusal
// code, which `errors.*` names. A word from an exception's text is not a literal and is not covered. // code, which `errors.*` names. A word from an exception's text is not a literal and is not covered.
const appFiles = (dir: string): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? e.name === "routes" || e.name === "__pycache__"
? []
: appFiles(`${dir}${e.name}/`)
: e.name.endsWith(".py") && `${dir}${e.name}` !== "main.py"
? [`${dir}${e.name}`]
: [],
);
const skipWords = [ const skipWords = [
...new Set( ...new Set(
appFiles("").flatMap((f) => appPaths()
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!), .filter((f) => !f.startsWith("routes/") && f !== "main.py")
), .flatMap((f) =>
), [...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
];
// A target the scan cannot read — `what=SOME_CONSTANT` — is a 404 no locale is checked for (one hid
// `debug_session`). Only the helpers' own pass-through in `_common.py` may name it by variable.
const opaqueWhats = routeFiles()
.filter((f) => !f.endsWith("/_common.py"))
.flatMap((f) =>
[...backend(f).matchAll(/not_found\((?!"|\))[^)]*\)|what=(?!")\S+/g)].map(
(m) => `${f}: ${m[0]}`,
),
);
const goneWhats = [
...new Set(
routeSources().flatMap((src) =>
[...src.matchAll(/not_found\("(\w+)"\)|what="(\w+)"/g)].map((m) => (m[1] ?? m[2])!),
),
),
];
// Every generic refusal → `errors.refused.<reason>`: each `refused(<status>, "<reason>")` anywhere in
// the backend (not `_refused`, a router's own family), plus the values of the settings map, which
// `setting_refused` picks from at runtime, and every `FolderError` reason.
const appSources = (dir = ""): string[] =>
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
withFileTypes: true,
}).flatMap((e) =>
e.isDirectory()
? appSources(`${dir}${e.name}/`)
: e.name.endsWith(".py")
? [backend(`${dir}${e.name}`)]
: [],
);
const refusedReasons = [
...new Set([
...appSources().flatMap((src) =>
[...src.matchAll(/(?<![\w.])refused\(\s*\d+,\s*"(\w+)"/g)].map((m) => m[1]!),
),
// A folder change the rules refuse travels as `FolderError("<reason>")` and is answered with
// `refused(409, e.reason)`, which the pattern above cannot read.
...appSources().flatMap((src) =>
[...src.matchAll(/FolderError\("(\w+)"\)/g)].map((m) => m[1]!),
),
...[
...(backend("refusal.py").split("_SETTING_REASONS = {")[1]?.split("}")[0] ?? "").matchAll(
/:\s*"(\w+)"/g,
), ),
].map((m) => m[1]!),
]),
];
// A refused YouTube action → `errors.youtube.<reason>`, the reasons read from their Literal.
const youtubeReasons = [
...(
backend("youtube/client.py").match(/^YouTubeRefusal = Literal\[([^\]]*)\]/m)?.[1] ?? ""
).matchAll(/"(\w+)"/g),
].map((m) => m[1]!);
// A clip the server will not cut → `clip.errors.<reason>`: every `ClipError("<reason>"…)` in the backend
// (a reason may carry its numbers after it: `ClipError("too_long", limit_s=…)`).
const clipReasons = [
...new Set(
appSources().flatMap((src) => [...src.matchAll(/ClipError\("(\w+)"[,)]/g)].map((m) => m[1]!)),
), ),
]; ];
const backendCodes = Object.keys(scan.codes);
const recordedCodes = [
...Object.keys(REFUSAL_FAMILIES),
...Object.keys(REFUSAL_BY_REASON),
...Object.keys(REFUSAL_FIXED),
...Object.keys(REFUSAL_UNSHOWN),
];
// The codes whose reason picks the sentence, and what each can carry as its reason.
const reasoned = [...Object.keys(REFUSAL_FAMILIES), ...Object.keys(REFUSAL_BY_REASON)];
const reasonsOf: Record<string, string[]> = Object.fromEntries(
reasoned.map((code) => [code, scan.codes[code]?.reasons ?? []]),
);
// The closed English vocabularies the backend WRITES into data, named through `vocab.<group>` // The closed English vocabularies the backend WRITES into data, named through `vocab.<group>`
// (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named — // (lib/vocab) by their letters-and-digits key. A word the backend adds shows in English until named —
// so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can // so each list is read from its source here: the TMDB crew jobs kept, every auto-tag the tagger can
@@ -275,10 +235,11 @@ describe("backend ids have their names in both languages", () => {
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"])); expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"])); expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"])); expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"])); expect(reasonsOf.not_found).toEqual(
expect(opaqueWhats).toEqual([]); expect.arrayContaining(["playlist", "download", "job", "user", "debug_session"]),
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]); );
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"])); expect(reasonsOf.youtube).toEqual(["gone", "quota", "reconnect", "unavailable"]);
expect(reasonsOf.clip).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
expect(crewJobs).toEqual( expect(crewJobs).toEqual(
expect.arrayContaining(["Director", "Creator", "Director of Photography"]), expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
); );
@@ -294,11 +255,46 @@ describe("backend ids have their names in both languages", () => {
"no service user", "no service user",
]), ]),
); );
expect(refusedReasons).toEqual( expect(reasonsOf.refused).toEqual(
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]), expect.arrayContaining([
"weak_password",
"setting_too_low",
"plex_write_failed",
"file_folder_gone",
]),
); );
// Every code the frontend localizes is raised somewhere the scan can see. });
expect(adminCodes.filter((c) => !adminRefusals.some((m) => m[1] === c))).toEqual([]);
it("every refusal code the backend answers is said by exactly one record, and no record names another", () => {
expect(backendCodes).toEqual(expect.arrayContaining(["refused", "playlist", "too_large"]));
expect(backendCodes.filter((c) => recordedCodes.filter((r) => r === c).length !== 1)).toEqual(
[],
);
expect(recordedCodes.filter((c) => !backendCodes.includes(c))).toEqual([]);
});
it("every refusal code and reason is one the scan can follow", () => {
// The modules set aside as not refusals still use the key.
expect(Object.keys(scan.excluded).sort()).toEqual(scan.notRefusals);
expect(scan.unreadCodes).toEqual([]);
expect(scan.errors).toEqual([]);
// A reason it could not follow is a sentence nobody is asked for, unless no screen says that code.
expect(
Object.entries(scan.codes)
.filter(([code]) => !(code in REFUSAL_UNSHOWN))
.flatMap(([, c]) => c.unread),
).toEqual([]);
// A code whose reason picks the sentence, sent without one, says only its fallback.
expect(reasoned.flatMap((code) => scan.codes[code]?.reasonless ?? [])).toEqual([]);
});
it("every code whose reason picks the sentence is read with a reason (a scan that matches nothing is green over nothing)", () => {
expect(Object.entries(reasonsOf).filter(([, rs]) => rs.length === 0)).toEqual([]);
});
it("a code whose reasons share sentences names every reason it is sent with, and no other", () => {
for (const [code, { reasons }] of Object.entries(REFUSAL_BY_REASON))
expect(Object.keys(reasons).sort(), code).toEqual([...reasonsOf[code]!].sort());
}); });
it("the run list's status chips are the server's run statuses", () => { it("the run list's status chips are the server's run statuses", () => {
@@ -418,33 +414,6 @@ describe("backend ids have their names in both languages", () => {
expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]); expect([...named].filter((d) => !failDetails.includes(d))).toEqual([]);
}); });
it(`${lang}: every "that is gone" a 404 names, and every YouTube refusal`, () => {
expect(missing(goneWhats.map((w) => `errors.notFound.${w}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.notFound")].filter(
(w) => w !== "generic" && !goneWhats.includes(w),
),
).toEqual([]);
expect(missing(youtubeReasons.map((r) => `errors.youtube.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.youtube")].filter((r) => !youtubeReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every generic refusal the backend can answer, and none it cannot`, () => {
expect(missing(refusedReasons.map((r) => `errors.refused.${r}`))).toEqual([]);
expect(
[...idsUnder(keys, "errors.refused")].filter((r) => !refusedReasons.includes(r)),
).toEqual([]);
});
it(`${lang}: every reason a clip is refused, and none it cannot be`, () => {
expect(missing(clipReasons.map((r) => `clip.errors.${r}`))).toEqual([]);
expect([...idsUnder(keys, "clip.errors")].filter((r) => !clipReasons.includes(r))).toEqual(
[],
);
});
it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => { it(`${lang}: every crew job, auto-tag and built-in profile the backend writes, and no other`, () => {
for (const [group, words] of Object.entries(VOCAB)) { for (const [group, words] of Object.entries(VOCAB)) {
const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`); const wanted = words.map((w) => `vocab.${group}.${vocabKey(w)}`);
@@ -456,11 +425,49 @@ describe("backend ids have their names in both languages", () => {
} }
}); });
it(`${lang}: every admin refusal reason`, () => { it(`${lang}: every refusal the backend can answer has its sentence`, () => {
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]); const wanted = [
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual( ...Object.entries(REFUSAL_FAMILIES).flatMap(([code, f]) => [
[], ...reasonsOf[code]!.map((r) => `${f.prefix}.${r}${"suffix" in f ? f.suffix : ""}`),
); f.fallback,
]),
...Object.values(REFUSAL_BY_REASON).flatMap((f) => [
...Object.values(f.reasons),
f.fallback,
]),
...Object.values(REFUSAL_FIXED),
"errors.generic",
];
expect(missing([...new Set(wanted)])).toEqual([]);
});
it(`${lang}: no sentence for a refusal reason the backend cannot send`, () => {
// By prefix: the admin actions share `errors.admin`. A suffixed family shares its prefix with more
// than its refusals — the sign-in screens' `gate`, `addressLabel` — so only an id with the
// refusal's own key counts as a reason there.
const byPrefix = new Map<
string,
{ suffix: string; reasons: Set<string>; spare: Set<string> }
>();
for (const [code, f] of Object.entries(REFUSAL_FAMILIES)) {
const p = byPrefix.get(f.prefix) ?? {
suffix: "suffix" in f ? f.suffix : "",
reasons: new Set<string>(),
spare: new Set<string>(),
};
for (const r of reasonsOf[code]!) p.reasons.add(r);
if (f.fallback.startsWith(`${f.prefix}.`))
p.spare.add(f.fallback.slice(f.prefix.length + 1));
byPrefix.set(f.prefix, p);
}
for (const [prefix, { suffix, reasons, spare }] of byPrefix)
expect(
[...idsUnder(keys, prefix)].filter(
(r) =>
(!suffix || keys.has(`${prefix}.${r}${suffix}`)) && !spare.has(r) && !reasons.has(r),
),
prefix,
).toEqual([]);
}); });
} }
}); });
@@ -219,6 +219,7 @@
"edit": { "edit": {
"empty_edit": "Choose a trim range or crop area first", "empty_edit": "Choose a trim range or crop area first",
"source_not_ready": "The source download isn't ready to edit", "source_not_ready": "The source download isn't ready to edit",
"not_media": "Only video and audio can be edited",
"generic": "Couldn't create that edit" "generic": "Couldn't create that edit"
}, },
"upload": { "upload": {
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Play the show", "playShow": "Play the show",
"playSeason": "Play the season", "playSeason": "Play the season",
"removeFromPlaylist": "Remove from the playlist", "removeFromPlaylist": "Remove from the playlist",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "“{{title}}” removed from the playlist", "removedToast": "“{{title}}” removed from the playlist",
"undoRemoveFailed": "Couldn't put it back in the playlist" "undoRemoveFailed": "Couldn't put it back in the playlist"
}, },
@@ -219,6 +219,7 @@
"edit": { "edit": {
"empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet", "empty_edit": "Előbb válassz vágási tartományt vagy körbevágási területet",
"source_not_ready": "A forrásletöltés még nem szerkeszthető", "source_not_ready": "A forrásletöltés még nem szerkeszthető",
"not_media": "Csak videó és hang szerkeszthető",
"generic": "Nem sikerült létrehozni a vágást" "generic": "Nem sikerült létrehozni a vágást"
}, },
"upload": { "upload": {
+1
View File
@@ -344,6 +344,7 @@
"playShow": "Sorozat lejátszása", "playShow": "Sorozat lejátszása",
"playSeason": "Évad lejátszása", "playSeason": "Évad lejátszása",
"removeFromPlaylist": "Eltávolítás a listáról", "removeFromPlaylist": "Eltávolítás a listáról",
"seasonOfShow": "{{show}} · {{season}}",
"removedToast": "„{{title}}” eltávolítva a listáról", "removedToast": "„{{title}}” eltávolítva a listáról",
"undoRemoveFailed": "Nem sikerült visszatenni a listára" "undoRemoveFailed": "Nem sikerült visszatenni a listára"
}, },
@@ -3,6 +3,8 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { REFUSAL_FAMILIES, type RefusalFamily } from "../lib/api/refusalCodes";
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either // Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary, // one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step // the old login page, a pager the DataTable replaced), each a string a translator would keep in step
@@ -49,9 +51,14 @@ const code = sources(SRC).join("\n");
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!)); const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`, // Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated. // `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
// A refusal's sentence is built from its record (`lib/api/refusalCodes`) rather than a template the
// scan could read, so each family's prefix is a shape of its own.
const shapes = [ const shapes = [
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!), ...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`), ...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
...Object.values(REFUSAL_FAMILIES).map(
(f: RefusalFamily) => `${f.prefix}.\${reason}${f.suffix ?? ""}`,
),
] ]
.filter((s) => s.includes("${")) .filter((s) => s.includes("${"))
.map((s) => s.replace(/\$\{[^}]*\}/g, "*")); .map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
@@ -13,6 +13,7 @@ import {
reloadAfterRefusal, reloadAfterRefusal,
} from "./entryUrl"; } from "./entryUrl";
import { HttpError } from "./api/core"; import { HttpError } from "./api/core";
import { raw } from "./notifications";
const notice = (search: string, hash = "") => const notice = (search: string, hash = "") =>
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash)); accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
@@ -27,13 +28,13 @@ describe("a switch of account, told to the account signed in", () => {
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({ expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
level: "info", level: "info",
key: "header.account.switchRequested", key: "header.account.switchRequested",
values: { email: "b+tag@example.com" }, values: { email: raw("b+tag@example.com") },
}); });
}); });
it("names the address the request lives under when that is another spelling", () => { it("names the address the request lives under when that is another spelling", () => {
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({ expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
email: "b.ob@x.com", email: raw("b.ob@x.com"),
}); });
}); });
+46 -24
View File
@@ -4,12 +4,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
// Spy on the toast sink so we can assert exactly what each error maps to. // Spy on the toast sink so we can assert exactly what each error maps to.
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { HttpError, api } from "./api"; import { HttpError, api } from "./api";
import { alreadyReported } from "./api/core"; import { alreadyReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw, sayNotif } from "./notifications";
import { localizeDetail } from "./api/refusalText";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors"; import { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
import i18n from "../i18n"; import i18n from "../i18n";
@@ -32,10 +36,19 @@ describe("a Plex collection name that is already taken", () => {
); );
const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e); const err = await api.plexCreateCollection("1", "Marvel", "m1").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "EDIT_FAILED"); notifyActionError(err, raw("EDIT_FAILED"));
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: "EDIT_FAILED", message: i18n.t("plex.collection.nameTaken") }], [
{
level: "error",
title: raw("EDIT_FAILED"),
message: { detail: { code: "collection", reason: "name_taken" } },
},
],
]); ]);
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
i18n.t("plex.collection.nameTaken"),
);
expect(reportError).not.toHaveBeenCalled(); expect(reportError).not.toHaveBeenCalled();
}); });
}); });
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
); );
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e); const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
notifyMock.mockClear(); notifyMock.mockClear();
notifyActionError(err, "FAILED"); notifyActionError(err, raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
it("does toast one the caller was left to handle", () => { it("does toast one the caller was left to handle", () => {
notifyActionError(new HttpError(404, "gone"), "FAILED"); notifyActionError(new HttpError(404, "gone"), raw("FAILED"));
expect(notifyMock).toHaveBeenCalled(); expect(notifyMock).toHaveBeenCalled();
}); });
it("reports one failure once, however many callers try", () => { it("reports one failure once, however many callers try", () => {
// What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke. // What keeps the app-wide mutation reporter quiet for a mutation whose own onError already spoke.
const err = new HttpError(404, "gone"); const err = new HttpError(404, "gone");
notifyActionError(err, "FIRST"); notifyActionError(err, raw("FIRST"));
notifyActionError(err, "SECOND"); notifyActionError(err, raw("SECOND"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(alreadyReported(err)).toBe(true); expect(alreadyReported(err)).toBe(true);
}); });
it("says nothing about an upload the user cancelled", () => { it("says nothing about an upload the user cancelled", () => {
notifyActionError(new HttpError(0, "aborted"), "FAILED"); notifyActionError(new HttpError(0, "aborted"), raw("FAILED"));
expect(notifyMock).not.toHaveBeenCalled(); expect(notifyMock).not.toHaveBeenCalled();
}); });
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
// `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever // `reportFailure` leaves 403/404/429 to the caller, so for exactly those statuses whatever
// English a route hand-wrote went straight into the toast — "No user with that email." in an // English a route hand-wrote went straight into the toast — "No user with that email." in an
// otherwise Hungarian UI, while the caller's translated fallback sat unused. // otherwise Hungarian UI, while the caller's translated fallback sat unused.
notifyActionError(new HttpError(404, "No user with that email."), "TRANSLATED"); notifyActionError(new HttpError(404, "No user with that email."), raw("TRANSLATED"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED", title: raw("TRANSLATED"),
message: i18n.t("errors.notFound.generic"), message: msg("errors.notFound.generic"),
}); });
}); });
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => { it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language // The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
// AND more specific than anything a status can say — quota numbers, which row is gone. // reader's language AND more specific than anything a status can say — quota numbers, which row
// is gone.
const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", { const err = new HttpError(404, "Ez a csatorna már nincs a könyvtárban.", {
code: "not_found", code: "not_found",
reason: "channel", reason: "channel",
}); });
notifyActionError(err, "TRANSLATED FALLBACK"); notifyActionError(err, raw("TRANSLATED FALLBACK"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: "TRANSLATED FALLBACK", title: raw("TRANSLATED FALLBACK"),
message: "Ez a csatorna már nincs a könyvtárban.", message: { detail: { code: "not_found", reason: "channel" } },
}); });
// Said at display through localizeDetail, in the language of that moment.
expect(sayNotif(notifyMock.mock.calls[0]![0].message)).toBe(
localizeDetail({ code: "not_found", reason: "channel" }),
);
}); });
}); });
@@ -114,17 +132,21 @@ describe("failureReason — why a write failed, when the server did not say", ()
[0, "errors.reason.unreachable"], // uploadReq's network failure [0, "errors.reason.unreachable"], // uploadReq's network failure
[500, "errors.generic"], [500, "errors.generic"],
])("a %s reads %s", (status, key) => { ])("a %s reads %s", (status, key) => {
expect(failureReason(new HttpError(status, "hand-written English"))).toBe(i18n.t(key)); const reason = failureReason(new HttpError(status, "hand-written English"));
expect(reason).toEqual(msg(key));
expect(sayNotif(reason)).toBe(i18n.t(key));
}); });
it("a request that never reached the server reads as unreachable", () => { it("a request that never reached the server reads as unreachable", () => {
expect(failureReason(new TypeError("Failed to fetch"))).toBe( expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
i18n.t("errors.reason.unreachable"), i18n.t("errors.reason.unreachable"),
); );
}); });
it("anything else is the app's own fault, not the server's", () => { it("anything else is the app's own fault, not the server's", () => {
expect(failureReason(new Error("key import failed"))).toBe(i18n.t("errors.reason.unexpected")); expect(sayNotif(failureReason(new Error("key import failed")))).toBe(
i18n.t("errors.reason.unexpected"),
);
}); });
it("every reason exists in both languages", () => { it("every reason exists in both languages", () => {
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
describe("notifyKnownFailure", () => { describe("notifyKnownFailure", () => {
it("shows the caller's specific sentence and marks the failure reported", () => { it("shows the caller's specific sentence and marks the failure reported", () => {
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" }); const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
notifyKnownFailure(err, "EMAIL IS OFF"); notifyKnownFailure(err, raw("EMAIL IS OFF"));
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" }); expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
notifyActionError(err, "GENERIC"); notifyActionError(err, raw("GENERIC"));
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
}); });
}); });
+9 -11
View File
@@ -1,26 +1,24 @@
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the // From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it. // barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core"; import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, type NotifText } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]); const GATEWAY = new Set([502, 503, 504]);
/** /**
* WHY a write failed, in the reader's language — the line under the toast's title. * WHY a write failed — the line under the toast's title, said in the reader's language when shown.
* *
* The server's own reason wins: `req` has already run the structured detail through * The server's own reason wins: the structured detail, which `localizeDetail` says, and it is the most specific thing there is to say (quota numbers, which row is
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string * gone). Every refusal the app answers is structured now (backend `app/refusal.py`), and a string
* detail never reaches `HttpError.detail` (see there) — the hand-written English that once put * detail never reaches `HttpError.detail` (see there) — the hand-written English that once put
* "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still * "No user with that email." into a Hungarian UI has nowhere left to come from. `detailData` still
* gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status * gates it: status-0 errors carry a local sentinel in `detail`, not a reason. Otherwise the status
* says it. * says it.
*/ */
export function failureReason(err: unknown): string { export function failureReason(err: unknown): NotifText {
const t = i18n.t.bind(i18n); const t = msg;
if (err instanceof HttpError) { if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail; if (err.detailData != null && err.detail) return { detail: err.detailData };
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the // 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say. // change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable"); if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
@@ -36,13 +34,13 @@ export function failureReason(err: unknown): string {
/** /**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title * Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says * names WHAT failed (the caller's "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including * WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient. * the app-wide mutation reporter in lib/queryClient.
*/ */
export function notifyActionError( export function notifyActionError(
err: unknown, err: unknown,
title: string, title: NotifText,
level: "error" | "warning" = "error", level: "error" | "warning" = "error",
): void { ): void {
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own // Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
@@ -57,7 +55,7 @@ export function notifyActionError(
* instance cannot send email", "someone registered on that address") — shown as it is, and marked, * instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it. * so the app-wide mutation reporter does not put a second, generic toast next to it.
*/ */
export function notifyKnownFailure(err: unknown, message: string): void { export function notifyKnownFailure(err: unknown, message: NotifText): void {
if (alreadyReported(err)) return; if (alreadyReported(err)) return;
notify({ level: "error", message }); notify({ level: "error", message });
markReported(err); markReported(err);
+6 -1
View File
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
})); }));
vi.mock("../errorDialog", () => ({ reportError })); vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts })); vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify,
remove,
getActiveToasts,
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } })); vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
import { setDebugSession } from "../log"; import { setDebugSession } from "../log";
+4 -119
View File
@@ -2,9 +2,9 @@
// error, structured-error localization, the connectivity toast, the identity headers, // error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module, // and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles. // so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications"; import { getActiveToasts, msg, notify, remove } from "../notifications";
import i18n from "../../i18n"; import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format"; import { localizeDetail } from "./refusalText";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log"; import { currentDebugSession, newRequestId } from "../log";
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
? (err.detailData as { reason?: string } | undefined)?.reason ? (err.detailData as { reason?: string } | undefined)?.reason
: undefined; : undefined;
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as
| { code?: string; reason?: string; limit?: number; limit_s?: number; min?: number }
| null
| undefined;
if (o?.code === "quota") {
if (o.reason === "max_bytes") {
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
return t("downloads.errors.quota.max_bytes", { size: gb });
}
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
return t("downloads.errors.quota.generic");
}
if (o?.code === "edit")
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
// A clip the server will not cut (downloads/clip): the range, the frame, the source. An unknown
// reason reads as the plain failure rather than a key; a cap comes with the refusal (`limit_s`).
if (o?.code === "clip")
return t(`clip.errors.${o.reason}`, {
defaultValue: t("clip.failed"),
minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, i18n.language, 1, 0),
});
if (o?.code === "cookies")
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
// Password sign-in refusals. The card renders the account-shaped ones as a full panel
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
// gets. A reason this build doesn't know falls through to the generic message rather than showing
// a raw key, which is what an i18n miss would otherwise put on screen.
if (o?.code === "login")
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
if (o?.code === "reset")
return o.reason === "weak_password"
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
: t("welcome.auth.deadLink.short");
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
// because the ceiling and the route's own cap are the same figure and the edge answers first —
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
// the generic "couldn't carry out that action", the one message no retry can fix.
//
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
// to read this at all depends on the client having finished sending: measured readable up to
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
// happens when that check could not run.
//
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
// about a file told the issue composer's user to pick a smaller one — of nothing. The
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
if (o?.code === "too_large") return t("errors.tooLarge");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// The one-step "move to another list". Both reasons mean the same thing to a reader —
// the list moved under you — but they are different enough to be worth naming separately, and an
// unknown reason falls back to the plain failure rather than printing a key.
if (o?.code === "playlist_move")
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
// A Plex collection name already taken in that library (the one collection-edit refusal).
if (o?.code === "collection")
return o.reason === "name_taken"
? t("plex.collection.nameTaken")
: t("plex.collection.editFailed");
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
if (o?.code === "youtube")
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
// reads as the generic failure rather than a key.
if (o?.code === "refused")
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
// An admin action on an account or an invite that the server refused: activating an address that
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
// "try again", which no retry could fix.
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
// generically rather than printing a key.
if (o?.code === "not_found")
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}
// A single, self-resolving "connection lost" status: while the server is unreachable we // A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the // show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps // server again we remove it. This makes good on its "clears once it's back" copy and keeps
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
if (id !== null) remove(id); if (id !== null) remove(id);
connectivityNotifId = notify({ connectivityNotifId = notify({
level: "error", level: "error",
title: i18n.t("errors.offline.title"), title: msg("errors.offline.title"),
message: i18n.t("errors.offline.body"), message: msg("errors.offline.body"),
transient: true, transient: true,
}); });
} }
+2 -1
View File
@@ -7,7 +7,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../errorDialog", () => ({ reportError: vi.fn() })); vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
vi.mock("../notifications", () => ({ vi.mock("../notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../notifications")>()),
notify: vi.fn(() => 1), notify: vi.fn(() => 1),
remove: vi.fn(), remove: vi.fn(),
getActiveToasts: vi.fn((): { id: number }[] => []), getActiveToasts: vi.fn((): { id: number }[] => []),
+104
View File
@@ -0,0 +1,104 @@
// Which sentence answers each server refusal (`{ code, reason, … }`), as data: `localizeDetail` says a
// refusal through these records, and `i18n/backendIds.contract.test.ts` reads every code and reason the
// backend can send and requires each to land here with its sentence in both languages. A refusal that
// fell through to the generic "couldn't carry out that action" used to stay unnoticed — the language
// parity test is green when EN and HU miss the same key.
//
// No imports: the contract test reads this module in a plain node run, without i18n.
/** A code whose reasons each name their own sentence: `<prefix>.<reason><suffix>`. */
export type RefusalFamily = { prefix: string; suffix?: string; fallback: string };
export const REFUSAL_FAMILIES = {
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and the
// rest of the detail its values.
refused: { prefix: "errors.refused", fallback: "errors.generic" },
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it — wait
// for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
youtube: { prefix: "errors.youtube", fallback: "errors.youtube.unavailable" },
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's row
// 404s the same way); `reason` names WHAT is gone.
not_found: { prefix: "errors.notFound", fallback: "errors.notFound.generic" },
// A clip the server will not cut (downloads/clip): the range, the frame, the source; a cap comes
// with the refusal (`limit_s`).
clip: { prefix: "clip.errors", fallback: "clip.failed" },
// An admin action on an account or an invite: an address no longer approved, a link or invitation
// with nothing to go to or no mail to go by. The toast's title names the button; this is the WHY,
// which used to be the generic "try again" no retry could fix. One namespace for the three codes.
activate: { prefix: "errors.admin", fallback: "errors.generic" },
resend: { prefix: "errors.admin", fallback: "errors.generic" },
invite_send: { prefix: "errors.admin", fallback: "errors.generic" },
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
playlist: { prefix: "playlists.errors", fallback: "errors.generic" },
// The one-step "move to another list": both reasons mean the list moved under you, named apart.
playlist_move: { prefix: "playlists.moveErrors", fallback: "playlists.moveFailed" },
edit: { prefix: "downloads.errors.edit", fallback: "downloads.errors.edit.generic" },
cookies: { prefix: "downloads.errors.cookies", fallback: "downloads.errors.cookies.generic" },
// A download URL the server refuses to fetch. The reasons are deliberately distinct — "we could not
// look that host up" is a different thing to tell someone than "we will not fetch from that address".
url_blocked: {
prefix: "downloads.errors.urlBlocked",
fallback: "downloads.errors.urlBlocked.generic",
},
// Password sign-in refusals, in one line. The sign-in card renders the account-shaped ones as a full
// panel off `detailData`; this is the form every other consumer (and the card's fallback) gets.
login: {
prefix: "welcome.auth.blocked",
suffix: ".short",
fallback: "welcome.auth.genericError",
},
} satisfies Record<string, RefusalFamily>;
/** A code whose few reasons share sentences: each reason the backend sends is named here. */
export const REFUSAL_BY_REASON = {
quota: {
reasons: {
max_bytes: "downloads.errors.quota.max_bytes",
max_jobs: "downloads.errors.quota.max_jobs",
},
fallback: "downloads.errors.quota.generic",
},
// The set-password link. Its failure is one a user meets by simply clicking an old link, so it may
// not answer in hardcoded English under a bilingual UI.
reset: {
reasons: { weak_password: "welcome.auth.weakPassword", invalid: "welcome.auth.deadLink.short" },
fallback: "welcome.auth.deadLink.short",
},
// A Plex collection name already taken in that library (the one collection-edit refusal).
collection: {
reasons: { name_taken: "plex.collection.nameTaken" },
fallback: "plex.collection.editFailed",
},
} satisfies Record<string, { reasons: Record<string, string>; fallback: string }>;
/** A code with one sentence, whatever else it carries. */
export const REFUSAL_FIXED = {
// The email confirmation link — like `reset`, met by clicking an old link.
verify: "welcome.auth.verifyInvalid",
// Local-upload ingest: the per-file cap carries the limit in bytes; empty is a bare code.
upload_too_large: "downloads.errors.upload.tooLarge",
upload_empty: "downloads.errors.upload.empty",
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
// number. On a DEFAULT install it is what an over-cap upload gets, because the ceiling and the
// route's own cap are the same figure and the edge answers first — the numbered `upload_too_large`
// answers only once an admin has LOWERED the knob (bodylimit.ROUTE_MAX_BODY_BYTES).
// A BACKSTOP, not the plan: the middleware answers without draining, so whether the browser reads
// this at all depends on the client having finished sending (readable up to 200 MiB over loopback,
// a bare `onerror` through a dev proxy). That is why the upload dialog checks the file against
// `upload_max_bytes` BEFORE sending. The sentence stays PAYLOAD-NEUTRAL: a JSON form gets it too
// (`debug_sessions` refuses an over-size draft with the same code), and advice about a file told the
// issue composer's user to pick a smaller one — of nothing.
too_large: "errors.tooLarge",
// A manual sync trigger on cooldown: a pace limit rather than an error, and the copy says so.
sync_cooldown: "errors.syncCooldown",
} satisfies Record<string, string>;
/** Codes no screen prints, so they read as the generic failure — each with why nobody reads it. */
export const REFUSAL_UNSHOWN = {
subtitle: "answers a <track> src; the player polls the track's status and never reads this body",
json_required: "answers the client log beacon, which the UI always sends as JSON",
diagnostics_off: "answers the client log beacon; the client stops streaming on it, silently",
rate_limited: "answers the client log beacon over its rate; dropped lines are not reported",
bad_cursor: "a list cursor the UI itself wrote; only a hand-edited request gets this",
invalid_input: "a NUL byte in a request, which no form of the UI can send",
} satisfies Record<string, string>;
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
//
// A server refusal said through the records (`refusalCodes`), in both languages, by the real i18n —
// one case per record shape, so moving the dispatch into data changed what no reader sees.
import { afterEach, describe, expect, it } from "vitest";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { localizeDetail } from "./refusalText";
afterEach(async () => {
await i18n.changeLanguage("en");
});
describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
const say = async (d: unknown) => {
await i18n.changeLanguage(lng);
return localizeDetail(d);
};
it("names an edit refused for a file that is not video or audio", async () => {
const said = await say({ code: "edit", reason: "not_media" });
expect(said).toBe(i18n.t("downloads.errors.edit.not_media"));
expect(said).not.toBe(i18n.t("downloads.errors.edit.generic"));
});
it("says a family's reason, and its own fallback for a reason it does not know", async () => {
expect(await say({ code: "playlist_move", reason: "same_playlist" })).toBe(
i18n.t("playlists.moveErrors.same_playlist"),
);
expect(await say({ code: "playlist_move", reason: "nope" })).toBe(
i18n.t("playlists.moveFailed"),
);
});
it("reads a sign-in refusal's one-line form", async () => {
expect(await say({ code: "login", reason: "suspended" })).toBe(
i18n.t("welcome.auth.blocked.suspended.short"),
);
});
it("puts a refusal's numbers into its sentence", async () => {
expect(await say({ code: "quota", reason: "max_jobs", limit: 7 })).toContain("7");
expect(await say({ code: "reset", reason: "weak_password", min: 12 })).toContain("12");
expect(await say({ code: "refused", reason: "view_name_taken", name: "Esti" })).toContain(
"Esti",
);
});
it("puts a download cap in GB and an upload cap in bytes, in the reader's number format", async () => {
expect(await say({ code: "quota", reason: "max_bytes", limit: 1.5 * 2 ** 30 })).toContain(
lng === "hu" ? "1,5" : "1.5",
);
const upload = await say({ code: "upload_too_large", limit: 5_242_880 });
expect(upload).toContain(formatBytes(5_242_880, lng));
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
});
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
expect(await say({ code: "quota", reason: "nope" })).toBe(
i18n.t("downloads.errors.quota.generic"),
);
expect(await say({ code: "collection", reason: "nope" })).toBe(
i18n.t("plex.collection.editFailed"),
);
});
it("says a shared-sentence code's fallback, and a fixed code's one sentence", async () => {
expect(await say({ code: "reset", reason: "invalid" })).toBe(
i18n.t("welcome.auth.deadLink.short"),
);
expect(await say({ code: "sync_cooldown" })).toBe(i18n.t("errors.syncCooldown"));
});
it("reads a code no screen shows, or one it does not know, as the generic failure", async () => {
expect(await say({ code: "bad_cursor" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "never_sent" })).toBe(i18n.t("errors.generic"));
expect(await say({ code: "constructor" })).toBe(i18n.t("errors.generic"));
expect(await say(null)).toBe(i18n.t("errors.generic"));
});
});
+73
View File
@@ -0,0 +1,73 @@
// A server refusal ({ code, reason, … }) in the reader's language. Its own module, not the request
// core's: a notice keeps the refusal it reports and says it again at display, in the language of the
// moment, and the notice store cannot import the request core that imports it.
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import {
REFUSAL_BY_REASON,
REFUSAL_FAMILIES,
REFUSAL_FIXED,
type RefusalFamily,
} from "./refusalCodes";
type Detail = {
code?: string;
reason?: string;
limit?: number;
limit_s?: number;
min?: number;
} | null;
// Which sentence answers which code lives in `refusalCodes` — the records the backendIds contract holds
// against every code and reason the backend can send. A Map, so a code is looked up as data only.
const families = new Map<string, RefusalFamily>(Object.entries(REFUSAL_FAMILIES));
const byReason = new Map(
Object.entries(REFUSAL_BY_REASON).map(([code, f]) => [
code,
{ reasons: new Map<string, string>(Object.entries(f.reasons)), fallback: f.fallback },
]),
);
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
const lang = i18n.language;
if (o.code === "refused") return { ...o };
if (o.code === "clip")
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
if (o.code === "quota")
return o.reason === "max_bytes"
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }
: { limit: o.limit };
if (o.code === "reset") return { min: o.limit ?? o.min };
if (o.code === "upload_too_large") return { size: formatBytes(o.limit, lang) };
return {};
}
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. A reason this
// build does not know reads as its code's fallback sentence rather than a raw key, and a code it does
// not know (or one no screen shows, `REFUSAL_UNSHOWN`) as the generic failure.
export function localizeDetail(d: unknown): string {
const t = i18n.t.bind(i18n);
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
// "check your input" message rather than leaking them into a translated sentence.
if (Array.isArray(d)) return t("errors.validation");
const o = d as Detail | undefined;
if (!o?.code) return t("errors.generic");
const family = families.get(o.code);
if (family)
return t(`${family.prefix}.${o.reason}${family.suffix ?? ""}`, {
...valuesOf(o),
defaultValue: t(family.fallback),
});
const shared = byReason.get(o.code);
if (shared) {
return t(shared.reasons.get(o.reason ?? "") ?? shared.fallback, valuesOf(o));
}
const one = fixed.get(o.code);
return one ? t(one, valuesOf(o)) : t("errors.generic");
}
@@ -9,13 +9,15 @@ import type { ReactNode } from "react";
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
import { api, HttpError } from "./api"; import { api, HttpError } from "./api";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { useChannelTagActions } from "./useChannelTagActions"; import { useChannelTagActions } from "./useChannelTagActions";
import { useChannelPriority } from "./useChannelPriority"; import { useChannelPriority } from "./useChannelPriority";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => notifyMock.mockClear()); beforeEach(() => notifyMock.mockClear());
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.updateFailed"), title: msg("channels.tags.updateFailed"),
message: i18n.t("errors.reason.forbidden"), message: msg("errors.reason.forbidden"),
}); });
}); });
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
await waitFor(() => await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("channels.tags.createFailed"), title: msg("channels.tags.createFailed"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}), }),
); );
}); });
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1")); await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.writeFailed.channelPriority"), title: msg("errors.writeFailed.channelPriority"),
message: i18n.t("errors.reason.unreachable"), message: msg("errors.reason.unreachable"),
}); });
}); });
}); });
+10 -9
View File
@@ -5,11 +5,12 @@
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the // notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it. // account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { raw, sayNotif, type NotifText } from "./notifications";
const entry = (id: number, message: string) => ({ const entry = (id: number, message: string) => ({
id, id,
level: "info", level: "info",
message, message: raw(message),
ts: 1, ts: 1,
read: true, read: true,
dismissed: true, dismissed: true,
@@ -19,10 +20,10 @@ const entry = (id: number, message: string) => ({
}); });
const storedMessages = () => const storedMessages = () =>
( (
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as { JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as {
message: string; message: NotifText;
}[] }[]
).map((n) => n.message); ).map((n) => sayNotif(n.message));
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s // Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives // alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
@@ -47,14 +48,14 @@ beforeEach(() => {
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => { describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once it is claimed", async () => { it("keep the account's notification history and settings once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5"); localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")])); localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 })); localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications"); const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe notifications.notify({ message: raw("before the claim") }); // e.g. a failed boot probe
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "after the claim" }); notifications.notify({ message: raw("after the claim") });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]); expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0); expect(notifications.getNotifSettings().durationMs).toBe(0);
@@ -75,12 +76,12 @@ describe("stores read at import, before the account is known", { timeout: CASE_B
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => { describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
it("starts the new account's notices from an empty history", async () => { it("starts the new account's notices from an empty history", async () => {
localStorage.setItem("siftlode.owner", "4"); localStorage.setItem("siftlode.owner", "4");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")])); localStorage.setItem("siftlode.notifHistory", JSON.stringify([entry(7, "four's")]));
const notifications = await import("./notifications"); const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage"); const { claimLocalData } = await import("./storage");
claimLocalData(5); claimLocalData(5);
notifications.notify({ message: "as five" }); notifications.notify({ message: raw("as five") });
expect(storedMessages()).toEqual(["as five"]); expect(storedMessages()).toEqual(["as five"]);
}); });
+19 -16
View File
@@ -21,18 +21,21 @@ vi.mock("./api", () => ({
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink }, api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
})); }));
vi.mock("./clipboard", () => ({ copyText })); vi.mock("./clipboard", () => ({ copyText }));
vi.mock("./notifications", () => ({ notify })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify,
}));
import { quickShareLink } from "./downloads"; import { quickShareLink } from "./downloads";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { msg, type NotifText } from "./notifications";
const t = (k: string) => k;
const origin = window.location.origin; const origin = window.location.origin;
type ToastArg = { type ToastArg = {
level: string; level: string;
message: string; message: NotifText;
action?: { label: string; onClick: () => void }; action?: { label: NotifText; onClick: () => void };
}; };
const firstToast = () => notify.mock.calls[0]![0] as ToastArg; const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
@@ -55,7 +58,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" }); createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
const openSpy = vi.spyOn(window, "open").mockReturnValue(null); const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
const url = await quickShareLink(qc(), { kind: "file", id: 42 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 42 });
expect(url).toBe(`${origin}/watch/tok7`); expect(url).toBe(`${origin}/watch/tok7`);
// Minted with an EMPTY body so the backend default (allow_download=true) applies. // Minted with an EMPTY body so the backend default (allow_download=true) applies.
@@ -63,8 +66,8 @@ describe("quickShareLink", () => {
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/tok7`);
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("success"); expect(arg.level).toBe("success");
expect(arg.message).toBe("downloads.share.linkCopied"); expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
arg.action!.onClick(); arg.action!.onClick();
expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer"); expect(openSpy).toHaveBeenCalledWith(`${origin}/watch/tok7`, "_blank", "noopener,noreferrer");
}); });
@@ -72,7 +75,7 @@ describe("quickShareLink", () => {
it("reuses an existing link instead of minting a duplicate", async () => { it("reuses an existing link instead of minting a duplicate", async () => {
downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]); downloadLinks.mockResolvedValue([{ id: 3, url: "/watch/old" }]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/old`); expect(url).toBe(`${origin}/watch/old`);
expect(createDownloadLink).not.toHaveBeenCalled(); expect(createDownloadLink).not.toHaveBeenCalled();
@@ -85,7 +88,7 @@ describe("quickShareLink", () => {
{ id: 3, url: "/watch/older" }, { id: 3, url: "/watch/older" },
]); ]);
const url = await quickShareLink(qc(), { kind: "file", id: 1 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 1 });
expect(url).toBe(`${origin}/watch/older`); expect(url).toBe(`${origin}/watch/older`);
expect(createDownloadLink).not.toHaveBeenCalled(); expect(createDownloadLink).not.toHaveBeenCalled();
@@ -97,7 +100,7 @@ describe("quickShareLink", () => {
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" }); createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
const client = qc(); const client = qc();
const url = await quickShareLink(client, { kind: "file", id: 6 }, t); const url = await quickShareLink(client, { kind: "file", id: 6 });
expect(url).toBe(`${origin}/watch/new`); expect(url).toBe(`${origin}/watch/new`);
expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`); expect(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
@@ -113,7 +116,7 @@ describe("quickShareLink", () => {
const client = qc(); const client = qc();
client.setQueryData(qk.fileFolders(), []); client.setQueryData(qk.fileFolders(), []);
const url = await quickShareLink(client, { kind: "folder", id: 5 }, t); const url = await quickShareLink(client, { kind: "folder", id: 5 });
expect(url).toBe(`${origin}/watch/fold`); expect(url).toBe(`${origin}/watch/fold`);
expect(folderLinks).toHaveBeenCalledWith(5); expect(folderLinks).toHaveBeenCalledWith(5);
@@ -129,26 +132,26 @@ describe("quickShareLink", () => {
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]); downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
copyText.mockResolvedValue(false); copyText.mockResolvedValue(false);
const url = await quickShareLink(qc(), { kind: "file", id: 2 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 2 });
expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end expect(url).toBe(`${origin}/watch/x`); // the link is never a dead end
const arg = firstToast(); const arg = firstToast();
expect(arg.level).toBe("warning"); expect(arg.level).toBe("warning");
expect(arg.message).toBe("downloads.share.copyFailed"); expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
expect(arg.action!.label).toBe("downloads.share.openNewTab"); expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
}); });
it("reports an error and returns null when link creation fails", async () => { it("reports an error and returns null when link creation fails", async () => {
downloadLinks.mockResolvedValue([]); downloadLinks.mockResolvedValue([]);
createDownloadLink.mockRejectedValue(new Error("boom")); createDownloadLink.mockRejectedValue(new Error("boom"));
const url = await quickShareLink(qc(), { kind: "file", id: 9 }, t); const url = await quickShareLink(qc(), { kind: "file", id: 9 });
expect(url).toBeNull(); expect(url).toBeNull();
expect(copyText).not.toHaveBeenCalled(); expect(copyText).not.toHaveBeenCalled();
// Titled with what failed, and carrying why (notifyActionError), not a bare message. // Titled with what failed, and carrying why (notifyActionError), not a bare message.
expect(notify).toHaveBeenCalledWith( expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }), expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
); );
}); });
}); });
+6 -10
View File
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { api, type NewShareLink } from "./api"; import { api, type NewShareLink } from "./api";
import { copyText } from "./clipboard"; import { copyText } from "./clipboard";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
// The download queue, the per-card download index, the library (its listings and folder counts) and // The download queue, the per-card download index, the library (its listings and folder counts) and
@@ -44,11 +44,7 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
// new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead // new tab" action. A copy failure still toasts (warning) WITH that action, so the link is never a dead
// end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog. // end. Advanced controls (password, expiry, revoke, share-with-a-user) live in the Share dialog.
// Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome. // Returns the shared URL, or null if link creation failed — so the caller/tests can react to the outcome.
export async function quickShareLink( export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
qc: QueryClient,
target: LinkTarget,
t: (key: string) => string,
): Promise<string | null> {
let path; let path;
try { try {
const existing = await qc.fetchQuery({ const existing = await qc.fetchQuery({
@@ -65,18 +61,18 @@ export async function quickShareLink(
} }
if (!path) throw new Error("the new link came back without its address"); if (!path) throw new Error("the new link came back without its address");
} catch (e) { } catch (e) {
notifyActionError(e, t("downloads.share.linkFailed")); notifyActionError(e, msg("downloads.share.linkFailed"));
return null; return null;
} }
const url = window.location.origin + path; const url = window.location.origin + path;
const action = { const action = {
label: t("downloads.share.openNewTab"), label: msg("downloads.share.openNewTab"),
onClick: () => window.open(url, "_blank", "noopener,noreferrer"), onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
}; };
notify( notify(
(await copyText(url)) (await copyText(url))
? { level: "success", message: t("downloads.share.linkCopied"), action } ? { level: "success", message: msg("downloads.share.linkCopied"), action }
: { level: "warning", message: t("downloads.share.copyFailed"), action }, : { level: "warning", message: msg("downloads.share.copyFailed"), action },
); );
return url; return url;
} }
+3 -3
View File
@@ -14,7 +14,7 @@
// names) — an email in a query string lands in every reverse-proxy access log. Plain status flags // names) — an email in a query string lands in every reverse-proxy access log. Plain status flags
// with nothing personal in them (login, deleted, account) ride the query string. // with nothing personal in them (login, deleted, account) ride the query string.
import type { NotifLevel } from "./notifications"; import { raw, type NotifLevel, type NotifValue } from "./notifications";
import { refusalReason } from "./api/core"; import { refusalReason } from "./api/core";
import { hrefHere } from "./urlState"; import { hrefHere } from "./urlState";
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
export interface AccountNotice { export interface AccountNotice {
level: NotifLevel; level: NotifLevel;
key: string; key: string;
values?: Record<string, string>; values?: Record<string, NotifValue>;
} }
/** What the entry URL says to the account signed in here about a switch of account. A sign-in as /** What the entry URL says to the account signed in here about a switch of account. A sign-in as
@@ -110,7 +110,7 @@ export function accountSwitchNotice(
const access = hash.get("access"); const access = hash.get("access");
const email = hash.get("linked") || hash.get("email"); const email = hash.get("linked") || hash.get("email");
if (access === "requested" && email) if (access === "requested" && email)
return { level: "info", key: "header.account.switchRequested", values: { email } }; return { level: "info", key: "header.account.switchRequested", values: { email: raw(email) } };
if (access === "requested" || access === "denied") if (access === "requested" || access === "denied")
return { level: "warning", key: "header.account.switchNotApproved" }; return { level: "warning", key: "header.account.switchNotApproved" };
return null; return null;
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
//
// The notice history changed shape (a notice keeps what to say, not the said text). A build from
// before it reads the old key and renders its rows as text, so an object there crashed its
// Notifications page after a rollback. The history now lives under a key of its own: the old one is
// read once, while the new one does not exist yet, and never written.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
const OLD_KEY = "siftlode.notifications";
// A row as the build before wrote it: its text already said.
const oldRow = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const saidHistory = (key: string) =>
(JSON.parse(localStorage.getItem(key) ?? "[]") as { message: NotifText }[]).map((n) =>
sayNotif(n.message),
);
beforeAll(async () => {
await import("./notifications");
await import("./storage");
}, 60_000);
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the store loads at import: each case needs a fresh one
});
describe("the notice history's own key", { timeout: 30_000 }, () => {
it("carries the old history forward and leaves the old key as the build before wrote it", async () => {
const old = JSON.stringify([oldRow(7, "older")]);
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, old);
const notifications = await import("./notifications");
const { claimLocalData, LS } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: msg("common.copy.done") });
expect(LS.notifHistory).not.toBe(OLD_KEY);
expect(localStorage.getItem(OLD_KEY)).toBe(old);
expect(saidHistory(LS.notifHistory)).toEqual(["older", "Copied to the clipboard"]);
});
it("reads the old key only while the new one does not exist", async () => {
const { LS } = await import("./storage");
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem(OLD_KEY, JSON.stringify([oldRow(7, "older")]));
localStorage.setItem(
LS.notifHistory,
JSON.stringify([{ ...oldRow(8, ""), message: raw("newer") }]),
);
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
expect(notifications.getNotifications().map((n) => sayNotif(n.message))).toEqual(["newer"]);
});
});
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import ts from "typescript";
import { readSources } from "./rawSources.testkit";
// A notice says WHAT to say (lib/notifications NotifText) and is said when shown; a plain `t()` says
// it now. tsc cannot tell the two apart where they meet — `msg`'s key is a string, `detail` is
// unknown, and i18next's options take any value — so the slips are caught here, on the syntax:
// - a NotifText handed to `t()` as a value (`t("k", { name: raw(x) })`) reads "[object Object]";
// - an already-said text kept in a notice (`msg(t("k"))`, `raw(t("k"))`, `{ detail: t("k") }`)
// stays in the language it was raised in.
const isT = (e: ts.Expression) =>
(ts.isIdentifier(e) && e.text === "t") ||
(ts.isPropertyAccessExpression(e) && e.name.text === "t");
const isCallTo = (e: ts.Node, names: string[]): e is ts.CallExpression =>
ts.isCallExpression(e) && ts.isIdentifier(e.expression) && names.includes(e.expression.text);
const isTCall = (e: ts.Node): e is ts.CallExpression => ts.isCallExpression(e) && isT(e.expression);
function mixedNotifText(name: string, src: string): string[] {
const file = ts.createSourceFile(name, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const out: string[] = [];
const at = (n: ts.Node, what: string) =>
out.push(`${name}:${file.getLineAndCharacterOfPosition(n.getStart()).line + 1} ${what}`);
const visit = (n: ts.Node) => {
if (isTCall(n)) {
for (const arg of n.arguments.slice(1)) {
if (!ts.isObjectLiteralExpression(arg)) continue;
for (const p of arg.properties) {
if (ts.isPropertyAssignment(p) && isCallTo(p.initializer, ["raw", "msg"]))
at(p, "a notice value handed to t()");
}
}
}
if (isCallTo(n, ["msg", "raw"]) && n.arguments[0] && isTCall(n.arguments[0]))
at(n, "a said text kept in a notice");
if (
ts.isPropertyAssignment(n) &&
ts.isIdentifier(n.name) &&
n.name.text === "detail" &&
isTCall(n.initializer)
)
at(n, "a said text kept as a notice's detail");
ts.forEachChild(n, visit);
};
visit(file);
return out;
}
describe("a notice's text and a plain t() do not mix", () => {
it("holds across src", () => {
const files = readSources(__dirname, ["../**/*.ts", "../**/*.tsx", "!../**/*.test.*"]);
expect(Object.keys(files).length).toBeGreaterThan(100); // the glob matched the app
expect(Object.entries(files).flatMap(([p, src]) => mixedNotifText(p, src))).toEqual([]);
});
it.each([
['t("users.delete.confirm", { email: raw(u.email) })', "a notice value handed to t()"],
['i18n.t("k", { n: msg("x") })', "a notice value handed to t()"],
['notify({ message: msg(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: raw(t("feed.saved")) })', "a said text kept in a notice"],
['notify({ message: { detail: t("x") } })', "a said text kept as a notice's detail"],
])("sees %s", (src, what) => {
expect(mixedNotifText("x.tsx", src)).toEqual([`x.tsx:1 ${what}`]);
});
it.each([
'msg("channels.discovery.subscribedBody", { name: raw(nameOf(c)) })',
't("channels.discovery.confirmSubscribe", { name: nameOf(c) })',
"sayNotif(msg(key), t)",
])("lets %s through", (src) => {
expect(mixedNotifText("x.tsx", src)).toEqual([]);
});
});
@@ -10,6 +10,7 @@ import {
configureNotifications, configureNotifications,
getActiveToasts, getActiveToasts,
notify, notify,
raw,
} from "./notifications"; } from "./notifications";
beforeEach(() => vi.useFakeTimers()); beforeEach(() => vi.useFakeTimers());
@@ -19,14 +20,14 @@ afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
const undo = { label: "Undo", onClick: () => {} }; const undo = { label: raw("Undo"), onClick: () => {} };
const shown = (id: number) => getActiveToasts().some((n) => n.id === id); const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
describe("an action toast's lifetime", () => { describe("an action toast's lifetime", () => {
it("stays for the floor when the reader's toasts last 3 s", () => { it("stays for the floor when the reader's toasts last 3 s", () => {
configureNotifications({ durationMs: 3000 }); configureNotifications({ durationMs: 3000 });
const plain = notify({ message: "plain" }); const plain = notify({ message: raw("plain") });
const withUndo = notify({ message: "removed", action: undo }); const withUndo = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(3001); vi.advanceTimersByTime(3001);
expect(shown(plain)).toBe(false); expect(shown(plain)).toBe(false);
expect(shown(withUndo)).toBe(true); expect(shown(withUndo)).toBe(true);
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
it("keeps a longer setting", () => { it("keeps a longer setting", () => {
configureNotifications({ durationMs: 12000 }); configureNotifications({ durationMs: 12000 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(ACTION_TTL_MIN + 1); vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN); vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
it("never dismisses when the reader chose never", () => { it("never dismisses when the reader chose never", () => {
configureNotifications({ durationMs: 0 }); configureNotifications({ durationMs: 0 });
const id = notify({ message: "removed", action: undo }); const id = notify({ message: raw("removed"), action: undo });
vi.advanceTimersByTime(60000); vi.advanceTimersByTime(60000);
expect(shown(id)).toBe(true); expect(shown(id)).toBe(true);
}); });
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
//
// A burst of the same notice is one entry with a count (×N), not a pile of copies. The texts are
// objects now (what to say, built afresh by every msg() call), so "the same" is the same key and
// values, never the same object: a compare by reference would let every repeat stack.
import { afterEach, describe, expect, it } from "vitest";
import { clearAll, getNotifications, msg, notify, raw } from "./notifications";
afterEach(() => clearAll());
describe("coalescing a repeated notice", () => {
it("folds the same key and values into one entry, counted", () => {
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
notify({
level: "error",
title: msg("errors.title"),
message: msg("errors.reason.unreachable"),
});
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("folds the same server refusal too", () => {
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
notify({ level: "error", message: { detail: { code: "not_found", reason: "view" } } });
expect(getNotifications().map((n) => n.repeat)).toEqual([2]);
});
it("keeps two notices apart when only a value differs", () => {
notify({ message: msg("channels.notify.hidden", { name: raw("One") }) });
notify({ message: msg("channels.notify.hidden", { name: raw("Two") }) });
expect(getNotifications().map((n) => n.repeat)).toEqual([1, 1]);
});
});
+72 -12
View File
@@ -2,9 +2,61 @@
// the Notification Center shows (info events, actions awaiting interaction, and app // the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action // errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import type { TFunction } from "i18next";
import i18n from "../i18n";
import { localizeDetail } from "./api/refusalText";
import { formatBytes, formatNumber } from "./format";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage"; import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log"; import { log, type Level } from "./log";
// What a notice SAYS, kept as what to say rather than the said text: a key and its values, said in
// the reader's language each time it is shown. The text a caller translated at raise time was kept
// in the history, so a language switch left every notice in the language it was raised in. `raw` is
// text kept as it was fetched (a video's title, a name — a Plex title in the language it was fetched
// in, since the card holds only that one); `detail` is a server refusal, localized at display like
// the key. `notify` takes nothing else, and tsc refuses a plain string as a value (a name goes in as
// `raw`, a translated word as its own `msg`). What tsc cannot see — `msg(t(…))`, `raw(t(…))`,
// `{ detail: t(…) }`, or a `raw`/`msg` handed to a plain `t()` — notifText.contract.test refuses.
// A value is formatted when shown too: `{ number }` / `{ bytes }` group digits the reader's way.
export type NotifValue = number | NotifText | { number: number } | { bytes: number };
export type NotifText =
| { key: string; values?: Readonly<Record<string, NotifValue>> }
| { raw: string }
| { detail: unknown };
/** The notice text for `t(key, values)`, said when it is shown. */
export const msg = (key: string, values?: Record<string, NotifValue>): NotifText =>
values ? { key, values } : { key };
/** Text kept as it was fetched (a title, a name), shown as it is — never something `t()` said. */
export const raw = (text: string): NotifText => ({ raw: text });
/** A notice text in the language `t` speaks — the reader's, from a component's `useTranslation`. */
export function sayNotif(text: NotifText, t: TFunction = i18n.t.bind(i18n)): string {
if ("raw" in text) return text.raw;
if ("detail" in text) return localizeDetail(text.detail);
if (!text.values) return t(text.key);
const values = Object.fromEntries(
Object.entries(text.values).map(([k, v]) => [k, sayValue(v, t)]),
);
return t(text.key, values);
}
function sayValue(v: NotifValue, t: TFunction): string | number {
if (typeof v !== "object") return v;
// In the language `t` speaks: a fixed one (the English log) names it, a component's follows i18n.
const lang = (t as { lng?: string }).lng ?? i18n.language;
if ("number" in v) return formatNumber(v.number, lang);
if ("bytes" in v) return formatBytes(v.bytes, lang);
return sayNotif(v, t);
}
const sameText = (a: NotifText | undefined, b: NotifText | undefined): boolean =>
JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
// A notice the history kept from before it said keys: its text, in the language it was raised in.
const asText = (v: unknown): NotifText | undefined =>
typeof v === "string" ? raw(v) : (v as NotifText | undefined);
// A notification's level in the client log: what the user was TOLD is part of the story a log tells. // A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = { const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO", info: "INFO",
@@ -17,7 +69,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
interface NotifAction { interface NotifAction {
label: string; // Said when shown, like the notice's text: a language switch must not leave the button behind.
label: NotifText;
onClick: () => void; onClick: () => void;
} }
@@ -56,8 +109,8 @@ export type NotifMeta =
export interface Notification { export interface Notification {
id: number; id: number;
level: NotifLevel; level: NotifLevel;
title?: string; title?: NotifText;
message: string; message: NotifText;
action?: NotifAction; action?: NotifAction;
// A second, lesser action beside it (a done download's "Open" after its "Copy link"). // A second, lesser action beside it (a done download's "Open" after its "Copy link").
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
@@ -79,9 +132,9 @@ export interface Notification {
} }
export interface NotifyInput { export interface NotifyInput {
message: string; message: NotifText;
level?: NotifLevel; level?: NotifLevel;
title?: string; title?: NotifText;
action?: NotifAction; action?: NotifAction;
secondaryAction?: NotifAction; secondaryAction?: NotifAction;
meta?: NotifMeta; meta?: NotifMeta;
@@ -175,17 +228,21 @@ let cachedUnread = 0;
function load(): Notification[] { function load(): Notification[] {
try { try {
const raw = readAccount<unknown>(HISTORY_KEY, []); // The old key only until the new one exists: its rows carry forward, said text as `raw`.
if (!Array.isArray(raw)) return []; const stored =
readAccount<unknown>(HISTORY_KEY, null) ?? readAccount<unknown>(LS.legacyNotifHistory, []);
if (!Array.isArray(stored)) return [];
// Restored entries are history only. Mark them dismissed so they don't // Restored entries are history only. Mark them dismissed so they don't
// resurrect as active toasts on reload — their auto-dismiss timers aren't // resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the // re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization. // live `action` callback, which can't survive serialization.
return raw.map( return stored.map(
(n) => (n) =>
({ ({
repeat: 1, repeat: 1,
...(n as object), ...(n as object),
title: asText((n as { title?: unknown }).title),
message: asText((n as { message?: unknown }).message) ?? raw(""),
action: undefined, action: undefined,
secondaryAction: undefined, secondaryAction: undefined,
dismissed: true, dismissed: true,
@@ -261,9 +318,12 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number { export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, { // In English, whatever the reader's language (a server refusal aside: it says itself in the
// reader's), so one log reads the same for every reader.
const en = i18n.getFixedT("en");
log.at(LOG_LEVEL[level], "ui.notify", sayNotif(input.message, en), {
level, level,
...(input.title ? { title: input.title } : {}), ...(input.title ? { title: sayNotif(input.title, en) } : {}),
...(input.meta ? { kind: input.meta.kind } : {}), ...(input.meta ? { kind: input.meta.kind } : {}),
}); });
const now = Date.now(); const now = Date.now();
@@ -294,8 +354,8 @@ export function notify(input: NotifyInput): number {
!n.action && !n.action &&
now - n.ts <= COALESCE_WINDOW_MS && now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level && n.level === level &&
(n.title ?? "") === (input.title ?? "") && sameText(n.title, input.title) &&
n.message === input.message, sameText(n.message, input.message),
); );
if (prior) { if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer. // Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
+6
View File
@@ -1,7 +1,13 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { msg, raw, type NotifText } from "./notifications";
/** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized /** Watch later is a built-in playlist whose stored `name` is not translatable — show the localized
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */ * label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
export function playlistName(p: { kind: string; name: string }, t: TFunction): string { export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
return p.kind === "watch_later" ? t("playlists.watchLater") : p.name; return p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
} }
/** The same name in a notice, said in the reader's language when it is shown. */
export function playlistNameText(p: { kind: string; name: string }): NotifText {
return p.kind === "watch_later" ? msg("playlists.watchLater") : raw(p.name);
}
+13 -4
View File
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
import { usePref, useSavePref } from "./usePref"; import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { PlexCard, PlexTmdb } from "./api"; import type { PlexCard, PlexTmdb } from "./api";
import { msg, raw, sayNotif, type NotifText } from "./notifications";
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so // prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
@@ -241,9 +242,17 @@ export function seasonTitle(
se: { title?: string | null; season_number?: number | null }, se: { title?: string | null; season_number?: number | null },
t: TFunction, t: TFunction,
): string { ): string {
return sayNotif(seasonTitleText(se), t);
}
/** The season's name as a notice keeps it: said in the reader's language each time it is shown. */
export function seasonTitleText(se: {
title?: string | null;
season_number?: number | null;
}): NotifText {
const n = se.season_number; const n = se.season_number;
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title; if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
if (n === 0) return t("plex.specials"); if (n === 0) return msg("plex.specials");
if (n != null) return t("plex.seasonTitle", { n }); if (n != null) return msg("plex.seasonTitle", { n });
return se.title || t("plex.playlist.seasonUnknown"); return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
} }
+2 -3
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
import { api, type PlexCard } from "./api"; import { api, type PlexCard } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache // Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache
// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then // updater, call the API, surface a failure (the click used to look dead on 403/404/429), then
@@ -12,7 +12,6 @@ import { notifyActionError } from "./actionErrors";
// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer. // (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState( export async function toggleWatchState(
card: PlexCard, card: PlexCard,
t: TFunction,
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void }, handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
) { ) {
const next = card.status === "watched" ? "new" : "watched"; const next = card.status === "watched" ? "new" : "watched";
@@ -21,7 +20,7 @@ export async function toggleWatchState(
await api.plexSetState(card.id, next); await api.plexSetState(card.id, next);
} catch (e) { } catch (e) {
handlers.revert?.(); handlers.revert?.();
notifyActionError(e, t("plex.watchedFailed")); notifyActionError(e, msg("plex.watchedFailed"));
} }
handlers.resync(); handlers.resync();
} }
+12 -10
View File
@@ -5,16 +5,18 @@
// silence on a 403/404/429 or a lost connection — 34 of them. // silence on a 403/404/429 or a lost connection — 34 of them.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) })); vi.mock("./notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./notifications")>()),
notify: vi.fn(() => 0),
}));
vi.mock("./errorDialog", () => ({ reportError: vi.fn() })); vi.mock("./errorDialog", () => ({ reportError: vi.fn() }));
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query"; import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
import { createQueryClient } from "./queryClient"; import { createQueryClient } from "./queryClient";
import { HttpError, req } from "./api/core"; import { HttpError, req } from "./api/core";
import { notify } from "./notifications"; import { msg, notify, raw } from "./notifications";
import { reportError } from "./errorDialog"; import { reportError } from "./errorDialog";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
const notifyMock = vi.mocked(notify); const notifyMock = vi.mocked(notify);
beforeEach(() => { beforeEach(() => {
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock.mock.calls).toEqual([ expect(notifyMock.mock.calls).toEqual([
[{ level: "error", title: i18n.t(TITLE), message: i18n.t("errors.notFound.generic") }], [{ level: "error", title: msg(TITLE), message: msg("errors.notFound.generic") }],
]); ]);
}); });
@@ -47,8 +49,8 @@ describe("a failed mutation", () => {
await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) }); await fail({ mutationFn: () => Promise.reject(new HttpError(429, "slow down")) });
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "error", level: "error",
title: i18n.t("errors.title"), title: msg("errors.title"),
message: i18n.t("errors.reason.rateLimited"), message: msg("errors.reason.rateLimited"),
}); });
}); });
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
}); });
expect(notifyMock).toHaveBeenCalledWith( expect(notifyMock).toHaveBeenCalledWith(
expect.objectContaining({ message: i18n.t("errors.reason.unreachable") }), expect.objectContaining({ message: msg("errors.reason.unreachable") }),
); );
}); });
@@ -66,9 +68,9 @@ describe("a failed mutation", () => {
await fail({ await fail({
mutationFn: () => Promise.reject(new HttpError(404, "gone")), mutationFn: () => Promise.reject(new HttpError(404, "gone")),
meta: { failTitle: TITLE }, meta: { failTitle: TITLE },
onError: (e) => notifyActionError(e, "OWN TITLE"), onError: (e) => notifyActionError(e, raw("OWN TITLE")),
}); });
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual(["OWN TITLE"]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([raw("OWN TITLE")]);
}); });
it("is still reported when the mutation's onError only rolls back", async () => { it("is still reported when the mutation's onError only rolls back", async () => {
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
onError: rollback, onError: rollback,
}); });
expect(rollback).toHaveBeenCalled(); expect(rollback).toHaveBeenCalled();
expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([i18n.t(TITLE)]); expect(notifyMock.mock.calls.map(([n]) => n.title)).toEqual([msg(TITLE)]);
}); });
it("gets no toast when the page shows the failure itself", async () => { it("gets no toast when the page shows the failure itself", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { log } from "./log"; import { log } from "./log";
import { msg } from "./notifications";
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`): /** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
* the rest of a key is filter state, which the base level does not keep. The status is what * the rest of a key is filter state, which the base level does not keep. The status is what
@@ -42,7 +42,7 @@ type FailMeta = { failTitle?: string; errorShownInline?: boolean } | undefined;
*/ */
function reportMutationFailure(err: unknown, meta: FailMeta): void { function reportMutationFailure(err: unknown, meta: FailMeta): void {
if (meta?.errorShownInline) return; if (meta?.errorShownInline) return;
setTimeout(() => notifyActionError(err, i18n.t(meta?.failTitle ?? "errors.title")), 0); setTimeout(() => notifyActionError(err, msg(meta?.failTitle ?? "errors.title")), 0);
} }
export function createQueryClient(): QueryClient { export function createQueryClient(): QueryClient {
+6 -1
View File
@@ -100,7 +100,12 @@ const KEYS = {
// flag). A YT.Player instance is created per video and starts at full volume, so without this // flag). A YT.Player instance is created per video and starts at full volume, so without this
// every next video is loud again (same idea as plexPlayerPrefs). // every next video is loud again (same idea as plexPlayerPrefs).
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" }, ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
notifHistory: { key: "siftlode.notifications", kind: "cache" }, // The notice history: what each notice says (lib/notifications NotifText), not the said text.
notifHistory: { key: "siftlode.notifHistory", kind: "cache" },
// DEPRECATED — the history as said text, which a build from before the change still reads (and
// renders as text: an object there crashed its Notifications page after a rollback). Read once while
// `notifHistory` does not exist yet, then inert. Do not write.
legacyNotifHistory: { key: "siftlode.notifications", kind: "cache" },
notifSettings: { key: "siftlode.notifSettings", kind: "pref" }, notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" }, onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
seenVersion: { key: "siftlode.seenVersion", kind: "pref" }, seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
return { ...actual, accountSwitchNotice: () => notice() }; return { ...actual, accountSwitchNotice: () => notice() };
}); });
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice"); const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
const { msg, raw } = await import("./notifications");
afterEach(() => { afterEach(() => {
notifyMock.mockClear(); notifyMock.mockClear();
@@ -39,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
notice.mockReturnValue({ notice.mockReturnValue({
level: "info", level: "info",
key: "header.account.switchRequested", key: "header.account.switchRequested",
values: { email: "b@example.com" }, values: { email: raw("b@example.com") },
}); });
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), { const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
initialProps: { signedIn: false }, initialProps: { signedIn: false },
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
expect(notifyMock).toHaveBeenCalledTimes(1); expect(notifyMock).toHaveBeenCalledTimes(1);
expect(notifyMock).toHaveBeenCalledWith({ expect(notifyMock).toHaveBeenCalledWith({
level: "info", level: "info",
message: 'header.account.switchRequested {"email":"b@example.com"}', message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
}); });
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { accountSwitchNotice } from "./entryUrl"; import { accountSwitchNotice } from "./entryUrl";
import { notify } from "./notifications"; import { msg, notify } from "./notifications";
/** Say, once per load, what the entry URL tells the account signed in here about a switch of /** Say, once per load, what the entry URL tells the account signed in here about a switch of
* account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that * account (`accountSwitchNotice`): a sign-in as someone else the server refused, or a tab that
@@ -14,6 +14,6 @@ export function useAccountSwitchNotice(signedIn: boolean): void {
if (!signedIn || said.current) return; if (!signedIn || said.current) return;
said.current = true; said.current = true;
const notice = accountSwitchNotice(); const notice = accountSwitchNotice();
if (notice) notify({ level: notice.level, message: t(notice.key, notice.values) }); if (notice) notify({ level: notice.level, message: msg(notice.key, notice.values) });
}, [signedIn, t]); }, [signedIn, t]);
} }
+2 -2
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { api } from "./api"; import { api } from "./api";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import i18n from "../i18n";
import { useLatest } from "./useLatest"; import { useLatest } from "./useLatest";
import { msg } from "./notifications";
/** The shared feed-priority flow used by both the channel manager and the channel detail page: /** The shared feed-priority flow used by both the channel manager and the channel detail page:
* an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a * an optimistic bump/set with a debounced server flush (~400ms after the last notch) so a
@@ -40,7 +40,7 @@ export function useChannelPriority(opts: {
.catch((err: unknown) => { .catch((err: unknown) => {
// Said here, once, for both callers: their `onError` only refetches, so a refused priority // Said here, once, for both callers: their `onError` only refetches, so a refused priority
// used to just snap back — often after the stepper had already been let go. // used to just snap back — often after the stepper had already been let go.
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority")); notifyActionError(err, msg("errors.writeFailed.channelPriority"));
optsRef.current.onError(channelId); optsRef.current.onError(channelId);
}); });
}, },
+3 -4
View File
@@ -1,8 +1,8 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api } from "./api"; import { api } from "./api";
import { qk } from "./queryKeys"; import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { msg } from "./notifications";
/** The shared channel-tag mutation flow used by both the channel manager and the channel detail /** The shared channel-tag mutation flow used by both the channel manager and the channel detail
* page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific * page: optimistic attach/detach + a create-then-attach shortcut, with the cache-shape-specific
@@ -21,7 +21,6 @@ export function useChannelTagActions(opts: {
onError: (channelId: string) => void; onError: (channelId: string) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { t } = useTranslation();
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => { const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
opts.optimisticToggle(channelId, tagId, !hasTag); opts.optimisticToggle(channelId, tagId, !hasTag);
@@ -31,7 +30,7 @@ export function useChannelTagActions(opts: {
call call
.then(() => opts.onSuccess?.(channelId)) .then(() => opts.onSuccess?.(channelId))
.catch((err: unknown) => { .catch((err: unknown) => {
notifyActionError(err, t("channels.tags.updateFailed")); notifyActionError(err, msg("channels.tags.updateFailed"));
opts.onError(channelId); opts.onError(channelId);
}); });
}; };
@@ -45,7 +44,7 @@ export function useChannelTagActions(opts: {
qc.invalidateQueries({ queryKey: qk.tags() }); qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false); toggleTag(channelId, tag.id, false);
}) })
.catch((err: unknown) => notifyActionError(err, t("channels.tags.createFailed"))); .catch((err: unknown) => notifyActionError(err, msg("channels.tags.createFailed")));
}; };
return { toggleTag, createAndAttachTag }; return { toggleTag, createAndAttachTag };

Some files were not shown because too many files have changed in this diff Show More