Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c430558ab3 | ||
|
|
be38d652a5 | ||
|
|
31a75f4985 | ||
|
|
f8a8e905da | ||
|
|
5e8589bbf8 | ||
|
|
ceadf69e43 | ||
|
|
4379f76747 | ||
|
|
b3be568e36 | ||
|
|
a8cbc7b338 | ||
|
|
cf8dc80428 | ||
|
|
da74b8b9bf | ||
|
|
6944622085 | ||
|
|
2bdcebdb34 | ||
|
|
046dde44a4 | ||
|
|
74f58ea001 | ||
|
|
b515c16117 | ||
|
|
4a0d4b0964 | ||
|
|
cb8b99e0e7 | ||
|
|
e5c75702e8 | ||
|
|
4ef772d37b | ||
|
|
6986eb1937 |
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
### New
|
||||
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| 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` |
|
||||
| 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) |
|
||||
|
||||
@@ -22,6 +22,7 @@ import json
|
||||
import math
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from app.downloads.edit import AAC_ARGS, EVEN_FRAME, INPUT_GUARD, X264_ARGS
|
||||
from app.plex.sync import HDR_TRANSFERS
|
||||
@@ -57,12 +58,27 @@ _CROP_ASPECTS = (0.25, 4.0)
|
||||
_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):
|
||||
"""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)."""
|
||||
|
||||
def __init__(self, reason: str, **params: float):
|
||||
self.reason = reason
|
||||
def __init__(self, reason: ClipReason, **params: float):
|
||||
self.reason: ClipReason = reason
|
||||
self.params = params
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Security controls enforced here:
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
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()
|
||||
|
||||
|
||||
# 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):
|
||||
"""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:
|
||||
"""Sanity-check a pasted cookies.txt (Netscape format) and return the cleaned text. yt-dlp reads
|
||||
|
||||
@@ -24,6 +24,7 @@ kept it — and its `ready_at`, the moment it finished (`stamp_done`).
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -181,12 +182,22 @@ def stamp_done(db: Session, job: DownloadJob) -> None:
|
||||
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):
|
||||
"""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)
|
||||
self.reason = reason
|
||||
self.reason: FolderReason = reason
|
||||
|
||||
|
||||
def own_folder(db: Session, user_id: int, folder_id: int, *, hold: bool = True) -> FileFolder:
|
||||
|
||||
@@ -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 typing import Literal
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -50,11 +51,16 @@ class QuotaLimits:
|
||||
unlimited: bool
|
||||
|
||||
|
||||
class QuotaExceeded(Exception):
|
||||
"""Raised by check_enqueue; `reason` is one of max_jobs|max_bytes (i18n key on the client)."""
|
||||
# Every cap a download can hit; the UI names each one (`downloads.errors.quota.<reason>`, held by the
|
||||
# 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.current = current
|
||||
super().__init__(f"download quota exceeded: {reason} ({current}/{limit})")
|
||||
|
||||
@@ -10,6 +10,7 @@ handler); the folder, link and quota writes are `folders`, `links` and `quota`.
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import delete as delete_rows
|
||||
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
|
||||
|
||||
|
||||
class EditError(Exception):
|
||||
"""Bad edit request (empty spec / source not ready); `reason` is an i18n-mappable key."""
|
||||
# Every reason an edit is refused; the UI names each one (`downloads.errors.edit.<reason>`, held by
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -33,24 +33,33 @@ user-controlled destination to guard.
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Reason codes travel to the client inside the structured 422 detail, so they are part of the API
|
||||
# vocabulary (frontend `localizeDetail`) — keep them stable.
|
||||
REASON_SCHEME = "scheme" # not http(s)
|
||||
REASON_NO_HOST = "no_host" # URL carries no hostname
|
||||
REASON_MALFORMED = "malformed" # unparseable authority or port; urlparse raises late, so guard it
|
||||
REASON_UNRESOLVABLE = "unresolvable" # DNS said nothing (honest: not the same as "blocked")
|
||||
REASON_PRIVATE = "private" # resolves into a network the server must not fetch from
|
||||
# vocabulary — keep them stable. The UI names each one (`downloads.errors.urlBlocked.<reason>`, held by
|
||||
# the frontend's backendIds contract, which reads this Literal).
|
||||
UrlBlockedReason = Literal["scheme", "no_host", "malformed", "unresolvable", "private"]
|
||||
REASON_SCHEME: UrlBlockedReason = "scheme" # not http(s)
|
||||
REASON_NO_HOST: UrlBlockedReason = "no_host" # URL carries no hostname
|
||||
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):
|
||||
"""A user-supplied URL that must not be fetched server-side. ``reason`` is one of the
|
||||
REASON_* codes above."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
def __init__(self, reason: UrlBlockedReason):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
self.reason: UrlBlockedReason = reason
|
||||
|
||||
|
||||
def _is_forbidden(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
|
||||
@@ -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 typing import cast
|
||||
from typing import Literal, cast
|
||||
|
||||
from sqlalchemy import Table, bindparam, delete, exists, func, select, text, update
|
||||
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 -------------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 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):
|
||||
"""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)
|
||||
self.reason = reason
|
||||
self.reason: MoveReason = reason
|
||||
|
||||
|
||||
def touch(pl: Playlist) -> None:
|
||||
|
||||
+20
-10
@@ -1,29 +1,39 @@
|
||||
"""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
|
||||
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
|
||||
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
|
||||
(`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,
|
||||
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
|
||||
languages — so the rule is enforced, not described.
|
||||
A test (`test_http_details.py`) reads every place an `HTTPException` or a `JSONResponse` sends a detail
|
||||
and fails on one the client cannot say: a sentence, a dict without a `code`, a variable, none at all.
|
||||
`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 app.sysconfig import InvalidSetting, SettingReason
|
||||
|
||||
|
||||
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 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})
|
||||
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see.
|
||||
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
|
||||
# contract test can read every reason this can produce.
|
||||
_SETTING_REASONS = {
|
||||
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the refusal
|
||||
# scan reads every reason this can produce, and holds its keys to `SettingReason`.
|
||||
_SETTING_REASONS: dict[SettingReason, str] = {
|
||||
"not_a_number": "setting_not_a_number",
|
||||
"too_low": "setting_too_low",
|
||||
"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`)."""
|
||||
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
|
||||
|
||||
+19
-10
@@ -976,7 +976,16 @@ def _resend_verify_if_eligible(email: str) -> None:
|
||||
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}`
|
||||
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).
|
||||
@@ -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
|
||||
rather than assuming a sequence.
|
||||
"""
|
||||
detail: dict = {"code": "login", "reason": reason}
|
||||
if user is not None:
|
||||
detail |= {
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"approved": user.is_active,
|
||||
}
|
||||
detail |= {k: v for k, v in extra.items() if v is not None}
|
||||
return HTTPException(status_code=status, detail=detail)
|
||||
account = (
|
||||
{"email": user.email, "email_verified": user.email_verified, "approved": user.is_active}
|
||||
if user is not None
|
||||
else {}
|
||||
)
|
||||
given = {k: v for k, v in extra.items() if v is not None}
|
||||
return HTTPException(
|
||||
status_code=status, detail={**account, **given, "code": "login", "reason": reason}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/password-login")
|
||||
|
||||
@@ -208,7 +208,7 @@ def set_credential(
|
||||
try:
|
||||
cleaned = dlcreds.validate_cookies(body.cookies)
|
||||
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)
|
||||
audit.record(
|
||||
db,
|
||||
@@ -359,8 +359,9 @@ def enqueue_clip(
|
||||
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
|
||||
except clipmod.ClipError as e:
|
||||
status = 404 if e.reason == "not_found" else 422
|
||||
detail = {"code": "clip", "reason": e.reason, **e.params}
|
||||
raise HTTPException(status_code=status, detail=detail) from e
|
||||
raise HTTPException(
|
||||
status_code=status, detail={**e.params, "code": "clip", "reason": e.reason}
|
||||
) from e
|
||||
except quota.QuotaExceeded as e:
|
||||
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
|
||||
db.commit()
|
||||
|
||||
@@ -139,7 +139,7 @@ def own_job_for_update(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
|
||||
def quota_detail(e: quota.QuotaExceeded) -> dict:
|
||||
"""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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
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 pydantic import BaseModel, Field
|
||||
@@ -53,7 +53,22 @@ def _check_pull_cooldown(user: User) -> None:
|
||||
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
|
||||
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
|
||||
return HTTPException(status_code=status, detail={"code": "playlist", "reason": reason})
|
||||
|
||||
@@ -128,7 +128,8 @@ def update_view(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ def update_tag(
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
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 {
|
||||
"ok": True,
|
||||
"id": tag.id,
|
||||
|
||||
@@ -443,15 +443,20 @@ def _default(s: ConfigSpec):
|
||||
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):
|
||||
"""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
|
||||
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}")
|
||||
self.key = key
|
||||
self.reason = reason
|
||||
self.reason: SettingReason = reason
|
||||
self.params = params
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
# (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);
|
||||
# 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
|
||||
Authlib==1.9.1
|
||||
Mako==1.4.1
|
||||
@@ -39,7 +39,7 @@ httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
idna==3.18
|
||||
itsdangerous==2.2.0
|
||||
joserfc==1.7.4
|
||||
joserfc==1.7.6
|
||||
markdown-it-py==4.2.0
|
||||
mdurl==0.1.2
|
||||
mutagen==1.48.1
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
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
|
||||
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 pathlib
|
||||
import textwrap
|
||||
|
||||
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:
|
||||
"""A detail that is a sentence: a literal, an f-string, `str(exc)`, or `"…" + x`."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return True
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
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 _offenders(sources: dict[str, str]) -> list[str]:
|
||||
return [
|
||||
f"{path}:{line}"
|
||||
for path, line, fn in Scan(sources).other_details()
|
||||
if (path, fn) not in NO_REFUSAL
|
||||
]
|
||||
|
||||
|
||||
def test_no_http_exception_carries_a_sentence():
|
||||
offenders = []
|
||||
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}")
|
||||
def test_every_detail_is_one_the_client_can_say():
|
||||
offenders = _offenders(app_sources())
|
||||
assert offenders == [], (
|
||||
"a refusal with an English sentence for a detail — raise `refused(status, reason)` "
|
||||
f"(app/refusal.py) and name the reason in both locales: {offenders}"
|
||||
"a refusal the client can only answer with its generic line — send `{code, reason}` "
|
||||
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."""
|
||||
sample = ast.parse('HTTPException(status_code=400, detail=f"bad {x}")').body[0].value
|
||||
assert _is_text(sample.keywords[1].value)
|
||||
assert not _is_text(ast.parse('{"code": "refused", "reason": "x"}').body[0].value)
|
||||
src = textwrap.dedent(
|
||||
"""
|
||||
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"]
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
@@ -144,7 +144,8 @@ npm run typecheck # tsc x3 (app / node / e2e)
|
||||
npm run lint # eslint (typed rules included; warnings fail)
|
||||
npm run format:check # prettier
|
||||
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)
|
||||
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
||||
```
|
||||
|
||||
@@ -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 }) => {
|
||||
await openApp(page, "/?page=feed");
|
||||
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();
|
||||
await tab(page, "About").click();
|
||||
const fresh = await bookmarked(browser, page);
|
||||
|
||||
@@ -48,7 +48,7 @@ test.describe("channel view", () => {
|
||||
// `.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
|
||||
// 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 page.getByTestId("channel-back").click();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
|
||||
+32
-14
@@ -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
|
||||
* 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).
|
||||
* 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 count = await links.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hittable = await links.nth(i).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return hit !== null && el.contains(hit);
|
||||
});
|
||||
if (hittable) {
|
||||
await links.nth(i).click();
|
||||
return true;
|
||||
let index = -1;
|
||||
const findHittable = async () => {
|
||||
const count = await links.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const hittable = await links
|
||||
.nth(i)
|
||||
.evaluate(
|
||||
(el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
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 false;
|
||||
return -1;
|
||||
};
|
||||
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 }));
|
||||
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) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -35,6 +35,7 @@ import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||
import TagManager from "./features/channel/TagManager";
|
||||
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import type { NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -98,7 +99,7 @@ describe("the Plex playlist page", () => {
|
||||
() => fireEvent.contextMenu(screen.getByText("Movie 1").closest("[data-nav-index]")!),
|
||||
"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))));
|
||||
expect(screen.getByText("Movie 1")).toBeTruthy();
|
||||
});
|
||||
@@ -119,7 +120,7 @@ describe("the Plex playlist page", () => {
|
||||
const field = await screen.findByDisplayValue("Films");
|
||||
fireEvent.change(field, { target: { value: "Movies" } });
|
||||
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
|
||||
});
|
||||
|
||||
@@ -154,7 +155,7 @@ describe("the add-to-playlist popover", () => {
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("playlists.addToPlaylist"));
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -282,7 +283,7 @@ describe("the add-to-playlist popover", () => {
|
||||
fireEvent.click(await screen.findByText("Gone"));
|
||||
// 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.
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -300,7 +301,7 @@ describe("the tag manager", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
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) => ({
|
||||
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||
@@ -30,6 +30,7 @@ import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
|
||||
import UploadDialog from "./modules/files/UploadDialog";
|
||||
import DownloadDialog from "./features/downloads/DownloadDialog";
|
||||
import { api, HttpError } from "./lib/api";
|
||||
import { msg, type NotifText } from "./lib/notifications";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -87,7 +88,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
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 () => {
|
||||
@@ -97,7 +98,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
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 () => {
|
||||
@@ -108,7 +109,7 @@ describe("the Plex collection editor", () => {
|
||||
);
|
||||
mount();
|
||||
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();
|
||||
pick();
|
||||
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 () => {
|
||||
@@ -172,6 +173,6 @@ describe("the download dialog", () => {
|
||||
);
|
||||
await screen.findByText("Best");
|
||||
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: () => {} }));
|
||||
|
||||
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 { createPortal, flushSync } from "react-dom";
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "info", message: "Saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("Saved") }));
|
||||
try {
|
||||
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.
|
||||
@@ -234,7 +234,7 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
<Body />
|
||||
</Modal>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "Nope" }));
|
||||
act(() => void notify({ level: "error", message: raw("Nope") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven
|
||||
@@ -455,7 +455,7 @@ describe("Modal — toasts raised while it is open", () => {
|
||||
<Body />
|
||||
</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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Toaster from "./Toaster";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -31,7 +31,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster />
|
||||
</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.");
|
||||
|
||||
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", () => {
|
||||
const Docked = () => <button>dock</button>;
|
||||
const stop = dockUnderToasts(Docked);
|
||||
notify({ level: "info", message: "hello" });
|
||||
notify({ level: "info", message: raw("hello") });
|
||||
render(<Toaster />);
|
||||
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
|
||||
@@ -75,7 +75,7 @@ describe("Toaster — one host shows the toasts", () => {
|
||||
<Toaster overlay />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
fireEvent.click(screen.getByText("boom"));
|
||||
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
|
||||
// assertively, each time a player (or any dialog) opened or closed.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
render(<Toaster overlay />);
|
||||
const old = screen.getByText("old news").closest(".toast-card")!;
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// "second" arrived (its own screen had become the handover by then).
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "error", message: "first" }));
|
||||
act(() => void notify({ level: "error", message: "second" }));
|
||||
act(() => void notify({ level: "error", message: raw("first") }));
|
||||
act(() => void notify({ level: "error", message: raw("second") }));
|
||||
const card = (text: string) => screen.getByText(text).closest(".toast-card")!;
|
||||
expect(card("first").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
|
||||
// arming has to wait for the next one — a screen reader reads the region's state after the task.
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
const overlay = render(<Toaster overlay />);
|
||||
const region = overlay.container.querySelector("[aria-live]")!;
|
||||
expect(region.textContent).toContain("saved"); // it took the toast over…
|
||||
@@ -135,7 +135,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
<Toaster overlay />
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "upload queued" });
|
||||
notify({ level: "success", message: raw("upload queued") });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -147,7 +147,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
);
|
||||
}
|
||||
render(<Stage />);
|
||||
act(() => void notify({ level: "error", message: "old news" }));
|
||||
act(() => void notify({ level: "error", message: raw("old news") }));
|
||||
fireEvent.click(screen.getByText("upload"));
|
||||
|
||||
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} />
|
||||
</div>,
|
||||
);
|
||||
act(() => void notify({ level: "error", message: "boom" }));
|
||||
act(() => void notify({ level: "error", message: raw("boom") }));
|
||||
expect(screen.getByTestId("dialog").textContent).toContain("boom");
|
||||
// The player swaps its host (leaving true fullscreen) while the dialog stays open.
|
||||
player.rerender(
|
||||
@@ -188,7 +188,7 @@ describe("Toaster — handing the toasts to another host", () => {
|
||||
const t0 = Date.now();
|
||||
const now = vi.spyOn(Date, "now").mockReturnValue(t0);
|
||||
render(<Toaster />);
|
||||
act(() => void notify({ level: "info", message: "saved" }));
|
||||
act(() => void notify({ level: "info", message: raw("saved") }));
|
||||
now.mockReturnValue(t0 + 4000);
|
||||
render(<Toaster overlay />);
|
||||
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
|
||||
// docked bar, above them at z-overlay, and took their clicks for as long as a toast was up.
|
||||
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]")!;
|
||||
expect(host.className).toContain("bottom-24");
|
||||
expect(host.className).toContain("pointer-events-none");
|
||||
@@ -218,9 +218,9 @@ describe("Toaster — a toast's actions", () => {
|
||||
() =>
|
||||
void notify({
|
||||
level: "success",
|
||||
message: "Downloaded",
|
||||
action: { label: "Copy link", onClick: copy },
|
||||
secondaryAction: { label: "Open", onClick: open },
|
||||
message: raw("Downloaded"),
|
||||
action: { label: raw("Copy link"), onClick: copy },
|
||||
secondaryAction: { label: raw("Open"), onClick: open },
|
||||
}),
|
||||
);
|
||||
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 { useOverlayOrder } from "./overlayOrder";
|
||||
import { clearAll, notify } from "../../lib/notifications";
|
||||
import { clearAll, notify, raw } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -37,7 +37,7 @@ function Harness() {
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
notify({ level: "success", message: "Uploaded" });
|
||||
notify({ level: "success", message: raw("Uploaded") });
|
||||
setOpen(false); // …in the SAME handler: one batch, one commit
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
@@ -162,9 +162,11 @@ export default function Toaster({
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||
<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">
|
||||
{toast.message}
|
||||
{sayNotif(toast.message, t)}
|
||||
{toast.repeat > 1 && (
|
||||
<span className="ml-1.5 align-middle text-2xs font-semibold text-muted tabular-nums">
|
||||
×{toast.repeat}
|
||||
@@ -174,10 +176,10 @@ export default function Toaster({
|
||||
{(toast.action || toast.secondaryAction) && (
|
||||
<div className="mt-1 flex flex-wrap gap-x-4">
|
||||
{[toast.action, toast.secondaryAction].map(
|
||||
(action) =>
|
||||
(action, i) =>
|
||||
action && (
|
||||
<Button
|
||||
key={action.label}
|
||||
key={i}
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
@@ -185,7 +187,7 @@ export default function Toaster({
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
{action.label}
|
||||
{sayNotif(action.label, t)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Copy, Link2 } from "lucide-react";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
// "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() {
|
||||
const { t } = useTranslation();
|
||||
const copy = (text: string, done: string) => {
|
||||
const copy = (text: string, done: NotifText) => {
|
||||
void copyText(text).then((ok) =>
|
||||
notify(
|
||||
ok
|
||||
? { 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"),
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
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"),
|
||||
icon: <Link2 className="w-4 h-4" />,
|
||||
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}");
|
||||
// `t("ns.…")` and `t(\`ns.…\`)` — the namespace is everything before the first dot.
|
||||
const KEY = /\bt\(\s*["`]([A-Za-z]+)\./g;
|
||||
// `t("ns.…")` and `t(\`ns.…\`)`, and a notice's `msg("ns.…")` (the same key, said when shown) — the
|
||||
// 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", () => {
|
||||
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", () => {
|
||||
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 }),
|
||||
}));
|
||||
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";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,7 +31,7 @@ describe("useCopyFeedback", () => {
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||
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));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
@@ -49,6 +53,6 @@ describe("useCopyFeedback", () => {
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
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.
|
||||
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). */
|
||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||
const { t } = useTranslation();
|
||||
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.
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
@@ -23,12 +21,12 @@ export function useCopyFeedback(): { copied: boolean; copy: (value: string) => P
|
||||
const copy = async (value: string) => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
notify({ level: "success", message: t("common.copy.done") });
|
||||
notify({ level: "success", message: msg("common.copy.done") });
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
return;
|
||||
}
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
notify({ level: "warning", message: msg("common.copy.failed") });
|
||||
};
|
||||
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",
|
||||
"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",
|
||||
"date": "2026-10-29",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
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 { useScrollFade } from "../../lib/useScrollFade";
|
||||
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 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);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
onError: (e) => onFailed(e, msg("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
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.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Loader2, Pin, PinOff } from "lucide-react";
|
||||
import { plexLang } from "../../i18n";
|
||||
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, type NotifText, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
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
|
||||
* 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. */
|
||||
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. */
|
||||
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]);
|
||||
|
||||
const add = useCallback(
|
||||
async (target: PinTarget, title: string) => {
|
||||
async (target: PinTarget, title: NotifText) => {
|
||||
try {
|
||||
await api.addComfortPin(target.kind, target.ref);
|
||||
// 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.
|
||||
notify({ level: "success", message: t("home.pin.added", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.added", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.addFailed"));
|
||||
notifyActionError(e, msg("home.pin.addFailed"));
|
||||
}
|
||||
invalidate();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(pinId: number, title: string) => {
|
||||
(pinId: number, title: NotifText) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await api.removeComfortPin(pinId);
|
||||
notify({ level: "success", message: t("home.pin.removed", { title }) });
|
||||
notify({ level: "success", message: msg("home.pin.removed", { title }) });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("home.pin.removeFailed"));
|
||||
notifyActionError(e, msg("home.pin.removeFailed"));
|
||||
}
|
||||
invalidate();
|
||||
})();
|
||||
},
|
||||
[invalidate, t],
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(target: PinTarget, title: string) => {
|
||||
(target: PinTarget, title: NotifText) => {
|
||||
const pinId = pinIdOf(target);
|
||||
if (pinId == null) void add(target, title);
|
||||
else remove(pinId, title);
|
||||
@@ -173,7 +173,7 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
);
|
||||
|
||||
const item = useCallback(
|
||||
(target: PinTarget | null, title: string): ActionMenuItem[] => {
|
||||
(target: PinTarget | null, title: NotifText): ActionMenuItem[] => {
|
||||
if (!target) return [];
|
||||
const pinId = pinIdOf(target);
|
||||
// Still loading: an honest "we don't know yet" row rather than a confident "Pin" over
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../../lib/toastDock";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -73,7 +73,7 @@ export default function DebugRecorder() {
|
||||
if (!id || !until) return;
|
||||
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
|
||||
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);
|
||||
void qc.invalidateQueries({ queryKey: qk.me() });
|
||||
}, 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 { closeComposer } from "../../lib/debugRecorder";
|
||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import {
|
||||
draftSteps,
|
||||
@@ -217,7 +217,7 @@ function ComposerBody({
|
||||
meta: { failTitle: "recorder.composer.sendFailed" },
|
||||
onSuccess: (r) => {
|
||||
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.
|
||||
onSettled: async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
} from "../../lib/debugRecorder";
|
||||
import { flushNow, liveSession } from "../../lib/diagnostics";
|
||||
import { log } from "../../lib/log";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
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
|
||||
* answer, so a reload, another tab or the cap all show the same thing. */
|
||||
export function useDebugRecording() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const session = liveSession(me?.debug_session);
|
||||
@@ -54,7 +52,7 @@ export function useDebugRecording() {
|
||||
meta: { failTitle: "recorder.pill.stopFailed" },
|
||||
onSuccess: (r) => {
|
||||
setSession(null);
|
||||
notify({ level: "success", message: t("recorder.pill.stopped") });
|
||||
notify({ level: "success", message: msg("recorder.pill.stopped") });
|
||||
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.
|
||||
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 text = label.trim();
|
||||
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
|
||||
@@ -111,7 +109,7 @@ export function useDebugRecording() {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
image = await captureTab();
|
||||
} catch (err) {
|
||||
notifyActionError(err, t("recorder.pill.shotFailed"));
|
||||
notifyActionError(err, msg("recorder.pill.shotFailed"));
|
||||
}
|
||||
hide(false);
|
||||
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.
|
||||
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
import { profileName } from "../../lib/vocab";
|
||||
@@ -63,15 +63,15 @@ export default function DownloadDialog({
|
||||
setLastProfile(chosen);
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("downloads.dialog.added"),
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||
message: msg("downloads.dialog.added"),
|
||||
action: { label: msg("downloads.dialog.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// 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
|
||||
// open with nothing said.
|
||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||
notifyActionError(e, msg("errors.writeFailed.enqueueDownload"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
|
||||
import DownloadTray from "./DownloadTray";
|
||||
import { msg, type NotifText, raw } from "../../lib/notifications";
|
||||
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -199,14 +200,15 @@ describe("DownloadTray", () => {
|
||||
poll([job(1, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
const notice = h.notify.mock.calls[0]![0] as {
|
||||
title: string;
|
||||
message: string;
|
||||
action: { label: string; onClick: () => void };
|
||||
secondaryAction: { label: string; onClick: () => void };
|
||||
title: NotifText;
|
||||
message: NotifText;
|
||||
action: { label: NotifText; onClick: () => void };
|
||||
secondaryAction: { label: NotifText; onClick: () => void };
|
||||
};
|
||||
expect(notice.title).toBe("tray.done.title:Clip 1");
|
||||
expect(notice.message).toBe("tray.done.phone");
|
||||
expect(notice.action.label).toBe("tray.done.copyLink");
|
||||
expect(notice.title).toEqual(msg("tray.done.title", { title: raw("Clip 1") }));
|
||||
expect(notice.message).toEqual(msg("tray.done.phone"));
|
||||
expect(notice.action.label).toEqual(msg("tray.done.copyLink"));
|
||||
expect(notice.secondaryAction.label).toEqual(msg("tray.done.open"));
|
||||
notice.action.onClick();
|
||||
expect(h.share).toHaveBeenCalled();
|
||||
notice.secondaryAction.onClick();
|
||||
@@ -225,9 +227,9 @@ describe("DownloadTray", () => {
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(1));
|
||||
poll([job(1, "done"), job(2, "done")], view);
|
||||
await waitFor(() => expect(h.notify).toHaveBeenCalledTimes(2));
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: string }).title)).toEqual([
|
||||
"tray.done.title:Clip 1",
|
||||
"tray.done.title:Clip 2",
|
||||
expect(h.notify.mock.calls.map((c) => (c[0] as { title: NotifText }).title)).toEqual([
|
||||
msg("tray.done.title", { title: raw("Clip 1") }),
|
||||
msg("tray.done.title", { title: raw("Clip 2") }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -236,7 +238,9 @@ describe("DownloadTray", () => {
|
||||
const view = mount();
|
||||
poll([job(1, "done", { asset: { media_kind: "file" } as never })], view);
|
||||
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);
|
||||
await waitFor(() =>
|
||||
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";
|
||||
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify, raw } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
@@ -191,16 +191,16 @@ export default function DownloadTray() {
|
||||
notify({
|
||||
level: "success",
|
||||
// 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,
|
||||
title: msg(job.source_key === "upload" ? "tray.done.uploadedTitle" : "tray.done.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: {
|
||||
label: t("tray.done.copyLink"),
|
||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }, t),
|
||||
label: msg("tray.done.copyLink"),
|
||||
onClick: () => void quickShareLink(qc, { kind: "file", id: job.id }),
|
||||
},
|
||||
secondaryAction: {
|
||||
label: t("tray.done.open"),
|
||||
label: msg("tray.done.open"),
|
||||
onClick: () => openFile(job.id, job.folder_id),
|
||||
},
|
||||
});
|
||||
@@ -298,7 +298,7 @@ export default function DownloadTray() {
|
||||
id: "copyLink",
|
||||
label: t("tray.done.copyLink"),
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
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
|
||||
// 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.cancelDownload(id),
|
||||
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) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
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
|
||||
// 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 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;
|
||||
try {
|
||||
await fn();
|
||||
@@ -146,28 +147,28 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
if (!clean) return false;
|
||||
return runViewMutation(
|
||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
},
|
||||
rename: async (id, name) => {
|
||||
const next = name.trim();
|
||||
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) =>
|
||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||
runViewMutation(() => api.updateView(id, { filters }), msg("views.actionFailed")),
|
||||
saveCurrent: async (id) => {
|
||||
const filters = cfg.stripForSave(cfg.filters);
|
||||
const ok = await runViewMutation(
|
||||
() => api.updateView(id, { filters }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
);
|
||||
return ok ? filters : null;
|
||||
},
|
||||
toggleDefault: (v) =>
|
||||
runViewMutation(
|
||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||
t("views.actionFailed"),
|
||||
msg("views.actionFailed"),
|
||||
).then(() => undefined),
|
||||
remove: async (v) => {
|
||||
const ok = await confirm({
|
||||
@@ -177,14 +178,14 @@ export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||
});
|
||||
if (!ok) return false;
|
||||
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) => {
|
||||
qc.setQueryData<SavedView[]>(
|
||||
qk.savedViews(source),
|
||||
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();
|
||||
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||||
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 { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -188,7 +189,7 @@ describe("ClipPanel", () => {
|
||||
display_name: "The bit",
|
||||
});
|
||||
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.
|
||||
(
|
||||
@@ -214,7 +215,7 @@ describe("ClipPanel", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "clip.cut" }));
|
||||
await waitFor(() => expect(notifyActionError).toHaveBeenCalledTimes(1));
|
||||
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((screen.getByRole("button", { name: "clip.cut" }) as HTMLButtonElement).disabled).toBe(
|
||||
false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "luc
|
||||
import { api, type ClipRequest } from "../../lib/api";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { invalidateDownloads } from "../../lib/downloads";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { msg, notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { showInTray } from "../../lib/downloadQueue";
|
||||
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) });
|
||||
notify({
|
||||
level: "success",
|
||||
message: t("clip.queued"),
|
||||
action: { label: t("clip.view"), onClick: () => showInTray(job.id) },
|
||||
message: msg("clip.queued"),
|
||||
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
// 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.
|
||||
notifyActionError(err, t("clip.failed"));
|
||||
notifyActionError(err, msg("clip.failed"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type Video, type VideoStatus } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
@@ -7,6 +6,7 @@ import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useStableCallback } from "../../lib/useLatest";
|
||||
import { writeVideoState } from "../video/videoWrites";
|
||||
import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { msg } from "../../lib/notifications";
|
||||
|
||||
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
|
||||
* alone. Decided at boot, once — `hosted` is whether the page on screen then opens it itself. */
|
||||
export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [own] = useState(!hosted);
|
||||
const [video, setVideo] = useState<Video | null>(null);
|
||||
@@ -27,7 +26,7 @@ export function LinkedPlayer({ hosted }: { hosted: boolean }) {
|
||||
const write = writeVideoState(qc, id, status);
|
||||
write
|
||||
.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;
|
||||
});
|
||||
const close = useStableCallback(() => setVideo(null));
|
||||
|
||||
@@ -40,7 +40,7 @@ import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
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 { writeVideoState } from "../video/videoWrites";
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
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.");
|
||||
});
|
||||
|
||||
@@ -336,7 +336,7 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
||||
try {
|
||||
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"));
|
||||
} finally {
|
||||
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
||||
|
||||
@@ -26,6 +26,7 @@ import VideoCard from "../video/VideoCard";
|
||||
import { VideoMenu } from "../video/VideoMenu";
|
||||
import { announceVideoState } from "../video/stateNotice";
|
||||
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,
|
||||
* 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"));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("errors.writeFailed.videoState"));
|
||||
notifyActionError(e, msg("errors.writeFailed.videoState"));
|
||||
settle(seq);
|
||||
});
|
||||
};
|
||||
@@ -118,7 +119,7 @@ export function RelatedVideos({
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
msg(saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove"),
|
||||
);
|
||||
settle(seq);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useOpenLinkedPlayer } from "./useOpenLinkedPlayer";
|
||||
import { LinkedPlayer } from "./LinkedPlayer";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { msg } from "../../lib/notifications";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
const vid = (id: string) => ({ id, title: id }) as unknown as Video;
|
||||
@@ -76,7 +77,7 @@ describe("useOpenLinkedPlayer", () => {
|
||||
const open = vi.fn();
|
||||
renderHook(() => useOpenLinkedPlayer(true, () => undefined, open), { wrapper });
|
||||
await waitFor(() =>
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, "player.linkedFailed"),
|
||||
expect(notifyActionError).toHaveBeenCalledWith(gone, msg("player.linkedFailed")),
|
||||
);
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
// 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 { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type Video } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { linkedPlayer, PLAYER_KEYS } from "../../lib/playerUrlState";
|
||||
import { clearParams } from "../../lib/urlState";
|
||||
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
|
||||
* 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,
|
||||
scope: string | number | null = null,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
const [linked] = useState(() => linkedPlayer()?.watch ?? null);
|
||||
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
|
||||
@@ -40,7 +39,7 @@ export function useOpenLinkedPlayer(
|
||||
});
|
||||
const openIt = useEffectEvent(open);
|
||||
const failed = useEffectEvent((err: unknown) => {
|
||||
notifyActionError(err, t("player.linkedFailed"));
|
||||
notifyActionError(err, msg("player.linkedFailed"));
|
||||
clearParams(...PLAYER_KEYS);
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
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
|
||||
// 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);
|
||||
qc.setQueryData<typeof list.data>(forKey, (old) => flipContains(old, c.id));
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -83,7 +84,7 @@ export default function PlexCollectionAddAt({
|
||||
made = true;
|
||||
} catch (e) {
|
||||
made = madeOnPlex(e);
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
notifyActionError(e, msg("plex.collection.editFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -12,6 +12,7 @@ import Modal from "../../components/ui/Modal";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
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
|
||||
// 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) {
|
||||
notifyActionError(
|
||||
e,
|
||||
t(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
msg(allIn ? "plex.playlistAdd.removeFailed" : "plex.playlistAdd.addFailed"),
|
||||
);
|
||||
}
|
||||
refresh();
|
||||
@@ -104,7 +105,7 @@ export default function PlexPlaylistAdd({
|
||||
} catch (e) {
|
||||
// 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.
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
notifyActionError(e, msg("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -74,6 +74,8 @@ describe("a season's name", () => {
|
||||
["Season 1", 1, "plex.seasonTitle(1)"],
|
||||
["Specials", 0, "plex.specials"],
|
||||
["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) => {
|
||||
render(<SeasonCard se={season(title, n)} onOpen={vi.fn()} />);
|
||||
expect(screen.getByText(shown)).toBeTruthy();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { pinPlex, useComfortPin } from "../comfort/comfortPin";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
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
|
||||
// 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 })),
|
||||
...(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 { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
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
|
||||
// "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)),
|
||||
);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
notifyActionError(e, msg(pl.has_video ? "playlists.removeFailed" : "playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
@@ -137,7 +138,7 @@ function MembershipPanel({
|
||||
await addPlaylistItem(qc, pl, videoId);
|
||||
made = true;
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("playlists.addFailed"));
|
||||
notifyActionError(e, msg("playlists.addFailed"));
|
||||
}
|
||||
refresh();
|
||||
setBusy(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
/** 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";
|
||||
notify({
|
||||
message: video?.title
|
||||
? i18n.t(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: video.title })
|
||||
: i18n.t(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
action: { label: i18n.t(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||
? msg(hidden ? "feed.hiddenNamed" : "feed.markedWatchedNamed", { title: raw(video.title) })
|
||||
: msg(hidden ? "feed.hidden" : "feed.markedWatched"),
|
||||
action: { label: msg(hidden ? "feed.undo" : "feed.unwatch"), onClick: undo },
|
||||
meta: {
|
||||
kind: hidden ? "video-hidden" : "video-watched",
|
||||
videoId: id,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { useNavigationActions } from "../navigation/navigationContext";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
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
|
||||
// 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 { 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;
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("the onboarding wizard as a dialog", () => {
|
||||
document.body.appendChild(outside);
|
||||
outside.focus();
|
||||
const { unmount } = mount();
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
dismissToast.focus();
|
||||
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.
|
||||
mount();
|
||||
const panel = screen.getByRole("dialog");
|
||||
act(() => void notify({ level: "error", message: "offline" }));
|
||||
act(() => void notify({ level: "error", message: raw("offline") }));
|
||||
try {
|
||||
const dismissToast = screen.getByTitle("notifications.dismiss");
|
||||
expect(panel.contains(dismissToast)).toBe(false);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
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";
|
||||
|
||||
// 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) =>
|
||||
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<
|
||||
string,
|
||||
{ default: Record<string, unknown> }
|
||||
@@ -48,12 +75,11 @@ const keysOf = (lang: string): Set<string> => {
|
||||
const specs = [
|
||||
...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]! }));
|
||||
// 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 choicesOf = (attr: string): string[] => {
|
||||
const alias = configPy.match(new RegExp(`^\\s+${attr}: (\\w+) =`, "m"))?.[1];
|
||||
const body = alias && configPy.match(new RegExp(`^${alias} = Literal\\[([^\\]]*)\\]`, "m"))?.[1];
|
||||
return [...(body ?? "").matchAll(/"(\w+)"/g)].map((m) => m[1]!);
|
||||
return (alias && scan.aliases["config.py"]?.[alias]) || [];
|
||||
};
|
||||
const choiceIds = specs
|
||||
.filter((s) => s.type === "choice")
|
||||
@@ -84,20 +110,10 @@ const runEnum = (name: string) =>
|
||||
const runStatuses = runEnum("RunStatus");
|
||||
const runTriggers = runEnum("RunTrigger");
|
||||
const quotaActions = enumValues("quota.py");
|
||||
// The admin actions' refusal reasons → `errors.admin.<reason>`. The codes are read from
|
||||
// `localizeDetail`'s own ADMIN_REFUSALS, so a code added there is scanned here too; a detail dict
|
||||
// ruff wraps over several lines still matches.
|
||||
const adminCodes = [
|
||||
...(
|
||||
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 admin actions' refusals → `errors.admin.<reason>`: the codes recorded under that prefix.
|
||||
const adminCodes = Object.entries(REFUSAL_FAMILIES)
|
||||
.filter(([, f]) => f.prefix === "errors.admin")
|
||||
.map(([code]) => code);
|
||||
// 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
|
||||
// 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
|
||||
// `owned_or_404(..., what="x")` / `get_or_404(..., what="x")` under routes/, read from the sources.
|
||||
const routeFiles = (dir = "routes"): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}/`, import.meta.url), {
|
||||
// Every backend source, by its path under app/.
|
||||
const appPaths = (dir = ""): string[] =>
|
||||
readdirSync(new URL(`../../../backend/app/${dir}`, import.meta.url), {
|
||||
withFileTypes: true,
|
||||
}).flatMap((e) =>
|
||||
e.isDirectory()
|
||||
? routeFiles(`${dir}/${e.name}`)
|
||||
? e.name === "__pycache__"
|
||||
? []
|
||||
: appPaths(`${dir}${e.name}/`)
|
||||
: 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
|
||||
// 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
|
||||
// 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 = [
|
||||
...new Set(
|
||||
appFiles("").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,
|
||||
appPaths()
|
||||
.filter((f) => !f.startsWith("routes/") && f !== "main.py")
|
||||
.flatMap((f) =>
|
||||
[...backend(f).matchAll(/"(?:skipped|reason)": "([^"]+)"/g)].map((m) => m[1]!),
|
||||
),
|
||||
].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>`
|
||||
// (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
|
||||
@@ -275,10 +235,11 @@ describe("backend ids have their names in both languages", () => {
|
||||
expect(runTriggers).toEqual(expect.arrayContaining(["scheduled", "ad_hoc"]));
|
||||
expect(adminCodes).toEqual(expect.arrayContaining(["activate", "resend"]));
|
||||
expect(failDetails).toEqual(expect.arrayContaining(["facebook.login_wall"]));
|
||||
expect(goneWhats).toEqual(expect.arrayContaining(["playlist", "download", "job"]));
|
||||
expect(opaqueWhats).toEqual([]);
|
||||
expect(youtubeReasons).toEqual(["quota", "reconnect", "unavailable", "gone"]);
|
||||
expect(clipReasons).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||
expect(reasonsOf.not_found).toEqual(
|
||||
expect.arrayContaining(["playlist", "download", "job", "user", "debug_session"]),
|
||||
);
|
||||
expect(reasonsOf.youtube).toEqual(["gone", "quota", "reconnect", "unavailable"]);
|
||||
expect(reasonsOf.clip).toEqual(expect.arrayContaining(["too_long", "past_end", "bad_crop"]));
|
||||
expect(crewJobs).toEqual(
|
||||
expect.arrayContaining(["Director", "Creator", "Director of Photography"]),
|
||||
);
|
||||
@@ -294,11 +255,46 @@ describe("backend ids have their names in both languages", () => {
|
||||
"no service user",
|
||||
]),
|
||||
);
|
||||
expect(refusedReasons).toEqual(
|
||||
expect.arrayContaining(["weak_password", "setting_too_low", "plex_write_failed"]),
|
||||
expect(reasonsOf.refused).toEqual(
|
||||
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", () => {
|
||||
@@ -418,33 +414,6 @@ describe("backend ids have their names in both languages", () => {
|
||||
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`, () => {
|
||||
for (const [group, words] of Object.entries(VOCAB)) {
|
||||
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`, () => {
|
||||
expect(missing(adminReasons.map((r) => `errors.admin.${r}`))).toEqual([]);
|
||||
expect([...idsUnder(keys, "errors.admin")].filter((r) => !adminReasons.includes(r))).toEqual(
|
||||
[],
|
||||
);
|
||||
it(`${lang}: every refusal the backend can answer has its sentence`, () => {
|
||||
const wanted = [
|
||||
...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": {
|
||||
"empty_edit": "Choose a trim range or crop area first",
|
||||
"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"
|
||||
},
|
||||
"upload": {
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"playShow": "Play the show",
|
||||
"playSeason": "Play the season",
|
||||
"removeFromPlaylist": "Remove from the playlist",
|
||||
"seasonOfShow": "{{show}} · {{season}}",
|
||||
"removedToast": "“{{title}}” removed from the playlist",
|
||||
"undoRemoveFailed": "Couldn't put it back in the playlist"
|
||||
},
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"edit": {
|
||||
"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ő",
|
||||
"not_media": "Csak videó és hang szerkeszthető",
|
||||
"generic": "Nem sikerült létrehozni a vágást"
|
||||
},
|
||||
"upload": {
|
||||
|
||||
@@ -344,6 +344,7 @@
|
||||
"playShow": "Sorozat lejátszása",
|
||||
"playSeason": "Évad lejátszása",
|
||||
"removeFromPlaylist": "Eltávolítás a listáról",
|
||||
"seasonOfShow": "{{show}} · {{season}}",
|
||||
"removedToast": "„{{title}}” eltávolítva a listáról",
|
||||
"undoRemoveFailed": "Nem sikerült visszatenni a listára"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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
|
||||
// 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
|
||||
@@ -49,9 +51,14 @@ const code = sources(SRC).join("\n");
|
||||
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`,
|
||||
// `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 = [
|
||||
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/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("${"))
|
||||
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
reloadAfterRefusal,
|
||||
} from "./entryUrl";
|
||||
import { HttpError } from "./api/core";
|
||||
import { raw } from "./notifications";
|
||||
|
||||
const notice = (search: string, 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({
|
||||
level: "info",
|
||||
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", () => {
|
||||
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"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// 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() }));
|
||||
|
||||
import { HttpError, api } from "./api";
|
||||
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 { failureReason, notifyActionError, notifyKnownFailure } from "./actionErrors";
|
||||
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);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "EDIT_FAILED");
|
||||
notifyActionError(err, raw("EDIT_FAILED"));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -52,26 +65,26 @@ describe("notifyActionError", () => {
|
||||
);
|
||||
const err = await api.plexSetState("1", "watched").catch((e: unknown) => e);
|
||||
notifyMock.mockClear();
|
||||
notifyActionError(err, "FAILED");
|
||||
notifyActionError(err, raw("FAILED"));
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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.
|
||||
const err = new HttpError(404, "gone");
|
||||
notifyActionError(err, "FIRST");
|
||||
notifyActionError(err, "SECOND");
|
||||
notifyActionError(err, raw("FIRST"));
|
||||
notifyActionError(err, raw("SECOND"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(alreadyReported(err)).toBe(true);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -79,27 +92,32 @@ describe("notifyActionError", () => {
|
||||
// `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
|
||||
// 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({
|
||||
level: "error",
|
||||
title: "TRANSLATED",
|
||||
message: i18n.t("errors.notFound.generic"),
|
||||
title: raw("TRANSLATED"),
|
||||
message: msg("errors.notFound.generic"),
|
||||
});
|
||||
});
|
||||
|
||||
it("still prefers a STRUCTURED reason, which the request layer has already localized", () => {
|
||||
// `req` runs `{code, reason}` through localizeDetail, so `detail` is in the reader's language
|
||||
// AND more specific than anything a status can say — quota numbers, which row is gone.
|
||||
it("still prefers a STRUCTURED reason, kept as the refusal and said in the reader's language", () => {
|
||||
// The notice keeps `{code, reason}` and says it through localizeDetail when shown: in the
|
||||
// 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.", {
|
||||
code: "not_found",
|
||||
reason: "channel",
|
||||
});
|
||||
notifyActionError(err, "TRANSLATED FALLBACK");
|
||||
notifyActionError(err, raw("TRANSLATED FALLBACK"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: "TRANSLATED FALLBACK",
|
||||
message: "Ez a csatorna már nincs a könyvtárban.",
|
||||
title: raw("TRANSLATED FALLBACK"),
|
||||
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
|
||||
[500, "errors.generic"],
|
||||
])("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", () => {
|
||||
expect(failureReason(new TypeError("Failed to fetch"))).toBe(
|
||||
expect(sayNotif(failureReason(new TypeError("Failed to fetch")))).toBe(
|
||||
i18n.t("errors.reason.unreachable"),
|
||||
);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -142,9 +164,9 @@ describe("failureReason — why a write failed, when the server did not say", ()
|
||||
describe("notifyKnownFailure", () => {
|
||||
it("shows the caller's specific sentence and marks the failure reported", () => {
|
||||
const err = new HttpError(409, "x", { code: "resend", reason: "email_off" });
|
||||
notifyKnownFailure(err, "EMAIL IS OFF");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EMAIL IS OFF" });
|
||||
notifyActionError(err, "GENERIC");
|
||||
notifyKnownFailure(err, raw("EMAIL IS OFF"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: raw("EMAIL IS OFF") });
|
||||
notifyActionError(err, raw("GENERIC"));
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// 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.
|
||||
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import i18n from "../i18n";
|
||||
import { msg, notify, type NotifText } from "./notifications";
|
||||
|
||||
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
|
||||
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
|
||||
* 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
|
||||
* 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
|
||||
* "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
|
||||
* says it.
|
||||
*/
|
||||
export function failureReason(err: unknown): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
export function failureReason(err: unknown): NotifText {
|
||||
const t = msg;
|
||||
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
|
||||
// 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");
|
||||
@@ -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
|
||||
* 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
|
||||
* the app-wide mutation reporter in lib/queryClient.
|
||||
*/
|
||||
export function notifyActionError(
|
||||
err: unknown,
|
||||
title: string,
|
||||
title: NotifText,
|
||||
level: "error" | "warning" = "error",
|
||||
): void {
|
||||
// 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,
|
||||
* 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;
|
||||
notify({ level: "error", message });
|
||||
markReported(err);
|
||||
|
||||
@@ -14,7 +14,12 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
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" } }));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// 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,
|
||||
// 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 { formatBytes, formatDecimal } from "../format";
|
||||
import { localizeDetail } from "./refusalText";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
@@ -71,121 +71,6 @@ export const refusalReason = (err: unknown): string | undefined =>
|
||||
? (err.detailData as { reason?: string } | undefined)?.reason
|
||||
: 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
|
||||
// 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
|
||||
@@ -199,8 +84,8 @@ function markConnectivityLost(): void {
|
||||
if (id !== null) remove(id);
|
||||
connectivityNotifId = notify({
|
||||
level: "error",
|
||||
title: i18n.t("errors.offline.title"),
|
||||
message: i18n.t("errors.offline.body"),
|
||||
title: msg("errors.offline.title"),
|
||||
message: msg("errors.offline.body"),
|
||||
transient: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../errorDialog", () => ({ reportError: vi.fn() }));
|
||||
vi.mock("../notifications", () => ({
|
||||
vi.mock("../notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../notifications")>()),
|
||||
notify: vi.fn(() => 1),
|
||||
remove: vi.fn(),
|
||||
getActiveToasts: vi.fn((): { id: number }[] => []),
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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 { 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 { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -37,8 +39,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.updateFailed"),
|
||||
message: i18n.t("errors.reason.forbidden"),
|
||||
title: msg("channels.tags.updateFailed"),
|
||||
message: msg("errors.reason.forbidden"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,8 +54,8 @@ describe("useChannelTagActions", () => {
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("channels.tags.createFailed"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("channels.tags.createFailed"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -76,8 +78,8 @@ describe("useChannelPriority", () => {
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith("UC1"));
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.writeFailed.channelPriority"),
|
||||
message: i18n.t("errors.reason.unreachable"),
|
||||
title: msg("errors.writeFailed.channelPriority"),
|
||||
message: msg("errors.reason.unreachable"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,12 @@
|
||||
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
|
||||
// account's stored history with it.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { raw, sayNotif, type NotifText } from "./notifications";
|
||||
|
||||
const entry = (id: number, message: string) => ({
|
||||
id,
|
||||
level: "info",
|
||||
message,
|
||||
message: raw(message),
|
||||
ts: 1,
|
||||
read: true,
|
||||
dismissed: true,
|
||||
@@ -19,10 +20,10 @@ const entry = (id: number, message: string) => ({
|
||||
});
|
||||
const storedMessages = () =>
|
||||
(
|
||||
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
|
||||
message: string;
|
||||
JSON.parse(localStorage.getItem("siftlode.notifHistory") ?? "[]") as {
|
||||
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
|
||||
// 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 }, () => {
|
||||
it("keep the account's notification history and settings once it is claimed", async () => {
|
||||
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 }));
|
||||
const notifications = await import("./notifications");
|
||||
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);
|
||||
notifications.notify({ message: "after the claim" });
|
||||
notifications.notify({ message: raw("after the claim") });
|
||||
|
||||
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
|
||||
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 }, () => {
|
||||
it("starts the new account's notices from an empty history", async () => {
|
||||
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 { claimLocalData } = await import("./storage");
|
||||
|
||||
claimLocalData(5);
|
||||
notifications.notify({ message: "as five" });
|
||||
notifications.notify({ message: raw("as five") });
|
||||
|
||||
expect(storedMessages()).toEqual(["as five"]);
|
||||
});
|
||||
|
||||
@@ -21,18 +21,21 @@ vi.mock("./api", () => ({
|
||||
api: { downloadLinks, createDownloadLink, folderLinks, createFolderLink },
|
||||
}));
|
||||
vi.mock("./clipboard", () => ({ copyText }));
|
||||
vi.mock("./notifications", () => ({ notify }));
|
||||
vi.mock("./notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import { quickShareLink } from "./downloads";
|
||||
import { qk } from "./queryKeys";
|
||||
import { msg, type NotifText } from "./notifications";
|
||||
|
||||
const t = (k: string) => k;
|
||||
const origin = window.location.origin;
|
||||
|
||||
type ToastArg = {
|
||||
level: string;
|
||||
message: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
message: NotifText;
|
||||
action?: { label: NotifText; onClick: () => void };
|
||||
};
|
||||
const firstToast = () => notify.mock.calls[0]![0] as ToastArg;
|
||||
|
||||
@@ -55,7 +58,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 7, url: "/watch/tok7" });
|
||||
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`);
|
||||
// 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`);
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("success");
|
||||
expect(arg.message).toBe("downloads.share.linkCopied");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
expect(arg.message).toEqual(msg("downloads.share.linkCopied"));
|
||||
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||
arg.action!.onClick();
|
||||
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 () => {
|
||||
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(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -85,7 +88,7 @@ describe("quickShareLink", () => {
|
||||
{ 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(createDownloadLink).not.toHaveBeenCalled();
|
||||
@@ -97,7 +100,7 @@ describe("quickShareLink", () => {
|
||||
createDownloadLink.mockResolvedValue({ id: 8, url: "/watch/new" });
|
||||
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(copyText).toHaveBeenCalledWith(`${origin}/watch/new`);
|
||||
@@ -113,7 +116,7 @@ describe("quickShareLink", () => {
|
||||
const client = qc();
|
||||
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(folderLinks).toHaveBeenCalledWith(5);
|
||||
@@ -129,26 +132,26 @@ describe("quickShareLink", () => {
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/x" }]);
|
||||
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
|
||||
const arg = firstToast();
|
||||
expect(arg.level).toBe("warning");
|
||||
expect(arg.message).toBe("downloads.share.copyFailed");
|
||||
expect(arg.action!.label).toBe("downloads.share.openNewTab");
|
||||
expect(arg.message).toEqual(msg("downloads.share.copyFailed"));
|
||||
expect(arg.action!.label).toEqual(msg("downloads.share.openNewTab"));
|
||||
});
|
||||
|
||||
it("reports an error and returns null when link creation fails", async () => {
|
||||
downloadLinks.mockResolvedValue([]);
|
||||
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(copyText).not.toHaveBeenCalled();
|
||||
// Titled with what failed, and carrying why (notifyActionError), not a bare message.
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "downloads.share.linkFailed" }),
|
||||
expect.objectContaining({ level: "error", title: msg("downloads.share.linkFailed") }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
export async function quickShareLink(
|
||||
qc: QueryClient,
|
||||
target: LinkTarget,
|
||||
t: (key: string) => string,
|
||||
): Promise<string | null> {
|
||||
export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promise<string | null> {
|
||||
let path;
|
||||
try {
|
||||
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");
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("downloads.share.linkFailed"));
|
||||
notifyActionError(e, msg("downloads.share.linkFailed"));
|
||||
return null;
|
||||
}
|
||||
const url = window.location.origin + path;
|
||||
const action = {
|
||||
label: t("downloads.share.openNewTab"),
|
||||
label: msg("downloads.share.openNewTab"),
|
||||
onClick: () => window.open(url, "_blank", "noopener,noreferrer"),
|
||||
};
|
||||
notify(
|
||||
(await copyText(url))
|
||||
? { level: "success", message: t("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: t("downloads.share.copyFailed"), action },
|
||||
? { level: "success", message: msg("downloads.share.linkCopied"), action }
|
||||
: { level: "warning", message: msg("downloads.share.copyFailed"), action },
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// 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.
|
||||
|
||||
import type { NotifLevel } from "./notifications";
|
||||
import { raw, type NotifLevel, type NotifValue } from "./notifications";
|
||||
import { refusalReason } from "./api/core";
|
||||
import { hrefHere } from "./urlState";
|
||||
|
||||
@@ -88,7 +88,7 @@ export function clearEntryUrl(): void {
|
||||
export interface AccountNotice {
|
||||
level: NotifLevel;
|
||||
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
|
||||
@@ -110,7 +110,7 @@ export function accountSwitchNotice(
|
||||
const access = hash.get("access");
|
||||
const email = hash.get("linked") || hash.get("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")
|
||||
return { level: "warning", key: "header.account.switchNotApproved" };
|
||||
return null;
|
||||
|
||||
@@ -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,
|
||||
getActiveToasts,
|
||||
notify,
|
||||
raw,
|
||||
} from "./notifications";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
@@ -19,14 +20,14 @@ afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const undo = { label: "Undo", onClick: () => {} };
|
||||
const undo = { label: raw("Undo"), onClick: () => {} };
|
||||
const shown = (id: number) => getActiveToasts().some((n) => n.id === id);
|
||||
|
||||
describe("an action toast's lifetime", () => {
|
||||
it("stays for the floor when the reader's toasts last 3 s", () => {
|
||||
configureNotifications({ durationMs: 3000 });
|
||||
const plain = notify({ message: "plain" });
|
||||
const withUndo = notify({ message: "removed", action: undo });
|
||||
const plain = notify({ message: raw("plain") });
|
||||
const withUndo = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(3001);
|
||||
expect(shown(plain)).toBe(false);
|
||||
expect(shown(withUndo)).toBe(true);
|
||||
@@ -36,7 +37,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("keeps a longer setting", () => {
|
||||
configureNotifications({ durationMs: 12000 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(ACTION_TTL_MIN + 1);
|
||||
expect(shown(id)).toBe(true);
|
||||
vi.advanceTimersByTime(12000 - ACTION_TTL_MIN);
|
||||
@@ -45,7 +46,7 @@ describe("an action toast's lifetime", () => {
|
||||
|
||||
it("never dismisses when the reader chose never", () => {
|
||||
configureNotifications({ durationMs: 0 });
|
||||
const id = notify({ message: "removed", action: undo });
|
||||
const id = notify({ message: raw("removed"), action: undo });
|
||||
vi.advanceTimersByTime(60000);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,61 @@
|
||||
// the Notification Center shows (info events, actions awaiting interaction, and app
|
||||
// errors). History is persisted to localStorage so it survives reloads; action
|
||||
// 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 { 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.
|
||||
const LOG_LEVEL: Record<NotifLevel, Level> = {
|
||||
info: "INFO",
|
||||
@@ -17,7 +69,8 @@ const LOG_LEVEL: Record<NotifLevel, Level> = {
|
||||
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,8 +109,8 @@ export type NotifMeta =
|
||||
export interface Notification {
|
||||
id: number;
|
||||
level: NotifLevel;
|
||||
title?: string;
|
||||
message: string;
|
||||
title?: NotifText;
|
||||
message: NotifText;
|
||||
action?: NotifAction;
|
||||
// A second, lesser action beside it (a done download's "Open" after its "Copy link").
|
||||
secondaryAction?: NotifAction;
|
||||
@@ -79,9 +132,9 @@ export interface Notification {
|
||||
}
|
||||
|
||||
export interface NotifyInput {
|
||||
message: string;
|
||||
message: NotifText;
|
||||
level?: NotifLevel;
|
||||
title?: string;
|
||||
title?: NotifText;
|
||||
action?: NotifAction;
|
||||
secondaryAction?: NotifAction;
|
||||
meta?: NotifMeta;
|
||||
@@ -175,17 +228,21 @@ let cachedUnread = 0;
|
||||
|
||||
function load(): Notification[] {
|
||||
try {
|
||||
const raw = readAccount<unknown>(HISTORY_KEY, []);
|
||||
if (!Array.isArray(raw)) return [];
|
||||
// The old key only until the new one exists: its rows carry forward, said text as `raw`.
|
||||
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
|
||||
// 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
|
||||
// live `action` callback, which can't survive serialization.
|
||||
return raw.map(
|
||||
return stored.map(
|
||||
(n) =>
|
||||
({
|
||||
repeat: 1,
|
||||
...(n as object),
|
||||
title: asText((n as { title?: unknown }).title),
|
||||
message: asText((n as { message?: unknown }).message) ?? raw(""),
|
||||
action: undefined,
|
||||
secondaryAction: undefined,
|
||||
dismissed: true,
|
||||
@@ -261,9 +318,12 @@ const COALESCE_WINDOW_MS = 5000;
|
||||
export function notify(input: NotifyInput): number {
|
||||
const requiresInteraction = input.requiresInteraction ?? false;
|
||||
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,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.title ? { title: sayNotif(input.title, en) } : {}),
|
||||
...(input.meta ? { kind: input.meta.kind } : {}),
|
||||
});
|
||||
const now = Date.now();
|
||||
@@ -294,8 +354,8 @@ export function notify(input: NotifyInput): number {
|
||||
!n.action &&
|
||||
now - n.ts <= COALESCE_WINDOW_MS &&
|
||||
n.level === level &&
|
||||
(n.title ?? "") === (input.title ?? "") &&
|
||||
n.message === input.message,
|
||||
sameText(n.title, input.title) &&
|
||||
sameText(n.message, input.message),
|
||||
);
|
||||
if (prior) {
|
||||
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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
|
||||
* label instead. Was hand-inlined in Playlists, PlaylistsRail and AddToPlaylist; one copy now. */
|
||||
export function playlistName(p: { kind: string; name: string }, t: TFunction): string {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { REDUCED_MOTION, useMediaQuery } from "./useMediaQuery";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { TFunction } from "i18next";
|
||||
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
|
||||
// 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 },
|
||||
t: TFunction,
|
||||
): 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;
|
||||
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return se.title;
|
||||
if (n === 0) return t("plex.specials");
|
||||
if (n != null) return t("plex.seasonTitle", { n });
|
||||
return se.title || t("plex.playlist.seasonUnknown");
|
||||
if (se.title && !GENERIC_SEASON.test(se.title.trim())) return raw(se.title);
|
||||
if (n === 0) return msg("plex.specials");
|
||||
if (n != null) return msg("plex.seasonTitle", { n });
|
||||
return se.title ? raw(se.title) : msg("plex.playlist.seasonUnknown");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type PlexCard } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
// 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
|
||||
@@ -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.
|
||||
export async function toggleWatchState(
|
||||
card: PlexCard,
|
||||
t: TFunction,
|
||||
handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void },
|
||||
) {
|
||||
const next = card.status === "watched" ? "new" : "watched";
|
||||
@@ -21,7 +20,7 @@ export async function toggleWatchState(
|
||||
await api.plexSetState(card.id, next);
|
||||
} catch (e) {
|
||||
handlers.revert?.();
|
||||
notifyActionError(e, t("plex.watchedFailed"));
|
||||
notifyActionError(e, msg("plex.watchedFailed"));
|
||||
}
|
||||
handlers.resync();
|
||||
}
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
// silence on a 403/404/429 or a lost connection — 34 of them.
|
||||
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() }));
|
||||
|
||||
import { MutationObserver, type MutationObserverOptions } from "@tanstack/react-query";
|
||||
import { createQueryClient } from "./queryClient";
|
||||
import { HttpError, req } from "./api/core";
|
||||
import { notify } from "./notifications";
|
||||
import { msg, notify, raw } from "./notifications";
|
||||
import { reportError } from "./errorDialog";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => {
|
||||
@@ -39,7 +41,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
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")) });
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
title: i18n.t("errors.title"),
|
||||
message: i18n.t("errors.reason.rateLimited"),
|
||||
title: msg("errors.title"),
|
||||
message: msg("errors.reason.rateLimited"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +60,7 @@ describe("a failed mutation", () => {
|
||||
meta: { failTitle: TITLE },
|
||||
});
|
||||
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({
|
||||
mutationFn: () => Promise.reject(new HttpError(404, "gone")),
|
||||
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 () => {
|
||||
@@ -79,7 +81,7 @@ describe("a failed mutation", () => {
|
||||
onError: rollback,
|
||||
});
|
||||
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 () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
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"`):
|
||||
* 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 {
|
||||
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 {
|
||||
|
||||
@@ -100,7 +100,12 @@ const KEYS = {
|
||||
// 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).
|
||||
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" },
|
||||
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
|
||||
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("./entryUrl", async (importOriginal) => {
|
||||
return { ...actual, accountSwitchNotice: () => notice() };
|
||||
});
|
||||
const { useAccountSwitchNotice } = await import("./useAccountSwitchNotice");
|
||||
const { msg, raw } = await import("./notifications");
|
||||
|
||||
afterEach(() => {
|
||||
notifyMock.mockClear();
|
||||
@@ -39,7 +40,7 @@ describe("the shell's word on a switch of account", () => {
|
||||
notice.mockReturnValue({
|
||||
level: "info",
|
||||
key: "header.account.switchRequested",
|
||||
values: { email: "b@example.com" },
|
||||
values: { email: raw("b@example.com") },
|
||||
});
|
||||
const { rerender } = renderHook(({ signedIn }) => useAccountSwitchNotice(signedIn), {
|
||||
initialProps: { signedIn: false },
|
||||
@@ -53,7 +54,7 @@ describe("the shell's word on a switch of account", () => {
|
||||
expect(notifyMock).toHaveBeenCalledTimes(1);
|
||||
expect(notifyMock).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
message: 'header.account.switchRequested {"email":"b@example.com"}',
|
||||
message: msg("header.account.switchRequested", { email: raw("b@example.com") }),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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
|
||||
* 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;
|
||||
said.current = true;
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "./api";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import i18n from "../i18n";
|
||||
import { useLatest } from "./useLatest";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
/** 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
|
||||
@@ -40,7 +40,7 @@ export function useChannelPriority(opts: {
|
||||
.catch((err: unknown) => {
|
||||
// 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.
|
||||
notifyActionError(err, i18n.t("errors.writeFailed.channelPriority"));
|
||||
notifyActionError(err, msg("errors.writeFailed.channelPriority"));
|
||||
optsRef.current.onError(channelId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
import { msg } from "./notifications";
|
||||
|
||||
/** 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
|
||||
@@ -21,7 +21,6 @@ export function useChannelTagActions(opts: {
|
||||
onError: (channelId: string) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleTag = (channelId: string, tagId: number, hasTag: boolean) => {
|
||||
opts.optimisticToggle(channelId, tagId, !hasTag);
|
||||
@@ -31,7 +30,7 @@ export function useChannelTagActions(opts: {
|
||||
call
|
||||
.then(() => opts.onSuccess?.(channelId))
|
||||
.catch((err: unknown) => {
|
||||
notifyActionError(err, t("channels.tags.updateFailed"));
|
||||
notifyActionError(err, msg("channels.tags.updateFailed"));
|
||||
opts.onError(channelId);
|
||||
});
|
||||
};
|
||||
@@ -45,7 +44,7 @@ export function useChannelTagActions(opts: {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
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 };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user