Public Access
feat(import): read the exports of GOG Galaxy, Grouvee, HowLongToBeat and Playnite
This commit is contained in:
1 parent
89d0b29698
commit
8e31eafc06
14 files changed
+289
-26
No files matched your search
@@ -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
@@ -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
@@ -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?"
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Vendored
+5
@@ -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
|
||||||
|
|
||||||
|
Vendored
+6
@@ -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,,,
|
||||||
|
Vendored
+6
@@ -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"
|
||||||
|
+6
@@ -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"
|
||||||
|
@@ -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()],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -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 = [];
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user