diff --git a/README.md b/README.md
index 44ad382..f21a76a 100644
--- a/README.md
+++ b/README.md
@@ -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
"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.
- **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.
diff --git a/public/i18n/de.json b/public/i18n/de.json
index 5c61f8f..901dd02 100644
--- a/public/i18n/de.json
+++ b/public/i18n/de.json
@@ -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,13 @@
"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}}"
},
"UNSAVED_CHANGES": {
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
diff --git a/public/i18n/en.json b/public/i18n/en.json
index fe67518..69b5e59 100644
--- a/public/i18n/en.json
+++ b/public/i18n/en.json
@@ -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,13 @@
"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}}"
},
"UNSAVED_CHANGES": {
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
diff --git a/src/components/import/ImportInput.jsx b/src/components/import/ImportInput.jsx
index ac46c52..8567890 100644
--- a/src/components/import/ImportInput.jsx
+++ b/src/components/import/ImportInput.jsx
@@ -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)}/>
) : (
-
readFile(e.target.files?.[0])}/>
{t('IMPORT.CSV_HINT')}
{csv?.error &&
{t(csv.error)}
}
+ {csv?.rows && csv.format !== 'CSV' && (
+
{t('IMPORT.FORMAT_READ', {format: t(`IMPORT.SOURCE_${csv.format}`)})}
+ )}
{csv?.rows?.length > 0 && (
{csv.rows.slice(0, 5).map((row, index) => (
diff --git a/src/pages/ImportPage.test.jsx b/src/pages/ImportPage.test.jsx
index 41251a9..8572686 100644
--- a/src/pages/ImportPage.test.jsx
+++ b/src/pages/ImportPage.test.jsx
@@ -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({
diff --git a/src/test/fixtures/import/galaxy.tsv b/src/test/fixtures/import/galaxy.tsv
new file mode 100644
index 0000000..e279185
--- /dev/null
+++ b/src/test/fixtures/import/galaxy.tsv
@@ -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
+
diff --git a/src/test/fixtures/import/grouvee.csv b/src/test/fixtures/import/grouvee.csv
new file mode 100644
index 0000000..209b39e
--- /dev/null
+++ b/src/test/fixtures/import/grouvee.csv
@@ -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,,,
diff --git a/src/test/fixtures/import/hltb.csv b/src/test/fixtures/import/hltb.csv
new file mode 100644
index 0000000..01e260b
--- /dev/null
+++ b/src/test/fixtures/import/hltb.csv
@@ -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"
diff --git a/src/test/fixtures/import/playnite.csv b/src/test/fixtures/import/playnite.csv
new file mode 100644
index 0000000..ca0e86d
--- /dev/null
+++ b/src/test/fixtures/import/playnite.csv
@@ -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"
diff --git a/src/utils/importFormats.js b/src/utils/importFormats.js
new file mode 100644
index 0000000..8a9b9fe
--- /dev/null
+++ b/src/utils/importFormats.js
@@ -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()],
+ };
+}
diff --git a/src/utils/importFormats.test.js b/src/utils/importFormats.test.js
new file mode 100644
index 0000000..1f2a695
--- /dev/null
+++ b/src/utils/importFormats.test.js
@@ -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}]);
+ });
+});
diff --git a/src/utils/imports.js b/src/utils/imports.js
index d9ba06f..9f53c18 100644
--- a/src/utils/imports.js
+++ b/src/utils/imports.js
@@ -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 = [];
diff --git a/src/utils/imports.test.js b/src/utils/imports.test.js
index 199b974..fcf1267 100644
--- a/src/utils/imports.test.js
+++ b/src/utils/imports.test.js
@@ -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'
diff --git a/src/utils/platforms.js b/src/utils/platforms.js
index 19ad4ea..d5c2ee4 100644
--- a/src/utils/platforms.js
+++ b/src/utils/platforms.js
@@ -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.