Compare commits

..
4 Commits
Author SHA1 Message Date
lobo f01ad03f6e feat(import): bookmarklet for store pages
Build, Test & Push Frontend / docker (push) Successful in 25s
Build, Test & Push Frontend / quality-check (push) Successful in 22s
2026-09-30 15:59:05 +02:00
lobo c6d76b4cb5 fix(session): keep the hash on the login round trip 2026-09-30 15:59:05 +02:00
lobo 8e31eafc06 feat(import): read the exports of GOG Galaxy, Grouvee, HowLongToBeat and Playnite 2026-09-30 15:59:05 +02:00
lobo 89d0b29698 refactor: item 29 cleanup
F16 F17 F18 F19 F20 F21: import polling backs off and pauses in a hidden tab, one date format module, fake timers in the import tests, error codes instead of raw messages, one safe storage wrapper, dead code.
2026-09-30 15:00:47 +02:00
46 changed files with 943 additions and 126 deletions

No files matched your search

+24 -1
View File
@@ -42,6 +42,21 @@ To Be Played is split into three repositories:
progress, and "Try again" for an import that stopped. The review shows the best match, other candidates and
"search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be
undone.
- **Export files** in the same file field, told apart by their header (`src/utils/importFormats.js`): GOG Galaxy 2.0
(the community script [GOG-Galaxy-Export-Script](https://github.com/AB1908/GOG-Galaxy-Export-Script), minutes),
Grouvee (Settings, "Export your collection", shelves as status, the wish list is left out), HowLongToBeat (its CSV
export, the lists as status, "Ignored" left out) and Playnite (the add-on "Library Exporter Advanced", completion
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.
@@ -119,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
```
@@ -144,6 +160,13 @@ 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
write is lost, but the app still starts.
- `PUT /api/users/settings` replaces dark mode, language, display name and public profile at once, so every save
goes through `saveSettings` in `src/utils/settings.js`: one after the other, each on a fresh `GET /api/users/me`.
- Pages keep a load error as its i18n key and translate it when rendering, so a language switch loads nothing
@@ -211,7 +234,7 @@ npm run build
## Roadmap
See the roadmap in the ToBePlayed-Core readme.
See `ROADMAP.md` in the ToBePlayed-Deployment repo.
## License
+18 -3
View File
@@ -542,7 +542,7 @@
"BACK_TO_SOURCE": "Zurück zur Quelle",
"CORRECTED": "Korrigiert",
"SAVED": "Korrektur gespeichert",
"INVALID": "Nicht gespeichert: {{message}}",
"INVALID": "Nicht gespeichert: Der Wert passt nicht zu diesem Feld.",
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
@@ -641,7 +641,7 @@
"TEXT_LABEL": "Spieltitel",
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
"CSV_LABEL": "CSV-Datei",
"CSV_HINT": "Erste Zeile mit Spaltennamen: title (oder name), platform, hours (oder minutes), status.",
"CSV_HINT": "Erste Zeile mit Spaltennamen: title (oder name), platform, hours (oder minutes), status. Exporte von GOG Galaxy, Grouvee, HowLongToBeat und Playnite werden direkt gelesen.",
"CSV_NO_TITLE": "Die Datei hat keine Spalte title oder name.",
"LINES": "{{count}} Zeilen",
"TOO_MANY": "{{count}} Zeilen: höchstens {{max}} pro Import.",
@@ -705,7 +705,22 @@
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
"RETRY": "Nochmal versuchen",
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter.",
"SOURCE_GALAXY": "GOG Galaxy",
"SOURCE_GROUVEE": "Grouvee",
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store-Seite",
"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?"
+18 -3
View File
@@ -542,7 +542,7 @@
"BACK_TO_SOURCE": "Back to source",
"CORRECTED": "Corrected",
"SAVED": "Correction saved",
"INVALID": "Not saved: {{message}}",
"INVALID": "Not saved: the value does not fit this field.",
"NO_ACCESS": "Only auditors can correct game data.",
"ERROR": "The correction could not be saved.",
"IMAGE_HINT": "https://… (empty: the Steam header)",
@@ -641,7 +641,7 @@
"TEXT_LABEL": "Game titles",
"TEXT_PLACEHOLDER": "One game per line",
"CSV_LABEL": "CSV file",
"CSV_HINT": "First row with column names: title (or name), platform, hours (or minutes), status.",
"CSV_HINT": "First row with column names: title (or name), platform, hours (or minutes), status. Exports of GOG Galaxy, Grouvee, HowLongToBeat and Playnite are read as they are.",
"CSV_NO_TITLE": "The file has no title or name column.",
"LINES": "{{count}} lines",
"TOO_MANY": "{{count}} lines: at most {{max}} per import.",
@@ -705,7 +705,22 @@
"FAILED": "The import stopped after {{count}} tries.",
"RETRY": "Try again",
"RETRY_FAILED": "Could not start the import again.",
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
"ATTEMPT": "Try {{attempt}}: the last try made no progress.",
"SOURCE_GALAXY": "GOG Galaxy",
"SOURCE_GROUVEE": "Grouvee",
"SOURCE_HLTB": "HowLongToBeat",
"SOURCE_PLAYNITE": "Playnite",
"SOURCE_BOOKMARKLET": "Store page",
"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?"
+2 -1
View File
@@ -3,11 +3,12 @@ import {UserRound} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {formatDateTime} from '../utils/format.js';
const formatDate = (value, locale) => {
if (!value) return '—';
try {
return new Date(value).toLocaleString(locale);
return formatDateTime(value, locale);
} catch {
return value;
}
+2 -1
View File
@@ -4,7 +4,8 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import {formatDay, formatPlaytime} from '../utils/games.js';
import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js';
const CHART_HEIGHT = 80;
const BAR_WIDTH = 10;
+2 -1
View File
@@ -5,11 +5,12 @@ import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
import {formatDate} from '../utils/format.js';
const formatUnlockedAt = (instant, locale) => {
if (!instant) return '';
try {
return new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'});
return formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'});
} catch {
return '';
}
+5 -2
View File
@@ -12,6 +12,8 @@ import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '.
import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
const initialValues = (game) => ({
@@ -68,8 +70,9 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
return;
}
const data = await response.json().catch(() => ({}));
toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message})
: response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR'));
// Core's message is English and for developers, so it goes to the console only.
if (data.message) console.warn('Correction refused:', data.code, data.message);
toast.error(t(REFUSAL_KEY[data.code] ?? (response.status === 403 ? 'CORRECTION.NO_ACCESS' : 'CORRECTION.ERROR')));
} catch (err) {
console.error('Correction error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
+13 -1
View File
@@ -79,7 +79,8 @@ describe('GameCorrectionDialog', () => {
expect(sent()).toEqual([{url: expect.stringContaining('/corrections/name'), method: 'DELETE', body: undefined}]);
});
it('shows why Core refused a value', async () => {
it('says Core refused a value and logs its message instead of showing it', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
corrections(json(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
const onChanged = renderDialog();
@@ -87,9 +88,20 @@ describe('GameCorrectionDialog', () => {
fireEvent.click(saveButton(1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID'));
expect(warn).toHaveBeenCalledWith('Correction refused:', 'INVALID_CORRECTION', 'image must be https');
expect(onChanged).not.toHaveBeenCalled();
});
it('shows the generic error for an unknown code', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
corrections(json(400, {code: 'SOMETHING_NEW', message: 'raw text'}));
renderDialog();
fireEvent.click(saveButton(1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.ERROR'));
});
it('keeps an unsaved edit of another field after the reload', async () => {
const onChanged = vi.fn();
const {rerender} = render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()}
+2 -2
View File
@@ -6,11 +6,11 @@ import useSession from '../hooks/useSession.js';
* After a logout it only opens the login page, so the next login does not land on the page of the last user.
*/
export default function RequireSession() {
const {pathname, search} = useLocation();
const {pathname, search, hash} = useLocation();
const {active, endReason} = useSession();
if (active) return <Outlet/>;
const from = pathname + search;
const from = pathname + search + hash;
if (endReason === 'logout') return <Navigate to="/login" replace/>;
return <Navigate to="/login" replace state={endReason === 'expired' ? {sessionExpired: true, from} : {from}}/>;
}
+8
View File
@@ -30,6 +30,14 @@ describe('RequireSession', () => {
expect(at(router)).toEqual({path: '/login', state: {from: '/library?status=PLAYING'}});
});
// The bookmarklet sends its list in #data; it must survive the login.
it('keeps the hash of the deep link', async () => {
const router = renderAt('/library#data=abc');
expect(await screen.findByText('login')).toBeTruthy();
expect(at(router)).toEqual({path: '/login', state: {from: '/library#data=abc'}});
});
it('shows the page with a session', () => {
markSession();
renderAt('/library');
+2 -1
View File
@@ -6,6 +6,7 @@ import {RotateCcw} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {formatDate} from '../../utils/format.js';
import {checkUndo, undoImport} from '../../utils/imports.js';
export default function ImportHistory({imports, onOpen, onChanged}) {
@@ -44,7 +45,7 @@ export default function ImportHistory({imports, onOpen, onChanged}) {
{imports.map((entry) => (
<li key={entry.id} className="flex flex-wrap items-center gap-3 px-5 py-3 text-sm">
<span className="tabular-nums text-muted-foreground">
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
{formatDate(entry.createdAt, i18n.language)}
</span>
<span className="flex-1">
{t(`IMPORT.SOURCE_${entry.source}`)} · {t('IMPORT.LINES', {count: entry.total})}
+66 -18
View File
@@ -1,30 +1,41 @@
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';
import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea';
import useStartImport from '../../hooks/useStartImport.js';
import {parseCsv, parseTextList} from '../../utils/imports.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 ? parseCsv(await file.text()) : null);
const readFile = async (file) => setCsv(file ? readImportFile(await file.text()) : null);
const startImport = useStartImport();
const start = async () => {
setBusy(true);
const id = await startImport(source, rows);
// An export names its format, so the history shows where the lines came from.
const id = await startImport(source === 'CSV' ? csv.format : source, rows);
setBusy(false);
if (id) onCreated(id);
};
@@ -33,29 +44,26 @@ 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)}/>
) : (
<div className="space-y-2">
<Input type="file" accept=".csv,text/csv" aria-label={t('IMPORT.CSV_LABEL')}
<Input type="file" accept=".csv,.tsv,.txt,text/csv,text/tab-separated-values" aria-label={t('IMPORT.CSV_LABEL')}
onChange={(e) => readFile(e.target.files?.[0])}/>
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p>
{csv?.error && <p className="text-sm text-destructive">{t(csv.error)}</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>
{csv?.rows && csv.format !== 'CSV' && (
<p className="text-sm">{t('IMPORT.FORMAT_READ', {format: t(`IMPORT.SOURCE_${csv.format}`)})}</p>
)}
<RowPreview rows={csv?.rows}/>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -69,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>
);
}
+27 -4
View File
@@ -11,6 +11,8 @@ import {useCurrentUserContext} from '../../context/CurrentUserContext.jsx';
import {getImport, retryImport, RUNNING, STEPS, stepOf} from '../../utils/imports.js';
const POLL_MS = 2000;
const MAX_BACKOFF_MS = 30_000;
const MAX_FAILURES = 10;
// Polls while a worker has the import.
export default function ImportView({id, onDone, onChanged}) {
@@ -28,36 +30,57 @@ export default function ImportView({id, onDone, onChanged}) {
useEffect(() => {
let timer;
let cancelled = false;
let failures = 0;
let paused = false;
// A hidden tab waits for the visibilitychange instead of polling.
const poll = () => {
if (document.hidden) paused = true;
else load();
};
const onVisible = () => {
if (!document.hidden && paused) {
paused = false;
load();
}
};
// A 5xx while Core restarts or a network error is asked again, each time twice as late.
const retry = () => {
if (++failures >= MAX_FAILURES) setFailed(true);
else timer = setTimeout(poll, Math.min(POLL_MS * 2 ** failures, MAX_BACKOFF_MS));
};
const load = async () => {
try {
const response = await getImport(id);
if (cancelled) return;
if (response.status === 401) return;
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
// A 4xx ends the polling.
if (response.status >= 400 && response.status < 500) {
setFailed(true);
return;
}
if (!response.ok) {
timer = setTimeout(load, POLL_MS);
retry();
return;
}
const next = await response.json();
if (cancelled) return;
failures = 0;
// A new status also shows in the history.
if (lastStatus.current && lastStatus.current !== next.status) onChanged();
lastStatus.current = next.status;
setData(next);
if (RUNNING.includes(next.status)) timer = setTimeout(load, POLL_MS);
if (RUNNING.includes(next.status)) timer = setTimeout(poll, POLL_MS);
} catch (err) {
console.error('Import load error:', err);
if (!cancelled) timer = setTimeout(load, POLL_MS);
if (!cancelled) retry();
}
};
document.addEventListener('visibilitychange', onVisible);
load();
return () => {
cancelled = true;
clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [id, version, onChanged]);
+2 -1
View File
@@ -3,13 +3,14 @@ import {Rocket} from 'lucide-react';
import StatsSection from './StatsSection.jsx';
import StatTile from './StatTile.jsx';
import {formatDay} from '../../utils/format.js';
import {formatPlaytime} from '../../utils/games.js';
import {formatDecimal, formatPercent} from '../../utils/stats.js';
export default function HabitsStats({habits}) {
const {t, i18n} = useTranslation();
const day = habits.longestDay
? new Date(`${habits.longestDay.day}T12:00:00`).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'})
: null;
return (
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
+2 -1
View File
@@ -6,11 +6,12 @@ import StatTile from './StatTile.jsx';
import GameRow from './GameRow.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {formatDecimal} from '../../utils/stats.js';
import {formatDate} from '../../utils/format.js';
export default function PileStats({pile, linked = true}) {
const {t, i18n} = useTranslation();
const since = pile.oldestUnplayed
? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
? formatDate(pile.oldestUnplayed.inLibrarySince, i18n.language, {dateStyle: 'medium'})
: null;
return (
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
+1 -1
View File
@@ -1,5 +1,5 @@
import {useTranslation} from 'react-i18next';
import {formatDay} from '../../utils/games.js';
import {formatDay} from '../../utils/format.js';
import {trendPoints} from '../../utils/stats.js';
const WIDTH = 300;
+3 -2
View File
@@ -12,6 +12,7 @@ import {
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
// The --background of style.css; the installed app paints its title bar in this color.
@@ -23,7 +24,7 @@ export function PreferencesProvider({children}) {
const {i18n} = useTranslation();
const [isDarkMode, setIsDarkMode] = useState(() => {
const stored = localStorage.getItem(DARK_MODE_STORAGE_KEY);
const stored = storage.get(DARK_MODE_STORAGE_KEY);
return stored === null ? true : stored === 'true';
});
@@ -35,7 +36,7 @@ export function PreferencesProvider({children}) {
}
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
storage.set(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]);
const [appearance, setAppearanceState] = useState(readCachedAppearance);
+15
View File
@@ -37,6 +37,21 @@ const answerMe = (settings) => fetchRoutes({
'PUT /api/users/settings': json(204),
});
describe('PreferencesProvider storage', () => {
it('renders in dark mode when localStorage throws', () => {
['getItem', 'setItem'].forEach((method) => vi.spyOn(Storage.prototype, method).mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
}));
try {
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(preferences.isDarkMode).toBe(true);
} finally {
vi.restoreAllMocks();
}
});
});
describe('PreferencesProvider language', () => {
it('takes the language saved in Auth', async () => {
answerMe({language: 'en', useDarkTheme: true, publicProfile: false});
+1 -1
View File
@@ -101,7 +101,7 @@ describe('useSaveAppearance', () => {
await result.current.save({wideLayout: true});
});
expect(localStorage.getItem('tbp_jwt')).toBeNull();
expect(localStorage.getItem('tbp_session')).toBeNull();
});
it('ends the session with the default look on a 401, so the next user does not get this one', async () => {
+2 -1
View File
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Textarea} from '@/components/ui/textarea';
import HintCard from '../components/HintCard.jsx';
import {formatDateTime} from '../utils/format.js';
import PageContainer from '../components/PageContainer.jsx';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js';
@@ -94,7 +95,7 @@ export default function AdminHintsPage() {
};
const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const formatDate = (value) => new Date(value).toLocaleString(i18n.language, {dateStyle: 'short', timeStyle: 'short'});
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
if (status === 'forbidden') {
return (
+4 -3
View File
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx';
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
const PAGE_SIZE = 20;
@@ -28,7 +29,7 @@ const REFUSAL_KEY = {
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
};
const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateString(locale) : null);
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
/**
* PLATFORM_ADMIN only: list, block and delete users. Auth checks the role; a 403 shows "no access".
@@ -140,8 +141,8 @@ export default function AdminUsersPage() {
<p className="truncate font-medium">{user.email}</p>
<p className="truncate text-xs text-muted-foreground">
{[user.displayName,
t('ADMIN.REGISTERED', {date: formatDate(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDate(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}),
t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}),
].filter(Boolean).join(' · ')}
</p>
</div>
-2
View File
@@ -56,7 +56,6 @@ export default function GameLibrary() {
const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly);
const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false);
// Starts true until /games-count answers, so a Steam user does not see the sync controls vanish and come back.
@@ -285,7 +284,6 @@ export default function GameLibrary() {
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
state={{librarySearch}}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 sm:w-24 shrink-0"/>
+26 -3
View File
@@ -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();
}}/>
+154 -9
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
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,18 +49,25 @@ 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));
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
localStorage.clear();
vi.clearAllMocks();
});
@@ -130,6 +142,79 @@ describe('ImportPage', () => {
});
});
it('reads an export file by its format and sends that as the source', async () => {
renderPage({
'GET ': json(200, {imports: []}),
'POST ': json(202, {id: 9}),
'GET /9': json(200, {id: 9, source: 'GROUVEE', status: 'MATCHING', total: 1, matched: 0}),
});
fireEvent.click(await screen.findByRole('button', {name: 'IMPORT.SOURCE_CSV'}));
const file = new File(['id,name,shelves,platforms,dates\n1,Hades,"{""Playing"": {}}","{""PC"": {}}",None\n'],
'grouvee.csv', {type: 'text/csv'});
fireEvent.change(screen.getByLabelText('IMPORT.CSV_LABEL'), {target: {files: [file]}});
expect(await screen.findByText('IMPORT.FORMAT_READ')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'IMPORT.START'}));
await screen.findByText('IMPORT.MATCHING');
expect(JSON.parse(posted('POST', '/imports')[1].body)).toEqual({
source: 'GROUVEE', rows: [{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: 'PLAYING'}],
});
});
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({
@@ -138,7 +223,8 @@ describe('ImportPage', () => {
});
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 3000})).toBeTruthy();
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('preselects the favorite, checks only sure matches, and saves the checked lines', async () => {
@@ -240,8 +326,10 @@ describe('ImportPage', () => {
'GET /5': () => (++polls > 1 ? json(200, ready) : json(200, matching)),
});
expect(await screen.findByText('IMPORT.LINE: Hades', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
await screen.findByText('IMPORT.MATCHING');
await wait(2000);
expect(await screen.findByText('IMPORT.LINE: Hades')).toBeTruthy();
});
it('keeps polling after a 502 while Core restarts', async () => {
let calls = 0;
@@ -251,8 +339,51 @@ describe('ImportPage', () => {
total: 4, matched: 1, chosen: 0, saved: 0, added: 0, ahead: null, attempts: 0})),
}, '/import?id=8');
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
}, 10000);
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(4000);
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
});
it('stops polling after repeated 500', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(500, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(10 * 30_000);
expect(screen.getByText('IMPORT.LOAD_FAILED')).toBeTruthy();
expect(polls('/8')).toBe(10);
await wait(60_000);
expect(polls('/8')).toBe(10);
});
it('doubles the delay after each failure', async () => {
renderPage({'GET ': json(200, {imports: []}), 'GET /8': json(503, null)}, '/import?id=8');
await waitFor(() => expect(polls('/8')).toBe(1));
await wait(3500);
expect(polls('/8')).toBe(1);
await wait(1000);
expect(polls('/8')).toBe(2);
await wait(7000);
expect(polls('/8')).toBe(2);
await wait(1500);
expect(polls('/8')).toBe(3);
});
it('pauses polling in a hidden tab and goes on when it is visible again', async () => {
const hidden = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true);
renderPage({'GET ': json(200, {imports: []}), 'GET /9': json(200, {id: 9, source: 'TEXT', status: 'MATCHING',
total: 2, matched: 1})}, '/import?id=9');
await screen.findByText('IMPORT.MATCHING');
await wait(20_000);
expect(polls('/9')).toBe(1);
hidden.mockReturnValue(false);
await act(async () => document.dispatchEvent(new Event('visibilitychange')));
expect(polls('/9')).toBe(2);
await wait(2000);
expect(polls('/9')).toBe(3);
});
});
describe('review findings', () => {
@@ -271,7 +402,7 @@ describe('review findings', () => {
</MemoryRouter>,
);
expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy();
expect(await screen.findByLabelText('IMPORT.LINE: Hades')).toBeTruthy();
});
});
@@ -290,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});
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
import useSession from '../hooks/useSession.js';
@@ -105,7 +106,7 @@ export default function PublicProfilePage() {
const style = normalizeProfileStyle(profile.style);
const memberSince = profile.memberSince
? new Date(`${profile.memberSince}T12:00:00`).toLocaleDateString(i18n.language, {year: 'numeric', month: 'long'})
? formatDay(profile.memberSince, i18n.language, {year: 'numeric', month: 'long'})
: null;
const layout = normalizeLayout(profile.style?.tiles, PROFILE_TILES, DEFAULT_PROFILE);
const owner = viewer?.displayName?.toLowerCase() === profile.displayName.toLowerCase();
+2 -1
View File
@@ -10,9 +10,10 @@ import GameCover from '../components/GameCover.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {apiFetch} from '../utils/api.js';
import {formatDate} from '../utils/format.js';
const formatAdded = (instant, locale) =>
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
instant ? formatDate(instant, locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
export default function WishlistPage() {
const {t, i18n} = useTranslation();
+5
View File
@@ -0,0 +1,5 @@
title platformList playtime lastPlayed
Hades "Steam GOG" 1500 2024-01-02
Bloodborne PlayStation Network 0
Hollow Knight Nintendo Switch 2400 2023-05-06
1 title platformList playtime lastPlayed
2 Hades Steam GOG 1500 2024-01-02
3 Bloodborne PlayStation Network 0
4 Hollow Knight Nintendo Switch 2400 2023-05-06
5
+6
View File
@@ -0,0 +1,6 @@
id,name,shelves,platforms,rating,review_title,review,review_platform,dates,statuses,genres,franchises,series,developers,publishers,release_date,date_added_to_collection,url,giantbomb_id,igdb_id
101,Hades,"{""Played"": {""date_added"": ""2023-01-02T10:00:00Z"", ""url"": ""https://www.grouvee.com/user/x/shelves/1-played/""}}","{""PC"": {""url"": ""https://www.grouvee.com/platforms/pc/""}}",5,,,,"[{""level_of_completion"": ""Main Story"", ""seconds_played"": 90000, ""date_started"": ""2023-01-02"", ""date_finished"": ""2023-02-01""}]",[],{},{},{},{},{},2020-09-17,2023-01-02,https://www.grouvee.com/games/101-hades/,,
102,Celeste,"{""Playing"": {""date_added"": ""2024-03-01T10:00:00Z"", ""url"": """"}}","{""Nintendo Switch"": {""url"": """"}, ""PC"": {""url"": """"}}",,,,,None,[],{},{},{},{},{},2018-01-25,2024-03-01,,,
103,Starfield,"{""Wish List"": {""date_added"": ""2024-04-01T10:00:00Z"", ""url"": """"}}","{""Xbox Series X|S"": {""url"": """"}}",,,,,None,[],{},{},{},{},{},2023-09-06,2024-04-01,,,
104,Outer Wilds,"{""Played"": {""date_added"": ""2022-06-01T10:00:00Z"", ""url"": """"}}","{}",,,,,None,[],{},{},{},{},{},2019-05-28,2022-06-01,,,
105,Disco Elysium,"{""Backlog"": {""date_added"": ""2022-07-01T10:00:00Z"", ""url"": """"}}","{""PlayStation 5"": {""url"": """"}}",,,,,"[{""level_of_completion"": """", ""seconds_played"": 0, ""date_started"": ""None"", ""date_finished"": ""None""}]",[],{},{},{},{},{},2019-10-15,2022-07-01,,,
1 id name shelves platforms rating review_title review review_platform dates statuses genres franchises series developers publishers release_date date_added_to_collection url giantbomb_id igdb_id
2 101 Hades {"Played": {"date_added": "2023-01-02T10:00:00Z", "url": "https://www.grouvee.com/user/x/shelves/1-played/"}} {"PC": {"url": "https://www.grouvee.com/platforms/pc/"}} 5 [{"level_of_completion": "Main Story", "seconds_played": 90000, "date_started": "2023-01-02", "date_finished": "2023-02-01"}] [] {} {} {} {} {} 2020-09-17 2023-01-02 https://www.grouvee.com/games/101-hades/
3 102 Celeste {"Playing": {"date_added": "2024-03-01T10:00:00Z", "url": ""}} {"Nintendo Switch": {"url": ""}, "PC": {"url": ""}} None [] {} {} {} {} {} 2018-01-25 2024-03-01
4 103 Starfield {"Wish List": {"date_added": "2024-04-01T10:00:00Z", "url": ""}} {"Xbox Series X|S": {"url": ""}} None [] {} {} {} {} {} 2023-09-06 2024-04-01
5 104 Outer Wilds {"Played": {"date_added": "2022-06-01T10:00:00Z", "url": ""}} {} None [] {} {} {} {} {} 2019-05-28 2022-06-01
6 105 Disco Elysium {"Backlog": {"date_added": "2022-07-01T10:00:00Z", "url": ""}} {"PlayStation 5": {"url": ""}} [{"level_of_completion": "", "seconds_played": 0, "date_started": "None", "date_finished": "None"}] [] {} {} {} {} {} 2019-10-15 2022-07-01
+6
View File
@@ -0,0 +1,6 @@
"Title","Platform","Playing","Backlog","Replay","Stalled","Ignored","Custom-3","Completed","Retired","Retired Notes","Start Date","Completion Date","Playthrough","Progress","Main Story","Main Story Notes","Main + Extras","Main + Extras Notes","Completionist","Completionist Notes","Storefront","Review","Review Notes","Added","Updated"
"Elden Ring","PlayStation 5","","","","","","","X","","","2022-02-25","2022-05-01","","","85:30:00","","","","","","PlayStation Store","","","2022-02-25","2022-05-01"
"Cyberpunk 2077","PC","X","","","","","","","","","2024-01-01","","","12:15:00","--","","--","","--","","Steam","","","2024-01-01","2024-01-10"
"Anthem","PC","","","","","","","","X","Not for me","","","","03:00:00","--","","--","","--","","Origin","","","2019-02-22","2019-03-01"
"Some Shovelware","Nintendo Switch","","","","","X","","","","","","","","--","--","","--","","--","","","","","2021-01-01","2021-01-01"
"Hollow Knight","Nintendo Switch","","X","","","","","","","","","","","--","--","","--","","--","","Nintendo eShop","","","2021-01-01","2021-01-01"
1 Title Platform Playing Backlog Replay Stalled Ignored Custom-3 Completed Retired Retired Notes Start Date Completion Date Playthrough Progress Main Story Main Story Notes Main + Extras Main + Extras Notes Completionist Completionist Notes Storefront Review Review Notes Added Updated
2 Elden Ring PlayStation 5 X 2022-02-25 2022-05-01 85:30:00 PlayStation Store 2022-02-25 2022-05-01
3 Cyberpunk 2077 PC X 2024-01-01 12:15:00 -- -- -- Steam 2024-01-01 2024-01-10
4 Anthem PC X Not for me 03:00:00 -- -- -- Origin 2019-02-22 2019-03-01
5 Some Shovelware Nintendo Switch X -- -- -- -- 2021-01-01 2021-01-01
6 Hollow Knight Nintendo Switch X -- -- -- -- Nintendo eShop 2021-01-01 2021-01-01
+6
View File
@@ -0,0 +1,6 @@
"Name","Platforms","Playtime","Completion Status","Source"
"Hades","PC (Windows)","90000","Beaten","Steam"
"Bloodborne","Sony PlayStation 4","0","Plan to Play","PlayStation"
"Hollow Knight","Nintendo Switch, PC (Windows)","144000","Completed","Nintendo"
"Anthem","","10800","Abandoned","EA app"
"Celeste","","3600","Playing","Epic"
1 Name Platforms Playtime Completion Status Source
2 Hades PC (Windows) 90000 Beaten Steam
3 Bloodborne Sony PlayStation 4 0 Plan to Play PlayStation
4 Hollow Knight Nintendo Switch, PC (Windows) 144000 Completed Nintendo
5 Anthem 10800 Abandoned EA app
6 Celeste 3600 Playing Epic
+3 -8
View File
@@ -1,5 +1,6 @@
import {apiFetch} from './api.js';
import {fetchMe, REQUEST_TIMEOUT_MS} from './settings.js';
import {storage} from './storage.js';
// Auth accepts only these names (AppearanceRequest); style.css has a preset for each.
export const ACCENTS = ['violet', 'blue', 'green', 'orange', 'pink', 'red'];
@@ -28,19 +29,13 @@ export const normalizeAppearance = (value) => ({
*/
export const readCachedAppearance = () => {
try {
return normalizeAppearance(JSON.parse(localStorage.getItem(STORAGE_KEY)));
return normalizeAppearance(JSON.parse(storage.get(STORAGE_KEY)));
} catch {
return DEFAULT_APPEARANCE;
}
};
export const cacheAppearance = (appearance) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(appearance));
} catch {
// Private mode or a full storage: the look is only not cached.
}
};
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
export const applyAppearance = (appearance, root = document.documentElement) => {
root.dataset.accent = appearance.accent;
+7 -10
View File
@@ -1,15 +1,15 @@
import {storage} from './storage.js';
// The tokens live in HttpOnly cookies that no script can read. This flag only remembers that a login happened; a
// stale flag costs one 401 and a failed refresh, then the login page opens.
export const SESSION_KEY = 'tbp_session';
// Where the token lived before the cookies. Removed on the next login or logout.
const LEGACY_TOKEN_KEY = 'tbp_jwt';
export const hasSession = () => localStorage.getItem(SESSION_KEY) !== null;
export const hasSession = () => storage.get(SESSION_KEY) !== null;
/**
* Changes on every login and on every settings save, so a hook that shows the user's Steam data can load again.
*/
export const sessionStamp = () => localStorage.getItem(SESSION_KEY);
export const sessionStamp = () => storage.get(SESSION_KEY);
const listeners = new Set();
// Why the session of this tab ended: null (never had one, or another tab ended it), 'logout' or 'expired'.
@@ -28,14 +28,12 @@ const onStorage = (event) => {
export const markSession = () => {
// New for every login, also two in the same millisecond: apiFetch tells sessions apart by it.
localStorage.setItem(SESSION_KEY, crypto.randomUUID());
localStorage.removeItem(LEGACY_TOKEN_KEY);
storage.set(SESSION_KEY, crypto.randomUUID());
change(null);
};
export const clearSession = () => {
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
storage.remove(SESSION_KEY);
change('logout');
};
@@ -46,8 +44,7 @@ export const clearSession = () => {
*/
export const expireSession = (stamp = sessionStamp()) => {
if (stamp === null || sessionStamp() !== stamp) return;
localStorage.removeItem(SESSION_KEY);
localStorage.removeItem(LEGACY_TOKEN_KEY);
storage.remove(SESSION_KEY);
change('expired');
};
-10
View File
@@ -15,16 +15,6 @@ describe('session flag', () => {
expect(hasSession()).toBe(false);
});
it('removes the token of the old localStorage login', () => {
localStorage.setItem('tbp_jwt', 'old');
markSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
localStorage.setItem('tbp_jwt', 'old');
clearSession();
expect(localStorage.getItem('tbp_jwt')).toBeNull();
});
it('gives a new stamp on every mark', async () => {
markSession();
const first = sessionStamp();
+8
View File
@@ -0,0 +1,8 @@
// A LocalDate from Core ("2026-09-27"), by default as a short day; noon, so no time zone moves it to the day before.
export const formatDay = (day, locale, options = {day: 'numeric', month: 'short'}) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, options);
// An instant from the backend (ISO string or epoch millis) as a date, or as date and time.
export const formatDate = (instant, locale, options) => new Date(instant).toLocaleDateString(locale, options);
export const formatDateTime = (instant, locale, options) => new Date(instant).toLocaleString(locale, options);
+9
View File
@@ -0,0 +1,9 @@
import {describe, expect, it} from 'vitest';
import {formatDay} from './format.js';
describe('formatDay', () => {
it('formats a Core day as a short day, or with the given options', () => {
expect(formatDay('2026-09-27', 'en-US')).toBe('Sep 27');
expect(formatDay('2026-09-27', 'en-US', {year: 'numeric', month: 'long'})).toBe('September 2026');
});
});
-4
View File
@@ -10,10 +10,6 @@ export const formatPlaytime = (minutes) => {
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
};
// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before.
export const formatDay = (day, locale) =>
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
// 460x215 store header. Not every app has one; callers need an onError fallback.
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
+94
View File
@@ -0,0 +1,94 @@
import {csvRows, importRow, readCsv} from './imports.js';
import {platformFromNames} from './platforms.js';
// Unverified: built from the public source of each exporter (see the README), not from a real file yet.
const FORMATS = [
{format: 'GALAXY', columns: ['title', 'platformlist'], row: galaxyRow},
{format: 'GROUVEE', columns: ['name', 'shelves', 'platforms', 'dates'], row: grouveeRow},
{format: 'HLTB', columns: ['title', 'platform', 'playing', 'backlog', 'completed'], row: hltbRow},
{format: 'PLAYNITE', columns: ['name', 'completion status'], row: playniteRow},
];
/**
* An export file of GOG Galaxy (community script), Grouvee, HowLongToBeat or Playnite, told apart by its header;
* any other file is a plain CSV.
*
* @return {format, rows} or {format, error} with an i18n key; a row the user never owned (wish list) is left out
*/
export const readImportFile = (text) => {
const csv = readCsv(text);
const {names, records} = csv;
const known = FORMATS.find(({columns}) => columns.every((column) => names.includes(column)));
if (!known) return {format: 'CSV', ...csvRows(csv)};
const field = (record) => (name) => (record[names.indexOf(name)] ?? '').trim();
const rows = records.map((record) => known.row(field(record))).filter((row) => row?.title).map(importRow);
return {format: known.format, rows};
};
const json = (text, fallback) => {
try {
return JSON.parse(text);
} catch {
return fallback;
}
};
// A cell with several stores is joined with the file's delimiter, or a comma.
function galaxyRow(field) {
return {
title: field('title'),
platform: platformFromNames(field('platformlist').split(/[\t,;]/)),
minutes: Number(field('playtime') || field('gamemins')) || 0,
};
}
// shelves and platforms are JSON objects keyed by name, dates a JSON array (or "None"); Played without a finish date
// may be anything, so it goes to the pile and the user fixes it in the review.
function grouveeRow(field) {
const shelves = Object.keys(json(field('shelves'), {}) ?? {});
if (shelves.length > 0 && shelves.every((shelf) => shelf === 'Wish List')) return null;
const dates = [json(field('dates'), [])].flat().filter(Boolean);
const finished = dates.some((date) => date.date_finished && date.date_finished !== 'None');
const status = shelves.includes('Playing') ? 'PLAYING'
: shelves.includes('Played') ? (finished ? 'FINISHED' : 'BACKLOG')
: shelves.includes('Backlog') ? 'BACKLOG' : null;
return {
title: field('name'),
platform: platformFromNames(Object.keys(json(field('platforms'), {}) ?? {})),
minutes: Math.max(0, ...dates.map((date) => Number(date.seconds_played) || 0)) / 60,
status,
};
}
// "85:30:00", or "--" for none.
const hoursMinutes = (text) => {
const match = /^(\d+):(\d{2})(?::(\d{2}))?$/.exec(text);
return match ? Number(match[1]) * 60 + Number(match[2]) : 0;
};
// A list column holds X for the lists the game is on.
function hltbRow(field) {
const on = (list) => field(list) !== '';
const status = on('completed') ? 'FINISHED' : on('retired') ? 'DROPPED' : on('playing') ? 'PLAYING'
: ['backlog', 'replay', 'stalled'].some(on) ? 'BACKLOG' : null;
if (!status && on('ignored')) return null;
const time = ['progress', 'main story', 'main + extras', 'completionist'].map((name) => hoursMinutes(field(name)))
.find((minutes) => minutes > 0);
return {title: field('title'), platform: platformFromNames([field('platform')]), minutes: time ?? 0, status};
}
// Playnite's default completion statuses; a user can rename them, then the status stays open.
const PLAYNITE_STATUS = {
'not played': 'BACKLOG', 'plan to play': 'BACKLOG', 'on hold': 'BACKLOG', played: 'BACKLOG',
playing: 'PLAYING', beaten: 'FINISHED', completed: 'COMPLETED', abandoned: 'DROPPED',
};
// Playtime in seconds.
function playniteRow(field) {
return {
title: field('name'),
platform: platformFromNames([...field('platforms').split(','), field('source')]),
minutes: (Number(field('playtime') || field('time played')) || 0) / 60,
status: PLAYNITE_STATUS[field('completion status').toLowerCase()],
};
}
+58
View File
@@ -0,0 +1,58 @@
import {readFileSync} from 'node:fs';
import {describe, expect, it} from 'vitest';
import {readImportFile} from './importFormats.js';
const fixture = (name) => readFileSync(new URL(`../test/fixtures/import/${name}`, import.meta.url), 'utf8');
describe('readImportFile', () => {
it('reads the GOG Galaxy export: tab separated, several stores in one cell, minutes', () => {
expect(readImportFile(fixture('galaxy.tsv'))).toEqual({format: 'GALAXY', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: 1500, status: null},
{title: 'Bloodborne', platform: null, playtimeMinutes: null, status: null},
{title: 'Hollow Knight', platform: 'Switch', playtimeMinutes: 2400, status: null},
]});
});
it('reads the Grouvee export: shelves as status, wish list left out, seconds', () => {
expect(readImportFile(fixture('grouvee.csv'))).toEqual({format: 'GROUVEE', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: 1500, status: 'FINISHED'},
{title: 'Celeste', platform: 'Switch', playtimeMinutes: null, status: 'PLAYING'},
{title: 'Outer Wilds', platform: null, playtimeMinutes: null, status: 'BACKLOG'},
{title: 'Disco Elysium', platform: 'PS5', playtimeMinutes: null, status: 'BACKLOG'},
]});
});
it('reads the HowLongToBeat export: list columns as status, ignored left out, HH:MM:SS', () => {
expect(readImportFile(fixture('hltb.csv'))).toEqual({format: 'HLTB', rows: [
{title: 'Elden Ring', platform: 'PS5', playtimeMinutes: 5130, status: 'FINISHED'},
{title: 'Cyberpunk 2077', platform: 'PC', playtimeMinutes: 735, status: 'PLAYING'},
{title: 'Anthem', platform: 'PC', playtimeMinutes: 180, status: 'DROPPED'},
{title: 'Hollow Knight', platform: 'Switch', playtimeMinutes: null, status: 'BACKLOG'},
]});
});
it('reads the Playnite export: completion status, first known platform, seconds', () => {
expect(readImportFile(fixture('playnite.csv'))).toEqual({format: 'PLAYNITE', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: 1500, status: 'FINISHED'},
{title: 'Bloodborne', platform: 'PS4', playtimeMinutes: null, status: 'BACKLOG'},
{title: 'Hollow Knight', platform: 'Switch', playtimeMinutes: 2400, status: 'COMPLETED'},
{title: 'Anthem', platform: 'PC', playtimeMinutes: 180, status: 'DROPPED'},
{title: 'Celeste', platform: 'PC', playtimeMinutes: 60, status: 'PLAYING'},
]});
});
it('reads any other file as a plain CSV', () => {
expect(readImportFile('title,platform\nHades,PC\n')).toEqual({format: 'CSV', rows: [
{title: 'Hades', platform: 'PC', playtimeMinutes: null, status: null},
]});
expect(readImportFile('foo\nbar\n')).toEqual({format: 'CSV', error: 'IMPORT.CSV_NO_TITLE'});
});
it('survives broken JSON in a Grouvee cell', () => {
const file = fixture('grouvee.csv').split('\n').slice(0, 1).join('\n')
+ '\n9,Broken,"{not json",x,,,,,x,[],{},{},{},{},{},,,,,\n';
expect(readImportFile(file).rows).toEqual([{title: 'Broken', platform: null, playtimeMinutes: null, status: null}]);
});
});
+33 -17
View File
@@ -45,32 +45,48 @@ const COLUMNS = {
*
* @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key
*/
export const parseCsv = (text) => {
const clean = text.replace(/^\uFEFF/, '');
const firstLine = clean.split(/\r?\n/, 1)[0];
const delimiter = [',', ';', '\t'].reduce((best, next) =>
firstLine.split(next).length > firstLine.split(best).length ? next : best);
const [header, ...records] = csvRecords(clean, delimiter);
const names = (header ?? []).map((name) => name.trim().toLowerCase());
export const parseCsv = (text) => csvRows(readCsv(text));
export const csvRows = ({names, records}) => {
const at = Object.fromEntries(Object.entries(COLUMNS)
.map(([field, headers]) => [field, names.findIndex((name) => headers.includes(name))]));
if (at.title < 0) return {error: 'IMPORT.CSV_NO_TITLE'};
const value = (record, field) => (at[field] >= 0 ? (record[at[field]] ?? '').trim() : '');
const rows = records.filter((record) => value(record, 'title')).map((record) => {
const platform = value(record, 'platform');
const minutes = at.minutes >= 0 ? Math.round(Number(value(record, 'minutes'))) || 0
: hoursToMinutes(value(record, 'hours'));
return {
title: value(record, 'title').slice(0, MAX_IMPORT_TITLE_LENGTH),
platform: platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_PLAYTIME_MINUTES) : null,
const rows = records.filter((record) => value(record, 'title')).map((record) => importRow({
title: value(record, 'title'),
platform: value(record, 'platform'),
minutes: at.minutes >= 0 ? Number(value(record, 'minutes')) : hoursToMinutes(value(record, 'hours')),
status: parseStatus(value(record, 'status').toUpperCase()),
};
});
}));
return {rows};
};
/**
* A row as Core takes it: within the limits, and null for what is unknown.
*/
export const importRow = ({title, platform, minutes, status}) => {
const playtime = Math.round(minutes);
return {
title: title.trim().slice(0, MAX_IMPORT_TITLE_LENGTH),
platform: typeof platform === 'string' && platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
playtimeMinutes: playtime > 0 ? Math.min(playtime, MAX_PLAYTIME_MINUTES) : null,
status: status ?? null,
};
};
/**
* @return {names, records}: the header names in lower case and the other records as arrays of fields
*/
export const readCsv = (text) => {
const clean = text.replace(/^\uFEFF/, '');
const firstLine = clean.split(/\r?\n/, 1)[0];
const delimiter = [',', ';', '\t'].reduce((best, next) =>
firstLine.split(next).length > firstLine.split(best).length ? next : best);
const [header, ...records] = csvRecords(clean, delimiter);
return {names: (header ?? []).map((name) => name.trim().toLowerCase()), records};
};
function csvRecords(text, delimiter) {
const records = [];
let record = [];
+4
View File
@@ -9,6 +9,10 @@ describe('parseTextList', () => {
});
describe('parseCsv', () => {
it('treats a playtime under half a minute as unknown', () => {
expect(parseCsv('title,minutes\nHades,0.4\n').rows[0].playtimeMinutes).toBeNull();
});
it('reads the known columns, quotes and hours', () => {
const {rows} = parseCsv('Name,Platform,Hours,Status\n'
+ '"Hades, the game",PS5,"1,5",playing\r\n'
+24
View File
@@ -17,6 +17,30 @@ export const FAVORITE_PLATFORMS = [
{value: 'Android', label: 'Android'},
];
// Platform and store names of other tools and store pages, in lower case, as the IGDB abbreviation Core stores. A
// store (Steam, GOG, ...) means PC; an account name like "PlayStation Network" says no console and is left out.
const PLATFORM_NAMES = {
...Object.fromEntries(FAVORITE_PLATFORMS.flatMap(({value, label}) =>
[[value.toLowerCase(), value], [label.toLowerCase(), value]])),
windows: 'PC', steam: 'PC', gog: 'PC', 'gog.com': 'PC', epic: 'PC',
'epic games': 'PC', 'epic games store': 'PC', origin: 'PC', ea: 'PC', 'ea app': 'PC', uplay: 'PC',
'ubisoft connect': 'PC', 'battle.net': 'PC', 'amazon games': 'PC', itch: 'PC', 'itch.io': 'PC',
'sony playstation 5': 'PS5', 'sony playstation 4': 'PS4', 'microsoft xbox one': 'XONE',
'microsoft xbox series': 'Series X|S', 'xbox series x/s': 'Series X|S', 'xbox series x': 'Series X|S',
macos: 'Mac', 'mac os': 'Mac',
};
/**
* @return the first of the names that is a known platform, as Core stores it, or null
*/
export const platformFromNames = (names) => {
for (const name of names) {
const platform = PLATFORM_NAMES[name.trim().toLowerCase()];
if (platform) return platform;
}
return null;
};
/**
* The platform to preselect for a game: the line's own platform (in the game's spelling when it has it), else the
* favorite when the game has it, else the only platform of the game, else '' and the user picks.
+4 -13
View File
@@ -1,4 +1,5 @@
import {apiFetch} from './api.js';
import {storage} from './storage.js';
export const LANGUAGES = ['en', 'de'];
@@ -6,23 +7,13 @@ const LANGUAGE_STORAGE_KEY = 'tbp_language';
export const REQUEST_TIMEOUT_MS = 10000;
export const readCachedLanguage = () => {
try {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
const stored = storage.get(LANGUAGE_STORAGE_KEY);
return LANGUAGES.includes(stored) ? stored : null;
} catch {
return null;
}
};
export const cacheLanguage = (language) => {
try {
localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
} catch {
// Private mode or a full storage: the language is only not cached.
}
};
export const cacheLanguage = (language) => storage.set(LANGUAGE_STORAGE_KEY, language);
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me?v=1`;
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me`;
/**
* GET /api/users/me on Auth. The caller handles the response status.
+24
View File
@@ -0,0 +1,24 @@
// localStorage throws when site data is blocked (and setItem when it is full). Then nothing is stored and a read is null.
export const storage = {
get(key) {
try {
return localStorage.getItem(key);
} catch {
return null;
}
},
set(key, value) {
try {
localStorage.setItem(key, value);
} catch {
// not stored
}
},
remove(key) {
try {
localStorage.removeItem(key);
} catch {
// nothing to remove from a blocked storage
}
},
};
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {storage} from './storage.js';
afterEach(() => {
vi.restoreAllMocks();
localStorage.clear();
});
const blockStorage = () => ['getItem', 'setItem', 'removeItem'].forEach((method) =>
vi.spyOn(Storage.prototype, method).mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
}));
describe('storage', () => {
it('reads, writes and removes a value', () => {
storage.set('key', 'value');
expect(storage.get('key')).toBe('value');
storage.remove('key');
expect(storage.get('key')).toBeNull();
});
it('does not throw when localStorage throws, and reads null', () => {
blockStorage();
expect(() => storage.set('key', 'value')).not.toThrow();
expect(() => storage.remove('key')).not.toThrow();
expect(storage.get('key')).toBeNull();
});
});
+108
View File
@@ -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;
}
};
+110
View File
@@ -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)]);
});
});