Public Access
feat(import): bookmarklet for store pages
This commit is contained in:
1 parent
c6d76b4cb5
commit
f01ad03f6e
8 files changed
+411
-22
No files matched your search
@@ -49,6 +49,14 @@ To Be Played is split into three repositories:
|
||||
status, seconds). Store and platform names become IGDB abbreviations (`platformFromNames`); an unknown one stays
|
||||
empty and the review preselects. **Unverified:** each format is built from the exporter's public source, not from
|
||||
a real file; the test files in `src/test/fixtures/import` are made up.
|
||||
- **Store pages** (tab "Store page", `src/utils/storeList.js`): a bookmarklet for PlayStation
|
||||
(`library.playstation.com`), Xbox (`www.xbox.com`), Nintendo eShop (`ec.nintendo.com`), Epic Games
|
||||
(`www.epicgames.com`) and GOG (`www.gog.com`). On the store page it reads the user's games with the page's own
|
||||
login and opens `/import#data=...` with at most 1000 titles and platforms. Firefox blocks bookmarklets on pages
|
||||
with a strict Content Security Policy, so the same code can be copied and pasted in the browser console. Xbox has
|
||||
no JSON without its own tokens: it reads the game links shown on the page and is the most likely to break.
|
||||
Nintendo lists purchases only, no free games. **Unverified:** each reader follows the site's JSON as public
|
||||
scripts use it, not a real account.
|
||||
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the
|
||||
settings is preselected when a game is on it; otherwise a game on one platform gets that one, and a game on
|
||||
several waits for the user's pick.
|
||||
@@ -126,6 +134,7 @@ flowchart LR
|
||||
Core --> Speedrun["speedrun.com API"]
|
||||
Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
|
||||
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
|
||||
Browser -.->|"bookmarklet, the user's login"| Stores["PlayStation, Xbox, Nintendo, Epic, GOG"]
|
||||
Auth -->|"check_authentication"| SteamLogin
|
||||
```
|
||||
|
||||
@@ -151,6 +160,9 @@ flowchart LR
|
||||
- `src/utils/brokenImages.js` reports pictures that fail to load (`GameCover`, achievement icons on the game
|
||||
detail page) to Core.
|
||||
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
|
||||
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
|
||||
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
|
||||
through the login page.
|
||||
- The import page polls a running import every 2 s. After a `5xx` or a network error it waits twice as long (up to
|
||||
30 s) and gives up after 10 failures in a row; a hidden tab does not poll.
|
||||
- All `localStorage` access goes through `src/utils/storage.js`: with site data blocked a read is `null` and a
|
||||
|
||||
+10
-1
@@ -711,7 +711,16 @@
|
||||
"SOURCE_HLTB": "HowLongToBeat",
|
||||
"SOURCE_PLAYNITE": "Playnite",
|
||||
"SOURCE_BOOKMARKLET": "Store-Seite",
|
||||
"FORMAT_READ": "Gelesen als: {{format}}"
|
||||
"FORMAT_READ": "Gelesen als: {{format}}",
|
||||
"STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.",
|
||||
"STORE_LINK": "To Be Played Import",
|
||||
"STORE_COPY": "Als Skript kopieren",
|
||||
"STORE_COPIED": "Skript kopiert. Füge es auf der Store-Seite in die Browser-Konsole (F12) ein.",
|
||||
"STORE_COPY_FAILED": "Das Skript konnte nicht kopiert werden.",
|
||||
"STORE_NOTE": "Firefox kann Lesezeichen auf manchen Seiten blockieren; dann nimm das Skript. Xbox liest nur die Spiele, die auf der Seite zu sehen sind, Nintendo nur Käufe. Die Seiten können sich ändern, dann geht eine Seite eventuell nicht mehr.",
|
||||
"STORE_READ": "{{count}} Spiele von der Store-Seite gelesen",
|
||||
"STORE_LIST_BROKEN": "Die Liste von der Store-Seite konnte nicht gelesen werden.",
|
||||
"STORE_LIST_WAITS": "Die Liste von der Store-Seite wartet, bis der laufende Import fertig ist. Lade diese Seite nicht neu."
|
||||
},
|
||||
"UNSAVED_CHANGES": {
|
||||
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
||||
|
||||
+10
-1
@@ -711,7 +711,16 @@
|
||||
"SOURCE_HLTB": "HowLongToBeat",
|
||||
"SOURCE_PLAYNITE": "Playnite",
|
||||
"SOURCE_BOOKMARKLET": "Store page",
|
||||
"FORMAT_READ": "Read as: {{format}}"
|
||||
"FORMAT_READ": "Read as: {{format}}",
|
||||
"STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.",
|
||||
"STORE_LINK": "To Be Played import",
|
||||
"STORE_COPY": "Copy as script",
|
||||
"STORE_COPIED": "Script copied. Paste it in the browser console (F12) on the store page.",
|
||||
"STORE_COPY_FAILED": "Could not copy the script.",
|
||||
"STORE_NOTE": "Firefox can block bookmarks on some pages; then use the script. Xbox reads only the games shown on the page, Nintendo only purchases. The sites can change their pages, so a site may stop working.",
|
||||
"STORE_READ": "{{count}} games read from the store page",
|
||||
"STORE_LIST_BROKEN": "The list from the store page could not be read.",
|
||||
"STORE_LIST_WAITS": "The list from the store page waits until the running import is done. Do not reload this page."
|
||||
},
|
||||
"UNSAVED_CHANGES": {
|
||||
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, Search} from 'lucide-react';
|
||||
import {Copy, Loader2, Search} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
@@ -10,14 +11,22 @@ import useStartImport from '../../hooks/useStartImport.js';
|
||||
import {readImportFile} from '../../utils/importFormats.js';
|
||||
import {parseTextList} from '../../utils/imports.js';
|
||||
import {MAX_IMPORT_ROWS} from '../../utils/limits.js';
|
||||
import {bookmarkletHref, consoleScript} from '../../utils/storeList.js';
|
||||
|
||||
export default function ImportInput({onCreated}) {
|
||||
const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG';
|
||||
// React 19 blocks javascript: URLs in href. One function, so React sets it only when the link mounts.
|
||||
const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(location.origin));
|
||||
|
||||
/**
|
||||
* @param storeRows the games the bookmarklet read on a store page, or null
|
||||
*/
|
||||
export default function ImportInput({storeRows, onCreated}) {
|
||||
const {t} = useTranslation();
|
||||
const [source, setSource] = useState('TEXT');
|
||||
const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT');
|
||||
const [text, setText] = useState('');
|
||||
const [csv, setCsv] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
|
||||
const rows = source === 'TEXT' ? parseTextList(text) : (source === 'CSV' ? csv?.rows : storeRows) ?? [];
|
||||
const tooMany = rows.length > MAX_IMPORT_ROWS;
|
||||
|
||||
const readFile = async (file) => setCsv(file ? readImportFile(await file.text()) : null);
|
||||
@@ -35,12 +44,14 @@ export default function ImportInput({onCreated}) {
|
||||
<Card className="gap-4 p-5">
|
||||
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
|
||||
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
|
||||
{['TEXT', 'CSV'].map((value) => (
|
||||
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
|
||||
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
|
||||
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
|
||||
))}
|
||||
</div>
|
||||
{source === 'TEXT' ? (
|
||||
{source === 'BOOKMARKLET' ? (
|
||||
<StoreInput rows={storeRows}/>
|
||||
) : source === 'TEXT' ? (
|
||||
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
|
||||
onChange={(e) => setText(e.target.value)}/>
|
||||
) : (
|
||||
@@ -52,15 +63,7 @@ export default function ImportInput({onCreated}) {
|
||||
{csv?.rows && csv.format !== 'CSV' && (
|
||||
<p className="text-sm">{t('IMPORT.FORMAT_READ', {format: t(`IMPORT.SOURCE_${csv.format}`)})}</p>
|
||||
)}
|
||||
{csv?.rows?.length > 0 && (
|
||||
<ul className="text-sm text-muted-foreground">
|
||||
{csv.rows.slice(0, 5).map((row, index) => (
|
||||
<li key={index} className="truncate">
|
||||
{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<RowPreview rows={csv?.rows}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -74,3 +77,43 @@ export default function ImportInput({onCreated}) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const RowPreview = ({rows}) => rows?.length > 0 && (
|
||||
<ul className="text-sm text-muted-foreground">
|
||||
{rows.slice(0, 5).map((row, index) => (
|
||||
<li key={index} className="truncate">{[row.title, row.platform, row.status].filter(Boolean).join(' · ')}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
function StoreInput({rows}) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
const copyScript = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(consoleScript(location.origin));
|
||||
toast.success(t('IMPORT.STORE_COPIED'));
|
||||
} catch {
|
||||
toast.error(t('IMPORT.STORE_COPY_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
if (rows) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">{t('IMPORT.STORE_READ', {count: rows.length})}</p>
|
||||
<RowPreview rows={rows}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>{t('IMPORT.STORE_HINT', {sites: STORE_SITES})}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a ref={setBookmarklet} onClick={(e) => e.preventDefault()} className="rounded-md border border-dashed px-3 py-1.5 font-medium">{t('IMPORT.STORE_LINK')}</a>
|
||||
<Button size="sm" variant="outline" onClick={copyScript}><Copy/>{t('IMPORT.STORE_COPY')}</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('IMPORT.STORE_NOTE')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useLocation, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ArrowLeft, Loader2, Upload} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import ImportHistory from '../components/import/ImportHistory.jsx';
|
||||
@@ -9,15 +10,20 @@ import ImportInput from '../components/import/ImportInput.jsx';
|
||||
import ImportView from '../components/import/ImportView.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {listImports, RUNNING} from '../utils/imports.js';
|
||||
import {decodeStoreList} from '../utils/storeList.js';
|
||||
|
||||
/**
|
||||
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
|
||||
* the matches and saves them. ?id= opens one import; the history below can undo a saved import.
|
||||
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list
|
||||
* the bookmarklet read on a store page.
|
||||
*/
|
||||
export default function ImportPage() {
|
||||
const {t} = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [imports, setImports] = useState(null);
|
||||
const [storeRows, setStoreRows] = useState(null);
|
||||
const {hash, search} = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const id = searchParams.get('id');
|
||||
|
||||
const loadImports = useCallback(async () => {
|
||||
@@ -37,6 +43,21 @@ export default function ImportPage() {
|
||||
|
||||
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
|
||||
const open = imports?.find((entry) => RUNNING.includes(entry.status));
|
||||
|
||||
// Out of the URL, so a reload or the back button does not bring the list again. Only after the first answer: on
|
||||
// a 401 the login page must still get it.
|
||||
useEffect(() => {
|
||||
const data = new URLSearchParams(hash.slice(1)).get('data');
|
||||
if (!data || imports === null) return;
|
||||
const rows = decodeStoreList(data);
|
||||
if (!rows?.length) toast.error(t('IMPORT.STORE_LIST_BROKEN'));
|
||||
else {
|
||||
setStoreRows(rows);
|
||||
if (open) toast.info(t('IMPORT.STORE_LIST_WAITS'));
|
||||
}
|
||||
navigate({search}, {replace: true});
|
||||
}, [hash, search, navigate, t, imports, open]);
|
||||
|
||||
const show = useCallback((next) => setSearchParams(next ? {id: String(next)} : {}, {replace: !id}),
|
||||
[setSearchParams, id]);
|
||||
// Into the URL, so the import stays shown when it leaves the worker (review, done).
|
||||
@@ -62,7 +83,9 @@ export default function ImportPage() {
|
||||
) : shownId ? (
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
<ImportInput onCreated={(created) => {
|
||||
// A store list comes after the first answer: a new key opens its tab.
|
||||
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
|
||||
setStoreRows(null);
|
||||
show(created);
|
||||
loadImports();
|
||||
}}/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import ImportPage from './ImportPage.jsx';
|
||||
import {initialChoice, stepOf} from '../utils/imports.js';
|
||||
@@ -11,7 +11,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
|
||||
|
||||
|
||||
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
|
||||
@@ -29,6 +29,11 @@ const ready = {
|
||||
};
|
||||
|
||||
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
|
||||
const ShownUrl = () => {
|
||||
const {pathname, search, hash} = useLocation();
|
||||
return <output aria-label="url">{pathname + search + hash}</output>;
|
||||
};
|
||||
|
||||
function renderPage(routes, entry = '/import') {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
|
||||
@@ -44,10 +49,13 @@ function renderPage(routes, entry = '/import') {
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
<Route path="/login" element={<p>login</p>}/>
|
||||
</Routes>
|
||||
<ShownUrl/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const storeData = (games) => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(games))));
|
||||
|
||||
const posted = (method, path) => fetch.mock.calls.find(([url, init]) => init?.method === method && url.endsWith(path));
|
||||
const polls = (path) => fetch.mock.calls.filter(([url, init]) => !init?.method && url.endsWith(path)).length;
|
||||
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
@@ -154,6 +162,59 @@ describe('ImportPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the store list from the URL, drops it there and sends it as BOOKMARKLET', async () => {
|
||||
renderPage({
|
||||
'GET ': json(200, {imports: []}),
|
||||
'POST ': json(202, {id: 9}),
|
||||
'GET /9': json(200, {id: 9, source: 'BOOKMARKLET', status: 'MATCHING', total: 2, matched: 0}),
|
||||
}, `/import#data=${storeData([['Hades', 'PC'], ['Pokémon Scarlet', 'Switch']])}`);
|
||||
|
||||
expect(await screen.findByText('IMPORT.STORE_READ:2')).toBeTruthy();
|
||||
expect(screen.getByText('Pokémon Scarlet · Switch')).toBeTruthy();
|
||||
expect(screen.getByLabelText('url').textContent).toBe('/import');
|
||||
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
|
||||
|
||||
await screen.findByText('IMPORT.MATCHING');
|
||||
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({source: 'BOOKMARKLET', rows: [
|
||||
{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: null},
|
||||
{title: 'Pokémon Scarlet', platform: 'Switch', playtimeMinutes: null, status: null},
|
||||
]});
|
||||
});
|
||||
|
||||
it('tells the user when the store list in the URL is broken', async () => {
|
||||
renderPage({'GET ': json(200, {imports: []})}, '/import#data=broken');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
|
||||
expect(screen.getByLabelText('url').textContent).toBe('/import');
|
||||
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says that the store list waits while another import runs', async () => {
|
||||
renderPage({
|
||||
'GET ': json(200, {imports: [{...ready, status: 'MATCHING', rows: null}]}),
|
||||
'GET /5': json(200, {...ready, status: 'MATCHING', rows: null}),
|
||||
}, `/import#data=${storeData([['Hades', 'PC']])}`);
|
||||
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('IMPORT.STORE_LIST_WAITS'));
|
||||
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats a store list without a usable row as broken', async () => {
|
||||
renderPage({'GET ': json(200, {imports: []})}, `/import#data=${btoa('[[1, "PC"]]')}`);
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('IMPORT.STORE_LIST_BROKEN'));
|
||||
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers the bookmarklet as a link, not run on click', async () => {
|
||||
renderPage({'GET ': json(200, {imports: []})});
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_BOOKMARKLET'}));
|
||||
|
||||
const link = screen.getByText('IMPORT.STORE_LINK');
|
||||
expect(link.getAttribute('href')).toMatch(/^javascript:[^#]*$/);
|
||||
expect(fireEvent.click(link)).toBe(false);
|
||||
});
|
||||
|
||||
it('polls while matching and then shows the review', async () => {
|
||||
let calls = 0;
|
||||
renderPage({
|
||||
@@ -360,4 +421,18 @@ describe('ImportPage session', () => {
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the store list in the URL for the login when the session has expired', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/imports': json(401, {}),
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
});
|
||||
const entry = `/import#data=${storeData([['Hades', 'PC']])}`;
|
||||
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: [entry], guarded: true,
|
||||
routes: {'/login': <p>login</p>}});
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||
expect(router.state.location.state).toEqual({sessionExpired: true, from: entry});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import {importRow} from './imports.js';
|
||||
import {MAX_IMPORT_ROWS} from './limits.js';
|
||||
|
||||
/**
|
||||
* Runs on a store page, not in this app: as a bookmarklet or pasted in the browser console. It reads the user's
|
||||
* games with the page's own login and opens our import page with them in the URL fragment, which the browser sends
|
||||
* to no server. It must use nothing from this module: only its text is taken.
|
||||
*
|
||||
* Unverified: each reader follows public scripts of the site's own JSON (see the README), not a real account yet.
|
||||
*
|
||||
* @param origin the address of this app
|
||||
*/
|
||||
export function readStoreList(origin) {
|
||||
const MAX_ROWS = 1000;
|
||||
const json = async (url) => {
|
||||
const response = await fetch(url, {credentials: 'include'});
|
||||
if (!response.ok) throw new Error(`${new URL(url, location.href).host} answered ${response.status}`);
|
||||
return response.json();
|
||||
};
|
||||
const readers = {
|
||||
'library.playstation.com': async () => {
|
||||
const rows = [];
|
||||
const hash = '2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2';
|
||||
for (let start = 0; rows.length < MAX_ROWS; start += 100) {
|
||||
const variables = {isActive: true, platform: ['ps3', 'ps4', 'ps5'], start, size: 100, subscriptionService: 'NONE'};
|
||||
const data = await json('https://web.np.playstation.com/api/graphql/v1/op?operationName=getPurchasedGameList'
|
||||
+ `&variables=${encodeURIComponent(JSON.stringify(variables))}`
|
||||
+ `&extensions=${encodeURIComponent(JSON.stringify({persistedQuery: {version: 1, sha256Hash: hash}}))}`);
|
||||
const games = data?.data?.purchasedTitlesRetrieve?.games ?? [];
|
||||
games.forEach((game) => rows.push([game.name, {PS4: 'PS4', PS5: 'PS5'}[String(game.platform).toUpperCase()] ?? null]));
|
||||
if (games.length < 100) break;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
'www.gog.com': async () => {
|
||||
const rows = [];
|
||||
for (let page = 1, pages = 1; page <= pages && rows.length < MAX_ROWS; page++) {
|
||||
const data = await json(`/account/getFilteredProducts?mediaType=1&hiddenFlag=0&page=${page}`);
|
||||
pages = data.totalPages ?? 1;
|
||||
(data.products ?? []).forEach((product) => rows.push([product.title, 'PC']));
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
'www.epicgames.com': async () => {
|
||||
const rows = [];
|
||||
let next = '';
|
||||
do {
|
||||
const data = await json(`/account/v2/payment/ajaxGetOrderHistory?locale=en-US&nextPageToken=${encodeURIComponent(next)}`);
|
||||
(data.orders ?? []).forEach((order) => (order.items ?? []).forEach((item) => rows.push([item.description, 'PC'])));
|
||||
next = data.nextPageToken ?? '';
|
||||
} while (next && rows.length < MAX_ROWS);
|
||||
return rows;
|
||||
},
|
||||
'ec.nintendo.com': async () => {
|
||||
const rows = [];
|
||||
for (let offset = 0; rows.length < MAX_ROWS; offset += 100) {
|
||||
const data = await json(`/api/my/transactions?limit=100&offset=${offset}`);
|
||||
const transactions = data.transactions ?? [];
|
||||
transactions.forEach((transaction) => rows.push([transaction.title, 'Switch']));
|
||||
if (transactions.length < 100 || offset + 100 >= (data.total ?? 0)) break;
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
// No JSON without the Xbox tokens the page keeps to itself: the game links of the open page.
|
||||
'www.xbox.com': async () => [...document.querySelectorAll('a[href*="/games/"]')]
|
||||
.map((link) => [link.getAttribute('aria-label') || link.querySelector('img[alt]')?.alt || link.textContent, null]),
|
||||
};
|
||||
const read = readers[location.hostname];
|
||||
if (!read) {
|
||||
alert(`To Be Played reads the game list on: ${Object.keys(readers).join(', ')}.`);
|
||||
return;
|
||||
}
|
||||
read().then((rows) => {
|
||||
const seen = new Set();
|
||||
const games = rows.map(([title, platform]) => [String(title ?? '').trim(), platform])
|
||||
.filter(([title]) => title && !seen.has(title.toLowerCase()) && seen.add(title.toLowerCase()))
|
||||
.slice(0, MAX_ROWS);
|
||||
if (games.length === 0) {
|
||||
alert('To Be Played found no games on this page. Are you logged in, and did the list load?');
|
||||
return;
|
||||
}
|
||||
let binary = '';
|
||||
new TextEncoder().encode(JSON.stringify(games)).forEach((byte) => { binary += String.fromCharCode(byte); });
|
||||
const data = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
location.href = `${origin}/import#data=${data}`;
|
||||
}).catch((err) => alert(`To Be Played could not read the list: ${err.message}`));
|
||||
}
|
||||
|
||||
export const consoleScript = (origin) => `(${readStoreList})(${JSON.stringify(origin)});`;
|
||||
|
||||
// The whole code is encoded: a # in it would end the URL there.
|
||||
export const bookmarkletHref = (origin) => `javascript:${encodeURIComponent(consoleScript(origin))}`;
|
||||
|
||||
/**
|
||||
* The #data of readStoreList as import rows; null when it is no such list.
|
||||
*/
|
||||
export const decodeStoreList = (data) => {
|
||||
try {
|
||||
const binary = atob(data.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const games = JSON.parse(new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))));
|
||||
if (!Array.isArray(games)) return null;
|
||||
return games.filter((game) => Array.isArray(game) && typeof game[0] === 'string' && game[0].trim())
|
||||
.slice(0, MAX_IMPORT_ROWS)
|
||||
.map(([title, platform]) => importRow({title, platform}));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {bookmarkletHref, consoleScript, decodeStoreList} from './storeList.js';
|
||||
|
||||
const ORIGIN = 'https://tobeplayed.example';
|
||||
|
||||
// Runs the script text with only the globals it gets here, as a store page would: proves it needs nothing else.
|
||||
const runOn = async (hostname, answers, {script = consoleScript(ORIGIN), document} = {}) => {
|
||||
const location = {hostname, href: `https://${hostname}/`};
|
||||
const alert = vi.fn();
|
||||
const fetch = vi.fn(async (url) => {
|
||||
const answer = answers.find(([part]) => url.includes(part));
|
||||
if (!answer) return {ok: false, status: 404};
|
||||
return {ok: true, json: async () => answer[1]};
|
||||
});
|
||||
new Function('fetch', 'location', 'alert', 'document', script)(fetch, location, alert, document);
|
||||
await vi.waitFor(() => expect(alert.mock.calls.length + (location.href.startsWith(ORIGIN) ? 1 : 0)).toBe(1));
|
||||
const data = location.href.startsWith(ORIGIN) ? location.href.split('#data=')[1] : null;
|
||||
return {alert, fetch, rows: data && decodeStoreList(data)};
|
||||
};
|
||||
|
||||
const game = (title, platform) => ({title, platform, playtimeMinutes: null, status: null});
|
||||
|
||||
describe('store list', () => {
|
||||
it('reads GOG page by page', async () => {
|
||||
const {rows, fetch} = await runOn('www.gog.com', [
|
||||
['page=1', {totalPages: 2, products: [{title: 'Hades'}]}],
|
||||
['page=2', {totalPages: 2, products: [{title: 'Celeste'}]}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hades', 'PC'), game('Celeste', 'PC')]);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reads PlayStation until a page is not full, with the console per game', async () => {
|
||||
const full = Array.from({length: 100}, (_, i) => ({name: `Game ${i}`, platform: 'ps5'}));
|
||||
const {rows} = await runOn('library.playstation.com', [
|
||||
['%22start%22%3A0%2C', {data: {purchasedTitlesRetrieve: {games: full}}}],
|
||||
['%22start%22%3A100%2C', {data: {purchasedTitlesRetrieve: {games: [{name: 'Bloodborne', platform: 'PS4'}, {name: 'Old', platform: 'PS3'}]}}}],
|
||||
]);
|
||||
expect(rows).toHaveLength(102);
|
||||
expect(rows.slice(100)).toEqual([game('Bloodborne', 'PS4'), game('Old', null)]);
|
||||
});
|
||||
|
||||
it('reads Epic orders by the next page token', async () => {
|
||||
const {rows} = await runOn('www.epicgames.com', [
|
||||
['nextPageToken=abc', {orders: [{items: [{description: 'Celeste'}]}]}],
|
||||
['nextPageToken=', {orders: [{items: [{description: 'Hades'}, {description: 'Control'}]}], nextPageToken: 'abc'}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hades', 'PC'), game('Control', 'PC'), game('Celeste', 'PC')]);
|
||||
});
|
||||
|
||||
it('reads Nintendo purchases', async () => {
|
||||
const {rows} = await runOn('ec.nintendo.com', [
|
||||
['offset=0', {total: 1, transactions: [{title: 'Hollow Knight'}]}],
|
||||
]);
|
||||
expect(rows).toEqual([game('Hollow Knight', 'Switch')]);
|
||||
});
|
||||
|
||||
it('reads the game links of the Xbox page', async () => {
|
||||
const link = (label, alt, text) => ({
|
||||
getAttribute: () => label, querySelector: () => (alt ? {alt} : null), textContent: text,
|
||||
});
|
||||
const document = {querySelectorAll: () => [link('Halo', null, ''), link(null, 'Forza', ''), link(null, null, ' Starfield ')]};
|
||||
const {rows} = await runOn('www.xbox.com', [], {document});
|
||||
expect(rows).toEqual([game('Halo', null), game('Forza', null), game('Starfield', null)]);
|
||||
});
|
||||
|
||||
it('drops doubles and empty titles and keeps at most 1000 games', async () => {
|
||||
const products = [{title: 'Hades'}, {title: ' hades '}, {title: ''}, {title: null},
|
||||
...Array.from({length: 1200}, (_, i) => ({title: `Game ${i}`}))];
|
||||
const {rows, alert} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
|
||||
expect(alert).not.toHaveBeenCalled();
|
||||
expect(rows).toHaveLength(1000);
|
||||
expect(rows[0]).toEqual(game('Hades', 'PC'));
|
||||
});
|
||||
|
||||
it('keeps 1000 long titles under the URL size of Safari', async () => {
|
||||
const products = Array.from({length: 1000}, (_, i) => ({title: `Ünïcode game title number ${i} with a subtitle`}));
|
||||
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products}]]);
|
||||
const data = btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(rows.map((row) => [row.title, row.platform])))));
|
||||
expect(rows[5].title).toBe('Ünïcode game title number 5 with a subtitle');
|
||||
expect(`${ORIGIN}/import#data=${data}`.length).toBeLessThan(80_000);
|
||||
});
|
||||
|
||||
it('tells the user on an unknown site, an empty list and a failed call', async () => {
|
||||
expect((await runOn('example.com', [])).alert.mock.calls[0][0]).toContain('www.gog.com');
|
||||
expect((await runOn('www.gog.com', [['page=1', {products: []}]])).alert.mock.calls[0][0]).toContain('found no games');
|
||||
expect((await runOn('ec.nintendo.com', [])).alert.mock.calls[0][0]).toContain('ec.nintendo.com answered 404');
|
||||
});
|
||||
|
||||
it('runs the same code from the bookmarklet link, # included', async () => {
|
||||
const href = bookmarkletHref(ORIGIN);
|
||||
expect(href).toMatch(/^javascript:[^#]*$/);
|
||||
const {rows} = await runOn('www.gog.com', [['page=1', {totalPages: 1, products: [{title: 'Hades'}]}]],
|
||||
{script: decodeURIComponent(href.slice('javascript:'.length))});
|
||||
expect(rows).toEqual([game('Hades', 'PC')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeStoreList', () => {
|
||||
it('returns null for data that is no list', () => {
|
||||
expect(decodeStoreList('%%%')).toBeNull();
|
||||
expect(decodeStoreList(btoa('{"a":1}'))).toBeNull();
|
||||
});
|
||||
|
||||
it('cuts titles and drops unknown values', () => {
|
||||
const data = btoa(JSON.stringify([['x'.repeat(150), 'y'.repeat(40)], ['Hades', 7], [3, 'PC']]));
|
||||
expect(decodeStoreList(data)).toEqual([game('x'.repeat(100), null), game('Hades', null)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user