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:
@@ -305,6 +305,14 @@ describe("backend ids have their names in both languages", () => {
|
|||||||
expect([...RUN_STATUSES].sort()).toEqual([...runStatuses].sort());
|
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"]) {
|
for (const lang of ["en", "hu"]) {
|
||||||
const keys = keysOf(lang);
|
const keys = keysOf(lang);
|
||||||
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
|
const missing = (wanted: string[]) => wanted.filter((k) => !keys.has(k));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"openScheduler": "Open Scheduler",
|
"openScheduler": "Open Scheduler",
|
||||||
"openFile": "Open the file",
|
"openFile": "Open the file",
|
||||||
"openTokens": "Open API tokens",
|
"openTokens": "Open API tokens",
|
||||||
|
"openTakeout": "Open YouTube history",
|
||||||
"revertFailed": "Couldn't undo that — please try again",
|
"revertFailed": "Couldn't undo that — please try again",
|
||||||
"downloadDone": {
|
"downloadDone": {
|
||||||
"title": "Download ready"
|
"title": "Download ready"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"openScheduler": "Ütemező megnyitása",
|
"openScheduler": "Ütemező megnyitása",
|
||||||
"openFile": "Fájl megnyitása",
|
"openFile": "Fájl megnyitása",
|
||||||
"openTokens": "API-tokenek megnyitása",
|
"openTokens": "API-tokenek megnyitása",
|
||||||
|
"openTakeout": "YouTube-előzmények megnyitása",
|
||||||
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
|
"revertFailed": "Nem sikerült visszavonni — próbáld újra",
|
||||||
"downloadDone": {
|
"downloadDone": {
|
||||||
"title": "Letöltés kész"
|
"title": "Letöltés kész"
|
||||||
|
|||||||
@@ -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.",
|
"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.",
|
||||||
"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.",
|
"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": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
@@ -183,10 +183,10 @@
|
|||||||
"cached": "elmentve",
|
"cached": "elmentve",
|
||||||
"kept": "itteni állapot megtartva",
|
"kept": "itteni állapot megtartva",
|
||||||
"waiting": "a videójára vár",
|
"waiting": "a videójára vár",
|
||||||
"watch_later_added": "Watch laterbe téve",
|
"watch_later_added": "a „Megnézem később” listába téve",
|
||||||
"watch_later_present": "már a Watch laterben",
|
"watch_later_present": "már a „Megnézem később” listán",
|
||||||
"watch_later_missing": "már nincs a YouTube-on",
|
"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"
|
"takeout_watched": "Takeoutból megnézettre jelölve"
|
||||||
},
|
},
|
||||||
"skip": {
|
"skip": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { setupApi } from "./api/setup";
|
|||||||
export type * from "./api/setup";
|
export type * from "./api/setup";
|
||||||
import { feedApi } from "./api/feed";
|
import { feedApi } from "./api/feed";
|
||||||
export type * from "./api/feed";
|
export type * from "./api/feed";
|
||||||
|
export { TAKEOUT_JOB } from "./api/feed";
|
||||||
import { discoverApi } from "./api/discover";
|
import { discoverApi } from "./api/discover";
|
||||||
export type * from "./api/discover";
|
export type * from "./api/discover";
|
||||||
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
|
export { DISCOVER_BUDGETS, HERO_TIERS, discoverCardKey } from "./api/discover";
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string
|
|||||||
return p.toString();
|
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
|
// 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).
|
// out (an ad, a view of something that is not a video, a row that could not be read).
|
||||||
export interface TakeoutUpload {
|
export interface TakeoutUpload {
|
||||||
|
|||||||
@@ -205,6 +205,34 @@ describe("NotificationsPanel · a server row", () => {
|
|||||||
expect(menuRows()).toEqual(["inbox.openTokens", "inbox.markRead", "inbox.dismiss"]);
|
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 () => {
|
it("holds the link, mark read and dismiss", async () => {
|
||||||
h.server = [server(1)];
|
h.server = [server(1)];
|
||||||
h.client = [];
|
h.client = [];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
History,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { jobRunText } from "../../lib/jobResult";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||||
@@ -192,6 +193,10 @@ export default function NotificationsPanel() {
|
|||||||
focusSettingsTab("tokens");
|
focusSettingsTab("tokens");
|
||||||
setPage("settings");
|
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.
|
// A finished download opens on its file, in its folder; a notice from before it named one, on Files.
|
||||||
const openDone = (n: AppNotification) => {
|
const openDone = (n: AppNotification) => {
|
||||||
const { job_id: id, folder_id: folder } = n.data ?? {};
|
const { job_id: id, folder_id: folder } = n.data ?? {};
|
||||||
@@ -213,34 +218,43 @@ export default function NotificationsPanel() {
|
|||||||
const kind = serverKind(n);
|
const kind = serverKind(n);
|
||||||
return menuGroups(
|
return menuGroups(
|
||||||
[
|
[
|
||||||
...(kind === "scheduler"
|
...(kind === "takeout"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "open",
|
id: "open",
|
||||||
label: t("inbox.openScheduler"),
|
label: t("inbox.openTakeout"),
|
||||||
icon: icon(Activity),
|
icon: icon(History),
|
||||||
onSelect: openScheduler,
|
onSelect: openTakeout,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: kind === "download"
|
: kind === "scheduler"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "open",
|
id: "open",
|
||||||
label: t("inbox.openFile"),
|
label: t("inbox.openScheduler"),
|
||||||
icon: icon(FolderOpen),
|
icon: icon(Activity),
|
||||||
onSelect: () => openDone(n),
|
onSelect: openScheduler,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: kind === "token"
|
: kind === "download"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "open",
|
id: "open",
|
||||||
label: t("inbox.openTokens"),
|
label: t("inbox.openFile"),
|
||||||
icon: icon(KeyRound),
|
icon: icon(FolderOpen),
|
||||||
onSelect: openTokens,
|
onSelect: () => openDone(n),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: kind === "token"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "open",
|
||||||
|
label: t("inbox.openTokens"),
|
||||||
|
icon: icon(KeyRound),
|
||||||
|
onSelect: openTokens,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(!n.read
|
...(!n.read
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -411,6 +425,7 @@ export default function NotificationsPanel() {
|
|||||||
onRead={() => readMut.mutate(n.id)}
|
onRead={() => readMut.mutate(n.id)}
|
||||||
onDismiss={() => dismissMut.mutate(n.id)}
|
onDismiss={() => dismissMut.mutate(n.id)}
|
||||||
onOpenScheduler={openScheduler}
|
onOpenScheduler={openScheduler}
|
||||||
|
onOpenTakeout={openTakeout}
|
||||||
onOpenFile={() => openDone(n)}
|
onOpenFile={() => openDone(n)}
|
||||||
onOpenTokens={openTokens}
|
onOpenTokens={openTokens}
|
||||||
/>
|
/>
|
||||||
@@ -478,7 +493,9 @@ const DONE_TITLE: Record<string, string | undefined> = {
|
|||||||
upload: "inbox.uploadDone.title",
|
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 === "scheduler" && typeof n.data?.job_id === "string") return "scheduler";
|
||||||
if (n.type === "download_done") return "download";
|
if (n.type === "download_done") return "download";
|
||||||
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
|
if (n.type === "api_token_expiring" && typeof n.data?.name === "string") return "token";
|
||||||
@@ -527,6 +544,7 @@ function NotificationRow({
|
|||||||
onRead,
|
onRead,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
onOpenScheduler,
|
onOpenScheduler,
|
||||||
|
onOpenTakeout,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
onOpenTokens,
|
onOpenTokens,
|
||||||
}: {
|
}: {
|
||||||
@@ -538,6 +556,7 @@ function NotificationRow({
|
|||||||
onRead: () => void;
|
onRead: () => void;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
onOpenScheduler: () => void;
|
onOpenScheduler: () => void;
|
||||||
|
onOpenTakeout: () => void;
|
||||||
onOpenFile: () => void;
|
onOpenFile: () => void;
|
||||||
onOpenTokens: () => void;
|
onOpenTokens: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -549,7 +568,8 @@ function NotificationRow({
|
|||||||
// Known notification types are rendered from i18n (so they're trilingual) using the typed
|
// 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.
|
// 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 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 isDownloadDone = serverKind(n) === "download";
|
||||||
const isTokenExpiring = serverKind(n) === "token";
|
const isTokenExpiring = serverKind(n) === "token";
|
||||||
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
const isEmailUndelivered = n.type === "email_undelivered" && typeof n.data?.reason === "string";
|
||||||
@@ -633,10 +653,12 @@ function NotificationRow({
|
|||||||
variant="link"
|
variant="link"
|
||||||
className="text-sm mt-1.5"
|
className="text-sm mt-1.5"
|
||||||
data-nav-open=""
|
data-nav-open=""
|
||||||
onClick={onOpenScheduler}
|
onClick={isTakeout ? onOpenTakeout : onOpenScheduler}
|
||||||
icon={<Activity className="w-3.5 h-3.5" />}
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isTokenExpiring && (
|
{isTokenExpiring && (
|
||||||
|
|||||||
Reference in New Issue
Block a user