Files
ToBePlayed-Frontend/src/utils/imports.js
T
lobo 27c402fa34 feat(psn): replace PSN import button with sync review
Remove the PSN import tab; linking now starts a sync.
Show the PSN review link, last played date and PSN expiry notes.
PSN rows hide the hours field; unchecked titles you saw are dismissed,
unseen ones stay open.
2026-10-08 08:15:37 +02:00

165 lines
6.4 KiB
JavaScript

import {apiFetch} from './api.js';
import {parseStatus} from './backlog.js';
import {MAX_IMPORT_TITLE_LENGTH, MAX_PLATFORM_LENGTH, MAX_PLAYTIME_MINUTES} from './limits.js';
import {hoursToMinutes, toHours} from './manualGames.js';
import {preselectPlatform} from './platforms.js';
const IMPORTS_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/imports`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
// 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}`);
// shownLineNos: the lines the user saw; a PlayStation review keeps the others open (the sync may add lines meanwhile).
export const saveImport = (id, rows, shownLineNos) =>
apiFetch(`${IMPORTS_URL()}/${id}/save`, {method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({rows, shownLineNos})});
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_IMPORT_TITLE_LENGTH)}));
// 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) => 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) => 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 = [];
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;
}
// Statuses a worker still has: the page polls while it shows one.
export const RUNNING = ['QUEUED', 'MATCHING', 'SAVING'];
// The steps an import goes through, as shown to the user.
export const STEPS = ['QUEUED', 'MATCHING', 'REVIEW', 'SAVING', 'DETAILS', 'DONE'];
/**
* The step an import is in; a FAILED one is in the step it stopped in.
*/
export const stepOf = (data) => {
switch (data.status) {
case 'QUEUED': return 'QUEUED';
case 'MATCHING': return 'MATCHING';
case 'READY': return 'REVIEW';
case 'SAVING': return data.step === 'DETAILS' ? 'DETAILS' : 'SAVING';
case 'FAILED': return data.chosen > 0 ? 'SAVING' : 'MATCHING';
default: return 'DONE';
}
};
/**
* The first choice of a line: its best candidate with the preselected platform; checked only for a sure match (the
* same name, not in the library yet, a platform found).
*/
export const initialChoice = (row, favorite) => {
const candidate = row.candidates[0];
const platform = candidate ? preselectPlatform(row.platform, candidate.platforms, favorite) : '';
return {
candidates: row.candidates,
igdbId: candidate?.igdbId ?? null,
platform,
status: row.status ?? 'BACKLOG',
hours: toHours(row.playtimeMinutes),
checked: row.exact && !!candidate && !candidate.inLibrary && platform !== '',
};
};