fix(ui,i18n): E30 review round 3 — the stored language follows the screen, a fresh text starts collapsed, Retry waits again
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
//
|
//
|
||||||
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
||||||
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
||||||
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -47,6 +47,7 @@ function mount() {
|
|||||||
return qc;
|
return qc;
|
||||||
}
|
}
|
||||||
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
||||||
|
let savePrefs: MockInstance<typeof api.savePrefs>;
|
||||||
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -57,6 +58,7 @@ beforeEach(async () => {
|
|||||||
mounts = 0;
|
mounts = 0;
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
|
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
|
||||||
|
savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -85,6 +87,26 @@ describe("BootGate", () => {
|
|||||||
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
|
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an account with no language stored stores the one it is shown in, once", async () => {
|
||||||
|
await i18n.changeLanguage("hu"); // the browser's language, never saved
|
||||||
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
||||||
|
const qc = mount();
|
||||||
|
|
||||||
|
await screen.findByTestId("app");
|
||||||
|
await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" }));
|
||||||
|
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||||
|
// The server now sorts in Hungarian, and so does what reads the stored language.
|
||||||
|
await waitFor(() => expect(qc.getQueryData<Me>(qk.me())?.preferences.language).toBe("hu"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an account with its language stored stores nothing", async () => {
|
||||||
|
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
|
||||||
|
mount();
|
||||||
|
|
||||||
|
await screen.findByTestId("app");
|
||||||
|
expect(savePrefs).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("mounts nothing of the app while the identity is still unknown", async () => {
|
it("mounts nothing of the app while the identity is still unknown", async () => {
|
||||||
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
|
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
|
||||||
mount();
|
mount();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
||||||
|
import { log } from "../lib/log";
|
||||||
|
import { saveLanguage } from "../lib/saveLanguage";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import i18n, { isSupported, setLanguage } from "../i18n";
|
import i18n, { isSupported, setLanguage } from "../i18n";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||||
@@ -38,6 +40,7 @@ export function BootScreen() {
|
|||||||
|
|
||||||
export default function BootGate({ children }: { children: ReactNode }) {
|
export default function BootGate({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
|
||||||
// every other route is locked — so `me` is held back until the instance is known to be set up
|
// every other route is locked — so `me` is held back until the instance is known to be set up
|
||||||
// (otherwise it would 503 against the setup-mode lock).
|
// (otherwise it would 503 against the setup-mode lock).
|
||||||
@@ -81,7 +84,10 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
|||||||
// not is corrected here — otherwise the tab would run as the default account on the server while
|
// not is corrected here — otherwise the tab would run as the default account on the server while
|
||||||
// reading and writing the gone account's local state.
|
// reading and writing the gone account's local state.
|
||||||
// • Adopt the account's language, which follows the account across devices — the local copy only
|
// • Adopt the account's language, which follows the account across devices — the local copy only
|
||||||
// knows what this browser last showed.
|
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
|
||||||
|
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
|
||||||
|
// orders its lists and writes its mail in the stored one, else English, and a Hungarian screen
|
||||||
|
// over English-ordered lists breaks "A→Z in the UI language's alphabet".
|
||||||
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
||||||
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
|
// `me` is read for the identity it produced, so it rides a ref: a background refetch of the same
|
||||||
// account must not re-run the adoption over a language the user has just changed. That rule used
|
// account must not re-run the adoption over a language the user has just changed. That rule used
|
||||||
@@ -104,11 +110,21 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
|||||||
const account = meRef.current;
|
const account = meRef.current;
|
||||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
||||||
const lang = account?.preferences?.language;
|
const lang = account?.preferences?.language;
|
||||||
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
if (typeof lang === "string" && isSupported(lang)) {
|
||||||
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
||||||
|
if (lang !== i18n.language) setLanguage(lang);
|
||||||
|
} else if (account) {
|
||||||
|
// eslint-disable-next-line local/locale-format -- a one-shot read of the language the app is about to be shown in, stored once; nothing renders it.
|
||||||
|
const shown = i18n.language;
|
||||||
|
saveLanguage(qc, shown).catch((e: unknown) =>
|
||||||
|
log.warn("boot.language_store_failed", "the shown language could not be stored", {
|
||||||
|
error: String(e),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||||
setReadyFor(identity);
|
setReadyFor(identity);
|
||||||
}, [identity, readyFor]);
|
}, [identity, readyFor, qc]);
|
||||||
|
|
||||||
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
|
// No answer at all (a network or 5xx failure on the very first setup or account probe — past the
|
||||||
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
|
// one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useChannelTagActions } from "../lib/useChannelTagActions";
|
|||||||
import { useTagSuggestions } from "./useTagSuggestions";
|
import { useTagSuggestions } from "./useTagSuggestions";
|
||||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||||
import { placeByPriority } from "../lib/channelOrder";
|
import { placeByPriority } from "../lib/channelOrder";
|
||||||
import { serverOrderLang } from "../lib/saveLanguage";
|
|
||||||
import { focusNavItem } from "../lib/listNav";
|
import { focusNavItem } from "../lib/listNav";
|
||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||||
@@ -174,7 +173,7 @@ export default function Channels() {
|
|||||||
setPriority(c.id, next);
|
setPriority(c.id, next);
|
||||||
qc.setQueryData<ManagedChannel[]>(
|
qc.setQueryData<ManagedChannel[]>(
|
||||||
qk.channels(),
|
qk.channels(),
|
||||||
(old) => old && placeByPriority(old, c.id, serverOrderLang(me.preferences)),
|
(old) => old && placeByPriority(old, c.id, me.preferences),
|
||||||
);
|
);
|
||||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -483,7 +483,9 @@ export default function PlexBrowse() {
|
|||||||
if (sub.view.kind === "show") {
|
if (sub.view.kind === "show") {
|
||||||
const showId = sub.view.id;
|
const showId = sub.view.id;
|
||||||
return (
|
return (
|
||||||
|
// Keyed by the show: another show is a fresh page (its own summary collapsed, no open dialog).
|
||||||
<PlexShowView
|
<PlexShowView
|
||||||
|
key={showId}
|
||||||
showId={showId}
|
showId={showId}
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={(epRk, queue, fromStart) =>
|
onPlay={(epRk, queue, fromStart) =>
|
||||||
|
|||||||
@@ -276,6 +276,29 @@ describe("PlexPersonView — a bio on its way (E30 S6)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a retry after the wait is over waits again: a bio still on its way still lands", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
plexPerson.mockReset();
|
||||||
|
plexPerson
|
||||||
|
.mockResolvedValueOnce(person({ biography: null, enriching: true }))
|
||||||
|
.mockRejectedValueOnce(new Error("server restarting"))
|
||||||
|
.mockResolvedValueOnce(person({ biography: null, enriching: true })) // the retry
|
||||||
|
.mockResolvedValue(person({ biography: "Late bio." }));
|
||||||
|
wrap(view);
|
||||||
|
await screen.findByText("Movie One");
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(25_000)); // the first wait runs out
|
||||||
|
fireEvent.click(within(screen.getByRole("alert")).getByRole("button"));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10));
|
||||||
|
expect(screen.getByRole("status")).toBeTruthy(); // on its way again, not a blank
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(BIO_POLL));
|
||||||
|
await screen.findByText("Late bio.");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("offers More only when the bio is cut, and Less once open", async () => {
|
it("offers More only when the bio is cut, and Less once open", async () => {
|
||||||
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(160);
|
||||||
|
|||||||
@@ -160,12 +160,14 @@ export function PlexPersonView({
|
|||||||
// S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
// S6); the answer says so (`enriching`) and the page asks again until it lands — for a while, not
|
||||||
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
|
// forever: BIO_WAIT_MS after the page opened (by the clock, not by the last answer — a failing ask
|
||||||
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
|
// leaves that where it was) a TMDB that is down stops being waited on, and a failing ask ends it at
|
||||||
// once (the next open tries again). The page is keyed on the person, so the wait starts per person.
|
// once (the next open tries again). The page is keyed on the person, so the wait starts per person;
|
||||||
|
// a Retry starts it over (`waitRound`), or a retry answered "still on its way" would show nothing.
|
||||||
const [waitOver, setWaitOver] = useState(false);
|
const [waitOver, setWaitOver] = useState(false);
|
||||||
|
const [waitRound, setWaitRound] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
|
const timer = setTimeout(() => setWaitOver(true), BIO_WAIT_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, [waitRound]);
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: personKey,
|
queryKey: personKey,
|
||||||
queryFn: () => api.plexPerson(name, lang),
|
queryFn: () => api.plexPerson(name, lang),
|
||||||
@@ -336,7 +338,15 @@ export function PlexPersonView({
|
|||||||
// person with no bio; the retry asks (and waits) again.
|
// person with no bio; the retry asks (and waits) again.
|
||||||
d.enriching &&
|
d.enriching &&
|
||||||
q.isError && (
|
q.isError && (
|
||||||
<StateMessage tone="error" compact onRetry={() => q.refetch()}>
|
<StateMessage
|
||||||
|
tone="error"
|
||||||
|
compact
|
||||||
|
onRetry={() => {
|
||||||
|
setWaitOver(false);
|
||||||
|
setWaitRound((n) => n + 1);
|
||||||
|
void q.refetch();
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("plex.person.bioFailed")}
|
{t("plex.person.bioFailed")}
|
||||||
</StateMessage>
|
</StateMessage>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ describe("ClampedText", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts another text collapsed: a reused view opening the next item", () => {
|
||||||
|
const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(400);
|
||||||
|
const box = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(60);
|
||||||
|
try {
|
||||||
|
const { rerender } = render(view("The first show's long summary."));
|
||||||
|
fireEvent.click(screen.getByText("More"));
|
||||||
|
rerender(view("The next show's long summary."));
|
||||||
|
expect(screen.getByText(/next show/).className).toContain("line-clamp-3");
|
||||||
|
expect(screen.getByText("More").getAttribute("aria-expanded")).toBe("false");
|
||||||
|
} finally {
|
||||||
|
tall.mockRestore();
|
||||||
|
box.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("measures again when its box changes: the window or the text size moved the cut", () => {
|
it("measures again when its box changes: the window or the text size moved the cut", () => {
|
||||||
const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something
|
const seen: ResizeObserverCallback[] = []; // the callbacks of observers watching something
|
||||||
const real = globalThis.ResizeObserver;
|
const real = globalThis.ResizeObserver;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con
|
|||||||
|
|
||||||
/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
|
/** A long text (a biography, a summary) clamped to a few lines, with a More / Less toggle offered only
|
||||||
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
|
||||||
* resize: the window and the text-size slider both move where the cut falls. */
|
* resize: the window and the text-size slider both move where the cut falls. Another text starts
|
||||||
|
* collapsed: a view reused for the next item (a show page opening another show) must not open its
|
||||||
|
* text already expanded. */
|
||||||
export function ClampedText({
|
export function ClampedText({
|
||||||
text,
|
text,
|
||||||
lines,
|
lines,
|
||||||
@@ -22,6 +24,11 @@ export function ClampedText({
|
|||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [clipped, setClipped] = useState(false);
|
const [clipped, setClipped] = useState(false);
|
||||||
|
const [shown, setShown] = useState(text);
|
||||||
|
if (shown !== text) {
|
||||||
|
setShown(text);
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
const ref = useRef<HTMLParagraphElement>(null);
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.",
|
"intro": "An append-only record of admin actions and scheduler run summaries — who did what, and when. Logged at action granularity, never per item.",
|
||||||
"empty": "No audit entries yet.",
|
"empty": "No audit entries yet.",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"clear": "Clear log",
|
"clear": "Clear audit log",
|
||||||
"clearTitle": "Clear the audit log?",
|
"clearTitle": "Clear the audit log?",
|
||||||
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
|
"clearConfirm": "Permanently delete all audit entries? This can't be undone. (One entry recording that you cleared it is kept.)",
|
||||||
"cleared": "Cleared {{count}} audit entries",
|
"cleared": "Cleared {{count}} audit entries",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
|
"intro": "Az admin-műveletek és az ütemező futás-összegzéseinek csak-hozzáfűző naplója — ki, mit, mikor. Művelet-szinten naplózva, soha nem elemenként.",
|
||||||
"empty": "Még nincs naplóbejegyzés.",
|
"empty": "Még nincs naplóbejegyzés.",
|
||||||
"system": "Rendszer",
|
"system": "Rendszer",
|
||||||
"clear": "Napló ürítése",
|
"clear": "Eseménynapló ürítése",
|
||||||
"clearTitle": "Üríted az eseménynaplót?",
|
"clearTitle": "Üríted az eseménynaplót?",
|
||||||
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
"clearConfirm": "Véglegesen törlöd az összes naplóbejegyzést? Ez nem vonható vissza. (Egy bejegyzés arról, hogy te ürítetted, megmarad.)",
|
||||||
"cleared": "{{count}} naplóbejegyzés törölve",
|
"cleared": "{{count}} naplóbejegyzés törölve",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
|
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infóoldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő szinkron után jelennek meg.",
|
||||||
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes egyeztetések között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
|
||||||
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
|
"plex_watch_reconcile": "A megtekintési állapot teljes egyeztetése, amely minden könyvtárat újraszkennel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben levett megtekintve jel nem tükröződik vissza.",
|
||||||
"audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.",
|
"audit_gc": "Törli a beállított megőrzési időnél (Konfiguráció → Eseménynapló) régebbi admin-naplóbejegyzéseket. Ha leáll, az eseménynapló csak nő — ártalmatlan, de korlátlanul.",
|
||||||
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
|
"log_gc": "Törli a megőrzési időnél (Konfiguráció → Diagnosztika) régebbi tárolt naplóeseményeket. Óránként fut, így semmi sem marad meg egy óránál tovább az ígért határidőn túl.",
|
||||||
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
|
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül.",
|
||||||
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
|
"people_enrich": "Helyivé teszi a Plex-könyvtár szereplőit: a TMDB-ből letölti az életrajzukat, a portréjukat pedig a Plexszel kicsinyítve elmenti, a legtöbbet szereplőkkel kezdve. A személyoldal és a szereplősáv így az app saját adataiból nyílik meg. A TMDB-rész TMDB API-kulcs nélkül nem csinál semmit, a portrérész Plex nélkül. Ha leáll, a személyoldal megnyitáskor maga kéri le az életrajzot, egy pillanattal később."
|
||||||
|
|||||||
@@ -1,28 +1,32 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
// jsdom: the order language comes from the account's stored one, checked against i18n's languages.
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { placeByPriority, priorityChoices } from "./channelOrder";
|
import { placeByPriority, priorityChoices } from "./channelOrder";
|
||||||
|
|
||||||
const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title });
|
const row = (id: string, priority: number, title: string | null = id) => ({ id, priority, title });
|
||||||
const ids = (rows: { id: string }[]) => rows.map((r) => r.id);
|
const ids = (rows: { id: string }[]) => rows.map((r) => r.id);
|
||||||
|
const EN = { language: "en" };
|
||||||
|
const HU = { language: "hu" };
|
||||||
|
|
||||||
describe("placeByPriority", () => {
|
describe("placeByPriority", () => {
|
||||||
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
|
const list = [row("a", 10), row("b", 5), row("c", 5), row("d", 0), row("e", 0)];
|
||||||
|
|
||||||
it("bubbles a raised channel up to the first row it now outranks", () => {
|
it("bubbles a raised channel up to the first row it now outranks", () => {
|
||||||
expect(
|
expect(
|
||||||
ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", "en")),
|
ids(placeByPriority([...list.slice(0, 4), { ...list[4]!, priority: 5 }], "e", EN)),
|
||||||
).toEqual(["a", "b", "c", "e", "d"]);
|
).toEqual(["a", "b", "c", "e", "d"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sinks a lowered channel below every row it no longer outranks", () => {
|
it("sinks a lowered channel below every row it no longer outranks", () => {
|
||||||
expect(
|
expect(ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", EN))).toEqual(
|
||||||
ids(placeByPriority([{ ...list[0]!, priority: 0 }, ...list.slice(1)], "a", "en")),
|
["b", "c", "a", "d", "e"],
|
||||||
).toEqual(["b", "c", "a", "d", "e"]);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("orders equal priorities by title, case-folded, a missing title last", () => {
|
it("orders equal priorities by title, case-folded, a missing title last", () => {
|
||||||
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
|
const rows = [row("x", 3, "beta"), row("y", 3, null), row("z", 3, "Alpha")];
|
||||||
expect(ids(placeByPriority(rows, "z", "en"))).toEqual(["z", "x", "y"]);
|
expect(ids(placeByPriority(rows, "z", EN))).toEqual(["z", "x", "y"]);
|
||||||
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", "en"))).toEqual([
|
expect(ids(placeByPriority([row("y", 3, null), row("x", 3, "beta")], "y", EN))).toEqual([
|
||||||
"x",
|
"x",
|
||||||
"y",
|
"y",
|
||||||
]);
|
]);
|
||||||
@@ -31,21 +35,31 @@ describe("placeByPriority", () => {
|
|||||||
it("leaves every other row where it was, even ones out of the server's order", () => {
|
it("leaves every other row where it was, even ones out of the server's order", () => {
|
||||||
const rows = [row("p", 0), row("q", 9), row("r", 2)];
|
const rows = [row("p", 0), row("q", 9), row("r", 2)];
|
||||||
// r lands before the first row it outranks; p and q keep their (unsorted) order.
|
// r lands before the first row it outranks; p and q keep their (unsorted) order.
|
||||||
expect(ids(placeByPriority(rows, "r", "en"))).toEqual(["r", "p", "q"]);
|
expect(ids(placeByPriority(rows, "r", EN))).toEqual(["r", "p", "q"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an unknown id's list unchanged", () => {
|
it("returns an unknown id's list unchanged", () => {
|
||||||
expect(ids(placeByPriority(list, "nope", "en"))).toEqual(ids(list));
|
expect(ids(placeByPriority(list, "nope", EN))).toEqual(ids(list));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
|
it("places a title in the reader's alphabet, as the server's collation does (E30 review)", () => {
|
||||||
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
|
const rows = [row("e", 0, "Ecset"), row("f", 0, "Fa"), row("m", 5, "Életrajz")];
|
||||||
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
|
const moved = [...rows.slice(0, 2), { ...rows[2]!, priority: 0 }];
|
||||||
expect(ids(placeByPriority(moved, "m", "hu"))).toEqual(["e", "m", "f"]);
|
expect(ids(placeByPriority(moved, "m", HU))).toEqual(["e", "m", "f"]);
|
||||||
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
|
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
|
||||||
expect(
|
expect(ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", HU))).toEqual([
|
||||||
ids(placeByPriority([...cs.slice(0, 2), { ...cs[2]!, priority: 0 }], "c", "hu")),
|
"u",
|
||||||
).toEqual(["u", "c", "x"]);
|
"c",
|
||||||
|
"x",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places by the STORED language, whatever the screen shows: the server sorts by that", () => {
|
||||||
|
const cs = [row("u", 0, "Cukor"), row("x", 0, "Dob"), row("c", 5, "Csiga")];
|
||||||
|
const moved = [...cs.slice(0, 2), { ...cs[2]!, priority: 0 }];
|
||||||
|
// None stored (a Hungarian screen picked while signed out): the server's English order.
|
||||||
|
expect(ids(placeByPriority(moved, "c", {}))).toEqual(["c", "u", "x"]);
|
||||||
|
expect(ids(placeByPriority(moved, "c", { language: "de" }))).toEqual(["c", "u", "x"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { compareText } from "./collate";
|
import { compareText } from "./collate";
|
||||||
|
import { serverOrderLang } from "./saveLanguage";
|
||||||
|
|
||||||
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
|
// The channel manager's list order, and the priority steps its menu offers (E28 S3e3b).
|
||||||
|
|
||||||
@@ -32,14 +33,16 @@ function before(a: Ordered, b: Ordered, lang: string): boolean {
|
|||||||
|
|
||||||
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
|
/** `rows` with the channel `id` taken out and put back where its priority now places it, every
|
||||||
* other row left exactly where it was — the server's order, without a refetch that would reorder
|
* other row left exactly where it was — the server's order, without a refetch that would reorder
|
||||||
* anything the reader did not touch. */
|
* anything the reader did not touch. It takes the account's `preferences`, not a language: the
|
||||||
|
* server sorts in the STORED language (`serverOrderLang`), which a UI language is not always. */
|
||||||
export function placeByPriority<T extends Ordered>(
|
export function placeByPriority<T extends Ordered>(
|
||||||
rows: readonly T[],
|
rows: readonly T[],
|
||||||
id: string,
|
id: string,
|
||||||
lang: string,
|
preferences: Record<string, unknown> | null | undefined,
|
||||||
): T[] {
|
): T[] {
|
||||||
const moved = rows.find((r) => r.id === id);
|
const moved = rows.find((r) => r.id === id);
|
||||||
if (!moved) return [...rows];
|
if (!moved) return [...rows];
|
||||||
|
const lang = serverOrderLang(preferences);
|
||||||
const rest = rows.filter((r) => r !== moved);
|
const rest = rows.filter((r) => r !== moved);
|
||||||
const at = rest.findIndex((r) => before(moved, r, lang));
|
const at = rest.findIndex((r) => before(moved, r, lang));
|
||||||
rest.splice(at < 0 ? rest.length : at, 0, moved);
|
rest.splice(at < 0 ? rest.length : at, 0, moved);
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ import type { QueryClient } from "@tanstack/react-query";
|
|||||||
import { isSupported } from "../i18n";
|
import { isSupported } from "../i18n";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { LANGUAGE_ORDERED } from "./queryKeys";
|
import { LANGUAGE_ORDERED } from "./queryKeys";
|
||||||
|
import { updateMeCache } from "./useMe";
|
||||||
|
|
||||||
/** Store the reader's language choice on the server, then ask again for the lists the server orders
|
/** Store the reader's language choice on the server, then ask again for the lists the server orders
|
||||||
* by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet.
|
* by it (`LANGUAGE_ORDERED`): only once the save has landed does the server sort in the new alphabet.
|
||||||
* Rejects with the save's error; the lists are left alone then. */
|
* The cached `me` takes it too, so what reads the stored language (`serverOrderLang`) agrees with the
|
||||||
|
* server at once, not at the next `me` poll. Rejects with the save's error; nothing is touched then. */
|
||||||
export async function saveLanguage(qc: QueryClient, code: string): Promise<void> {
|
export async function saveLanguage(qc: QueryClient, code: string): Promise<void> {
|
||||||
await api.savePrefs({ language: code });
|
await api.savePrefs({ language: code });
|
||||||
|
updateMeCache(qc, (me) => ({ ...me, preferences: { ...me.preferences, language: code } }));
|
||||||
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
|
for (const queryKey of LANGUAGE_ORDERED) void qc.invalidateQueries({ queryKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the
|
/** The language the server orders this account's lists in (backend `mailtext.lang_for_user`): the
|
||||||
* stored preference, else English. Not always the UI's — a browser-detected language, or one picked
|
* stored preference, else English. Not always the UI's: an account with none stored stores the one
|
||||||
* while signed out, is never stored — so a list re-slotted here to match the server's order uses it. */
|
* it is shown in at boot (BootGate), but until that save lands, or if it fails, the two differ — so a
|
||||||
|
* list re-slotted here to match the server's order uses this, not the UI's. */
|
||||||
export function serverOrderLang(preferences: Record<string, unknown> | null | undefined): string {
|
export function serverOrderLang(preferences: Record<string, unknown> | null | undefined): string {
|
||||||
const lang = preferences?.language;
|
const lang = preferences?.language;
|
||||||
return typeof lang === "string" && isSupported(lang) ? lang : "en";
|
return typeof lang === "string" && isSupported(lang) ? lang : "en";
|
||||||
|
|||||||
Reference in New Issue
Block a user