Public Access
feat(import): add the import page, multi-select add and the favorite platform
This commit is contained in:
1 parent
aa9e83e0cb
commit
14dd59f9e0
21 files changed
+1436
-48
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {parseStatus} from './backlog.js';
|
||||
import {hoursToMinutes, MAX_PLATFORM_LENGTH} from './manualGames.js';
|
||||
|
||||
const IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
|
||||
// Core's limits: lines per import, title length of the game search, playtime.
|
||||
export const MAX_IMPORT_ROWS = 1000;
|
||||
const MAX_TITLE = 100;
|
||||
const MAX_MINUTES = 6_000_000;
|
||||
|
||||
// The calls return the raw response; the caller handles the status.
|
||||
export const createImport = (source, rows) =>
|
||||
apiFetch(IMPORTS_URL(), {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({source, rows})});
|
||||
|
||||
export const listImports = () => apiFetch(IMPORTS_URL());
|
||||
|
||||
export const getImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}`);
|
||||
|
||||
export const saveImport = (id, rows) =>
|
||||
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows})});
|
||||
|
||||
export const retryImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/retry`, {method: 'POST'});
|
||||
|
||||
export const checkUndo = (id) => apiFetch(`${IMPORTS_URL()}/${id}/undo-check`);
|
||||
|
||||
export const undoImport = (id) => apiFetch(`${IMPORTS_URL()}/${id}/games`, {method: 'DELETE'});
|
||||
|
||||
/**
|
||||
* One title per line; empty lines are left out.
|
||||
*/
|
||||
export const parseTextList = (text) => text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
||||
.map((line) => ({title: line.slice(0, MAX_TITLE)}));
|
||||
|
||||
// Header names, lower case, per field.
|
||||
const COLUMNS = {
|
||||
title: ['title', 'name', 'game'],
|
||||
platform: ['platform'],
|
||||
hours: ['hours', 'playtime', 'playtime (hours)'],
|
||||
minutes: ['minutes', 'playtime (minutes)'],
|
||||
status: ['status'],
|
||||
};
|
||||
|
||||
/**
|
||||
* A CSV with a header row; the delimiter (comma, semicolon or tab) is the one the header has most of. A field that
|
||||
* starts with a double quote may hold the delimiter, line breaks and "" for a quote.
|
||||
*
|
||||
* @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(/^/, '');
|
||||
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());
|
||||
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_TITLE),
|
||||
platform: platform && platform.length <= MAX_PLATFORM_LENGTH ? platform : null,
|
||||
playtimeMinutes: minutes > 0 ? Math.min(minutes, MAX_MINUTES) : null,
|
||||
status: parseStatus(value(record, 'status').toUpperCase()),
|
||||
};
|
||||
});
|
||||
return {rows};
|
||||
};
|
||||
|
||||
function csvRecords(text, delimiter) {
|
||||
const records = [];
|
||||
let record = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (c === '"') {
|
||||
quoted = false;
|
||||
} else {
|
||||
field += c;
|
||||
}
|
||||
} else if (c === '"' && field === '') {
|
||||
// Only at the start of a field; a quote inside one (12" Barbarian) is a character.
|
||||
quoted = true;
|
||||
} else if (c === delimiter) {
|
||||
record.push(field);
|
||||
field = '';
|
||||
} else if (c === '\n' || c === '\r') {
|
||||
if (c === '\r' && text[i + 1] === '\n') i++;
|
||||
record.push(field);
|
||||
records.push(record);
|
||||
record = [];
|
||||
field = '';
|
||||
} else {
|
||||
field += c;
|
||||
}
|
||||
}
|
||||
if (field || record.length) {
|
||||
record.push(field);
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {parseCsv, parseTextList} from './imports.js';
|
||||
|
||||
describe('parseTextList', () => {
|
||||
it('takes one title per line and skips empty lines', () => {
|
||||
expect(parseTextList(' Hades \r\n\nCeleste\n')).toEqual([{title: 'Hades'}, {title: 'Celeste'}]);
|
||||
expect(parseTextList('x'.repeat(150))[0].title).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsv', () => {
|
||||
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'
|
||||
+ '"The ""Best"" Game",,,\n'
|
||||
+ ',PC,3,\n');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{title: 'Hades, the game', platform: 'PS5', playtimeMinutes: 90, status: 'PLAYING'},
|
||||
{title: 'The "Best" Game', platform: null, playtimeMinutes: null, status: null},
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the delimiter the header has most of, and minutes', () => {
|
||||
const {rows} = parseCsv('title;minutes;status\nCeleste;125;unknown\n');
|
||||
|
||||
expect(rows).toEqual([{title: 'Celeste', platform: null, playtimeMinutes: 125, status: null}]);
|
||||
});
|
||||
|
||||
it('keeps a line break inside quotes', () => {
|
||||
expect(parseCsv('game\n"Two\nLines"\n').rows[0].title).toBe('Two\nLines');
|
||||
});
|
||||
|
||||
it('keeps a quote inside an unquoted field as a character', () => {
|
||||
expect(parseCsv('title,platform\n12" Barbarian,PC\nHades,PS5\n').rows.map((row) => row.title))
|
||||
.toEqual(['12" Barbarian', 'Hades']);
|
||||
});
|
||||
|
||||
it('needs a title column', () => {
|
||||
expect(parseCsv('platform,hours\nPC,1\n')).toEqual({error: 'IMPORT.CSV_NO_TITLE'});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,12 @@ const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
// positive keys; Auth also takes only those as background or banner.
|
||||
export const isSteamKey = (appid) => Number(appid) > 0;
|
||||
|
||||
// Core's limit of a game's platform (ManualGameUpdate.MAX_PLATFORM_LENGTH).
|
||||
export const MAX_PLATFORM_LENGTH = 32;
|
||||
|
||||
// Two decimals: then hoursToMinutes gives back the same minute.
|
||||
export const toHours = (minutes) => (minutes > 0 ? String(Math.round((minutes / 60) * 100) / 100) : '');
|
||||
|
||||
export const hoursToMinutes = (hours) => {
|
||||
const value = Number(String(hours).replace(',', '.'));
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
const AUTH_URL = () => import.meta.env.VITE_API_URL;
|
||||
|
||||
// IGDB platform abbreviations (/v4/platforms, checked 2026-09-29), the way Core stores a game's platform.
|
||||
export const FAVORITE_PLATFORMS = [
|
||||
{value: 'PC', label: 'PC (Windows)'},
|
||||
{value: 'PS5', label: 'PlayStation 5'},
|
||||
{value: 'PS4', label: 'PlayStation 4'},
|
||||
{value: 'Series X|S', label: 'Xbox Series X|S'},
|
||||
{value: 'XONE', label: 'Xbox One'},
|
||||
{value: 'Switch', label: 'Nintendo Switch'},
|
||||
{value: 'Switch 2', label: 'Nintendo Switch 2'},
|
||||
{value: 'Mac', label: 'Mac'},
|
||||
{value: 'Linux', label: 'Linux'},
|
||||
{value: 'iOS', label: 'iOS'},
|
||||
{value: 'Android', label: 'Android'},
|
||||
];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const preselectPlatform = (own, platforms, favorite) => {
|
||||
const find = (name) => name && platforms.find((platform) => platform.toLowerCase() === name.toLowerCase());
|
||||
if (own) return find(own) ?? own;
|
||||
return find(favorite) ?? (platforms.length === 1 ? platforms[0] : '');
|
||||
};
|
||||
|
||||
// A Steam game has no stored platform.
|
||||
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
|
||||
|
||||
/**
|
||||
* The user's favorite platform from Auth; null when none is set or the load failed, so a caller can go on.
|
||||
*/
|
||||
export const loadFavoritePlatform = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${AUTH_URL()}/api/users/me?v=1`);
|
||||
return response.ok ? (await response.json()).settings?.favoritePlatform ?? null : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
|
||||
*/
|
||||
export const saveFavoritePlatform = (platform) =>
|
||||
apiFetch(`${AUTH_URL()}/api/users/settings/favorite-platform`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({platform}),
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {platformLabel, preselectPlatform} from './platforms.js';
|
||||
|
||||
describe('preselectPlatform', () => {
|
||||
it('takes the own platform first, in the game spelling', () => {
|
||||
expect(preselectPlatform('ps5', ['PC', 'PS5'], 'PC')).toBe('PS5');
|
||||
expect(preselectPlatform('Amiga', ['PC'], 'PC')).toBe('Amiga');
|
||||
});
|
||||
|
||||
it('then the favorite when the game has it, then the only platform', () => {
|
||||
expect(preselectPlatform(null, ['PC', 'Switch'], 'switch')).toBe('Switch');
|
||||
expect(preselectPlatform(null, ['PS4'], 'Switch')).toBe('PS4');
|
||||
expect(preselectPlatform(null, ['PC', 'PS4'], 'Switch')).toBe('');
|
||||
expect(preselectPlatform(null, ['PC', 'PS4'], null)).toBe('');
|
||||
expect(preselectPlatform(null, [], null)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps Switch and Switch 2 apart', () => {
|
||||
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch')).toBe('Switch');
|
||||
expect(preselectPlatform(null, ['Switch', 'Switch 2'], 'Switch 2')).toBe('Switch 2');
|
||||
expect(preselectPlatform(null, ['Switch', 'PC'], 'Switch 2')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('platformLabel', () => {
|
||||
it('shows Steam for a Steam game', () => {
|
||||
expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam');
|
||||
expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user