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

+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.