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

+7
View File
@@ -42,6 +42,13 @@ 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 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 "search again", with the platform and status per line. Only sure matches are checked. Earlier imports can be
undone. 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.
- **Platform everywhere**: each game shows its platform ("Steam" for Steam games). The favorite platform from the - **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 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. several waits for the user's pick.
+8 -2
View File
@@ -641,7 +641,7 @@
"TEXT_LABEL": "Spieltitel", "TEXT_LABEL": "Spieltitel",
"TEXT_PLACEHOLDER": "Ein Spiel pro Zeile", "TEXT_PLACEHOLDER": "Ein Spiel pro Zeile",
"CSV_LABEL": "CSV-Datei", "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.", "CSV_NO_TITLE": "Die Datei hat keine Spalte title oder name.",
"LINES": "{{count}} Zeilen", "LINES": "{{count}} Zeilen",
"TOO_MANY": "{{count}} Zeilen: höchstens {{max}} pro Import.", "TOO_MANY": "{{count}} Zeilen: höchstens {{max}} pro Import.",
@@ -705,7 +705,13 @@
"FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.", "FAILED": "Der Import ist nach {{count}} Versuchen stehen geblieben.",
"RETRY": "Nochmal versuchen", "RETRY": "Nochmal versuchen",
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.", "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}}"
}, },
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?" "CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
+8 -2
View File
@@ -641,7 +641,7 @@
"TEXT_LABEL": "Game titles", "TEXT_LABEL": "Game titles",
"TEXT_PLACEHOLDER": "One game per line", "TEXT_PLACEHOLDER": "One game per line",
"CSV_LABEL": "CSV file", "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.", "CSV_NO_TITLE": "The file has no title or name column.",
"LINES": "{{count}} lines", "LINES": "{{count}} lines",
"TOO_MANY": "{{count}} lines: at most {{max}} per import.", "TOO_MANY": "{{count}} lines: at most {{max}} per import.",
@@ -705,7 +705,13 @@
"FAILED": "The import stopped after {{count}} tries.", "FAILED": "The import stopped after {{count}} tries.",
"RETRY": "Try again", "RETRY": "Try again",
"RETRY_FAILED": "Could not start the import 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}}"
}, },
"UNSAVED_CHANGES": { "UNSAVED_CHANGES": {
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?" "CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
+9 -4
View File
@@ -7,7 +7,8 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Textarea} from '@/components/ui/textarea'; import {Textarea} from '@/components/ui/textarea';
import useStartImport from '../../hooks/useStartImport.js'; 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 {MAX_IMPORT_ROWS} from '../../utils/limits.js';
export default function ImportInput({onCreated}) { export default function ImportInput({onCreated}) {
@@ -19,12 +20,13 @@ export default function ImportInput({onCreated}) {
const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? []; const rows = source === 'TEXT' ? parseTextList(text) : csv?.rows ?? [];
const tooMany = rows.length > MAX_IMPORT_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 startImport = useStartImport();
const start = async () => { const start = async () => {
setBusy(true); 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); setBusy(false);
if (id) onCreated(id); if (id) onCreated(id);
}; };
@@ -43,10 +45,13 @@ export default function ImportInput({onCreated}) {
onChange={(e) => setText(e.target.value)}/> onChange={(e) => setText(e.target.value)}/>
) : ( ) : (
<div className="space-y-2"> <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])}/> onChange={(e) => readFile(e.target.files?.[0])}/>
<p className="text-xs text-muted-foreground">{t('IMPORT.CSV_HINT')}</p> <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?.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 && ( {csv?.rows?.length > 0 && (
<ul className="text-sm text-muted-foreground"> <ul className="text-sm text-muted-foreground">
{csv.rows.slice(0, 5).map((row, index) => ( {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 () => { it('polls while matching and then shows the review', async () => {
let calls = 0; let calls = 0;
renderPage({ 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 * @return {rows} with title, platform, playtimeMinutes and status where the file has them, or {error} as an i18n key
*/ */
export const parseCsv = (text) => { export const parseCsv = (text) => csvRows(readCsv(text));
const clean = text.replace(/^\uFEFF/, '');
const firstLine = clean.split(/\r?\n/, 1)[0]; export const csvRows = ({names, records}) => {
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());
const at = Object.fromEntries(Object.entries(COLUMNS) const at = Object.fromEntries(Object.entries(COLUMNS)
.map(([field, headers]) => [field, names.findIndex((name) => headers.includes(name))])); .map(([field, headers]) => [field, names.findIndex((name) => headers.includes(name))]));
if (at.title < 0) return {error: 'IMPORT.CSV_NO_TITLE'}; if (at.title < 0) return {error: 'IMPORT.CSV_NO_TITLE'};
const value = (record, field) => (at[field] >= 0 ? (record[at[field]] ?? '').trim() : ''); const value = (record, field) => (at[field] >= 0 ? (record[at[field]] ?? '').trim() : '');
const rows = records.filter((record) => value(record, 'title')).map((record) => { const rows = records.filter((record) => value(record, 'title')).map((record) => importRow({
const platform = value(record, 'platform'); title: value(record, 'title'),
const minutes = at.minutes >= 0 ? Math.round(Number(value(record, 'minutes'))) || 0 platform: value(record, 'platform'),
: hoursToMinutes(value(record, 'hours')); minutes: at.minutes >= 0 ? Number(value(record, 'minutes')) : hoursToMinutes(value(record, 'hours')),
return { status: parseStatus(value(record, 'status').toUpperCase()),
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()),
};
});
return {rows}; 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) { function csvRecords(text, delimiter) {
const records = []; const records = [];
let record = []; let record = [];
+4
View File
@@ -9,6 +9,10 @@ describe('parseTextList', () => {
}); });
describe('parseCsv', () => { 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', () => { it('reads the known columns, quotes and hours', () => {
const {rows} = parseCsv('Name,Platform,Hours,Status\n' const {rows} = parseCsv('Name,Platform,Hours,Status\n'
+ '"Hades, the game",PS5,"1,5",playing\r\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'}, {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 * 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. * favorite when the game has it, else the only platform of the game, else '' and the user picks.