diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts index 4a23797e..9b2496ed 100644 --- a/frontend/src/i18n/backendIds.contract.test.ts +++ b/frontend/src/i18n/backendIds.contract.test.ts @@ -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)); diff --git a/frontend/src/i18n/locales/en/inbox.json b/frontend/src/i18n/locales/en/inbox.json index 4a97180a..a34194aa 100644 --- a/frontend/src/i18n/locales/en/inbox.json +++ b/frontend/src/i18n/locales/en/inbox.json @@ -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" diff --git a/frontend/src/i18n/locales/hu/inbox.json b/frontend/src/i18n/locales/hu/inbox.json index 1eea1a57..5ad64a7e 100644 --- a/frontend/src/i18n/locales/hu/inbox.json +++ b/frontend/src/i18n/locales/hu/inbox.json @@ -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" diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 8f04643d..029ad15b 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -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": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4cd31714..e957a10b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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"; diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index ba4b6e98..0ffbfe83 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -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 { diff --git a/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx b/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx index 28360123..d42adf50 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.menu.dom.test.tsx @@ -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("[data-nav-index]")!; + await openMenu(() => + fireEvent.click(takeoutRow.querySelector('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 = []; diff --git a/frontend/src/modules/notifications/NotificationsPanel.tsx b/frontend/src/modules/notifications/NotificationsPanel.tsx index 4b220755..e6c9eced 100644 --- a/frontend/src/modules/notifications/NotificationsPanel.tsx +++ b/frontend/src/modules/notifications/NotificationsPanel.tsx @@ -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 = { 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={} + onClick={isTakeout ? onOpenTakeout : onOpenScheduler} + icon={ + isTakeout ? : + } > - {t("inbox.openScheduler")} + {t(isTakeout ? "inbox.openTakeout" : "inbox.openScheduler")} )} {isTokenExpiring && (