fix(inbox): a Takeout import's notice opens the user's own import

The import's notice reaches a user who may not see the Scheduler, an
admin page, yet its link opened it. It now opens Settings › Account,
where the import's status is; any other run still opens the Scheduler.
The job id is held to the server's by the backend-ids contract. The
notice's Hungarian names the list as the app does elsewhere.
This commit is contained in:
2026-10-23 19:15:06 +02:00
parent 3553eac8c4
commit e0b02a0c14
8 changed files with 88 additions and 23 deletions
@@ -305,6 +305,14 @@ describe("backend ids have their names in both languages", () => {
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
});
it("the Takeout import's notice is known by the server's job id", () => {
// Read as text: the slice pulls in the request layer, which needs a browser.
const feedTs = readFileSync(new URL("../lib/api/feed.ts", import.meta.url), "utf8");
const ours = /^export const TAKEOUT_JOB = "(\w+)";$/m.exec(feedTs)?.[1];
expect(ours).toBeTruthy();
expect(/^TAKEOUT_IMPORT = "(\w+)"$/m.exec(schedulerPy)?.[1]).toBe(ours);
});
for (const lang of ["en", "hu"]) {
const keys = keysOf(lang);
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
+1
View File
@@ -16,6 +16,7 @@
"openScheduler": "Open Scheduler",
"openFile": "Open the file",
"openTokens": "Open API tokens",
"openTakeout": "Open YouTube history",
"revertFailed": "Couldn't undo that — please try again",
"downloadDone": {
"title": "Download ready"
+1
View File
@@ -16,6 +16,7 @@
"openScheduler": "Ütemező megnyitása",
"openFile": "Fájl megnyitása",
"openTokens": "API-tokenek megnyitása",
"openTakeout": "YouTube-előzmények megnyitása",
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
"downloadDone": {
"title": "Letöltés kész"
+4 -4
View File
@@ -81,7 +81,7 @@
"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.",
"channel_backfill": "Ha egy csatornánál bekapcsolod a teljes előzményt, a teljes korábbi kínálata egyszerre, saját futásként töltődik le. Amit ez nem tud befejezni (egy nagyon nagy csatorna, kevés kvóta, vagy ha már két ilyen fut), azt a „Videók behúzása” feladat fejezi be.",
"takeout_import": "Egy felhasználó Google Takeout importja: amit a YouTube-on megnézett, itt is megnézett lesz (a Shorts nem, és amit itt beállított, azt nem írja felül), a YouTube Watch later listája pedig bekerül az övébe. A katalógusban még nem szereplő videó vár, és amint megérkezik, megnézett lesz."
"takeout_import": "Egy felhasználó Google Takeout importja: amit a YouTube-on megnézett, itt is megnézett lesz (a Shorts nem, és amit itt beállított, azt nem írja felül), a YouTube Watch later listája pedig bekerül a „Megnézem később” listájába. A katalógusban még nem szereplő videó vár, és amint megérkezik, megnézett lesz."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -183,10 +183,10 @@
"cached": "elmentve",
"kept": "itteni állapot megtartva",
"waiting": "a videójára vár",
"watch_later_added": "Watch laterbe téve",
"watch_later_present": "már a Watch laterben",
"watch_later_added": "a „Megnézem később” listába téve",
"watch_later_present": "már a „Megnézem később” listán",
"watch_later_missing": "már nincs a YouTube-on",
"watch_later_waiting": "Watch later vár",
"watch_later_waiting": "a „Megnézem később” listára vár",
"takeout_watched": "Takeoutból megnézettre jelölve"
},
"skip": {
+1
View File
@@ -20,6 +20,7 @@ import { setupApi } from "./api/setup";
export type * from "./api/setup";
import { feedApi } from "./api/feed";
export type * from "./api/feed";
export { TAKEOUT_JOB } from "./api/feed";
import { discoverApi } from "./api/discover";
export type * from "./api/discover";
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
+4
View File
@@ -149,6 +149,10 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
return p.toString();
}
// The import's job (`scheduler.TAKEOUT_IMPORT`, held to it by backendIds.contract.test): its notice
// reaches a user who may not see the Scheduler, so it opens their own import instead.
export const TAKEOUT_JOB = "takeout_import";
// A Google Takeout file, uploaded: which one it was, the videos read from it, and its entries left
// out (an ad, a view of something that is not a video, a row that could not be read).
export interface TakeoutUpload {
@@ -205,6 +205,34 @@ describe("NotificationsPanel · a server row", () => {
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
});
it("a Takeout import's run opens the user's own import, any other run the Scheduler", async () => {
const run = (id: number, job: string) =>
server(id, {
type: "scheduler",
data: { job_id: job, status: "ok", summary: `${job} done` },
});
h.server = [run(1, "takeout_import"), run(2, "channel_backfill")];
h.client = [];
mount();
fireEvent.click(screen.getByText("inbox.openTakeout"));
expect(h.focusSettingsTab).toHaveBeenCalledWith("account");
expect(h.setPage).toHaveBeenLastCalledWith("settings");
fireEvent.click(screen.getByText("inbox.openScheduler"));
expect(h.setPage).toHaveBeenLastCalledWith("scheduler");
const takeoutRow = screen
.getByText("inbox.openTakeout")
.closest<HTMLElement>("[data-nav-index]")!;
await openMenu(() =>
fireEvent.click(takeoutRow.querySelector<HTMLElement>('button[aria-label="inbox.rowMenu"]')!),
);
expect(menuRows()).toEqual(["inbox.openTakeout", "inbox.markRead", "inbox.dismiss"]);
h.focusSettingsTab.mockClear();
h.setPage.mockClear();
choose("inbox.openTakeout");
await waitFor(() => expect(h.focusSettingsTab).toHaveBeenCalledWith("account"));
expect(h.setPage).toHaveBeenCalledWith("settings");
});
it("holds the link, mark read and dismiss", async () => {
h.server = [server(1)];
h.client = [];
@@ -11,6 +11,7 @@ import {
ExternalLink,
Eye,
FolderOpen,
History,
KeyRound,
RotateCcw,
Search,
@@ -19,7 +20,7 @@ import {
UserPlus,
X,
} from "lucide-react";
import { api, type AppNotification } from "../../lib/api";
import { api, TAKEOUT_JOB, type AppNotification } from "../../lib/api";
import { jobRunText } from "../../lib/jobResult";
import { notifyActionError } from "../../lib/actionErrors";
import { useLiveQuery } from "../../lib/useLiveQuery";
@@ -192,6 +193,10 @@ export default function NotificationsPanel() {
focusSettingsTab("tokens");
setPage("settings");
};
const openTakeout = () => {
focusSettingsTab("account");
setPage("settings");
};
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
const openDone = (n: AppNotification) => {
const { job_id: id, folder_id: folder } = n.data ?? {};
@@ -213,34 +218,43 @@ export default function NotificationsPanel() {
const kind = serverKind(n);
return menuGroups(
[
...(kind === "scheduler"
...(kind === "takeout"
? [
{
id: "open",
label: t("inbox.openScheduler"),
icon: icon(Activity),
onSelect: openScheduler,
label: t("inbox.openTakeout"),
icon: icon(History),
onSelect: openTakeout,
},
]
: kind === "download"
: kind === "scheduler"
? [
{
id: "open",
label: t("inbox.openFile"),
icon: icon(FolderOpen),
onSelect: () => openDone(n),
label: t("inbox.openScheduler"),
icon: icon(Activity),
onSelect: openScheduler,
},
]
: kind === "token"
: kind === "download"
? [
{
id: "open",
label: t("inbox.openTokens"),
icon: icon(KeyRound),
onSelect: openTokens,
label: t("inbox.openFile"),
icon: icon(FolderOpen),
onSelect: () => openDone(n),
},
]
: []),
: kind === "token"
? [
{
id: "open",
label: t("inbox.openTokens"),
icon: icon(KeyRound),
onSelect: openTokens,
},
]
: []),
...(!n.read
? [
{
@@ -411,6 +425,7 @@ export default function NotificationsPanel() {
onRead={() => readMut.mutate(n.id)}
onDismiss={() => dismissMut.mutate(n.id)}
onOpenScheduler={openScheduler}
onOpenTakeout={openTakeout}
onOpenFile={() => openDone(n)}
onOpenTokens={openTokens}
/>
@@ -478,7 +493,9 @@ const DONE_TITLE: Record<string, string | undefined> = {
upload: "inbox.uploadDone.title",
};
function serverKind(n: AppNotification): "scheduler" | "download" | "token" | null {
function serverKind(n: AppNotification): "scheduler" | "takeout" | "download" | "token" | null {
// A Takeout import's run is the user's own: told like any run, opened where they started it.
if (n.type === "scheduler" && n.data?.job_id === TAKEOUT_JOB) return "takeout";
if (n.type === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
if (n.type === "download_done") return "download";
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
@@ -527,6 +544,7 @@ function NotificationRow({
onRead,
onDismiss,
onOpenScheduler,
onOpenTakeout,
onOpenFile,
onOpenTokens,
}: {
@@ -538,6 +556,7 @@ function NotificationRow({
onRead: () => void;
onDismiss: () => void;
onOpenScheduler: () => void;
onOpenTakeout: () => void;
onOpenFile: () => void;
onOpenTokens: () => void;
}) {
@@ -549,7 +568,8 @@ function NotificationRow({
// Known notification types are rendered from i18n (so they're trilingual) using the typed
// payload; the server-stored title/body are an English fallback for any unknown type.
const isMaintenance = n.type === "maintenance" && typeof n.data?.count === "number";
const isScheduler = serverKind(n) === "scheduler";
const isTakeout = serverKind(n) === "takeout";
const isScheduler = serverKind(n) === "scheduler" || isTakeout;
const isDownloadDone = serverKind(n) === "download";
const isTokenExpiring = serverKind(n) === "token";
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
@@ -633,10 +653,12 @@ function NotificationRow({
variant="link"
className="text-sm mt-1.5"
data-nav-open=""
onClick={onOpenScheduler}
icon={<Activity className="w-3.5 h-3.5" />}
onClick={isTakeout ? onOpenTakeout : onOpenScheduler}
icon={
isTakeout ? <History className="w-3.5 h-3.5" /> : <Activity className="w-3.5 h-3.5" />
}
>
{t("inbox.openScheduler")}
{t(isTakeout ? "inbox.openTakeout" : "inbox.openScheduler")}
</Button>
)}
{isTokenExpiring && (