feat(import): read the exports of GOG Galaxy, Grouvee, HowLongToBeat and Playnite

This commit is contained in:
lobo committed 2026-09-30 15:59:05 +02:00
1 parent 89d0b29698
commit 8e31eafc06
14 files changed
+289 -26

No files matched your search

+9 -4
View File
@@ -7,7 +7,8 @@ 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';
export default function ImportInput({onCreated}) {
@@ -19,12 +20,13 @@ export default function ImportInput({onCreated}) {
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
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);
};
@@ -43,10 +45,13 @@ export default function ImportInput({onCreated}) {
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 && 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) => (
+20
View File
@@ -134,6 +134,26 @@ 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('polls while matching and then shows the review', async () => {
let calls = 0;
renderPage({
+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
+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}]);
});
});
+34 -18
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,
status: parseStatus(value(record, 'status').toUpperCase()),
};
});
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.