From f01ad03f6e925b8a77f78e8da14fb14f18b68cd1 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 15:59:05 +0200 Subject: [PATCH] feat(import): bookmarklet for store pages --- README.md | 12 +++ public/i18n/de.json | 11 ++- public/i18n/en.json | 11 ++- src/components/import/ImportInput.jsx | 73 +++++++++++++---- src/pages/ImportPage.jsx | 29 ++++++- src/pages/ImportPage.test.jsx | 79 +++++++++++++++++- src/utils/storeList.js | 108 +++++++++++++++++++++++++ src/utils/storeList.test.js | 110 ++++++++++++++++++++++++++ 8 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 src/utils/storeList.js create mode 100644 src/utils/storeList.test.js diff --git a/README.md b/README.md index f21a76a..095f26f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/public/i18n/de.json b/public/i18n/de.json index 901dd02..e1f27b8 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -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?" diff --git a/public/i18n/en.json b/public/i18n/en.json index 69b5e59..6e304cb 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -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?" diff --git a/src/components/import/ImportInput.jsx b/src/components/import/ImportInput.jsx index 8567890..ec53aaa 100644 --- a/src/components/import/ImportInput.jsx +++ b/src/components/import/ImportInput.jsx @@ -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}) {

{t('IMPORT.HINT')}

- {['TEXT', 'CSV'].map((value) => ( + {['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => ( ))}
- {source === 'TEXT' ? ( + {source === 'BOOKMARKLET' ? ( + + ) : source === 'TEXT' ? (