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 !== '', }; };