Files
Live_RC/src/csv_import.js

218 lines
7.2 KiB
JavaScript

const DRIVER_COLUMNS = {
id: ["id", "driver_id", "forar_id"],
name: ["name", "namn", "driver", "driver_name", "forare", "forarnamn"],
className: ["class", "klass", "class_name", "klassnamn", "class_id", "klass_id"],
brand: ["brand", "marke", "team", "sponsor", "model", "modell"],
transponder: ["transponder", "transponder_id", "transpondernummer", "ponder"],
speechName: ["spoken_name", "speech_name", "talnamn", "upplasningsnamn"],
club: ["club", "klubb"],
country: ["country", "land", "nation"],
profileId: ["profile_id", "training_profile_id", "profil_id", "traningsprofil_id"],
};
const CAR_COLUMNS = {
id: ["id", "car_id", "bil_id"],
name: ["name", "namn", "car", "car_name", "bil", "bilnamn"],
brand: ["brand", "marke", "model", "modell", "chassis", "chassi"],
transponder: ["transponder", "transponder_id", "transpondernummer", "ponder"],
};
function normalizeHeader(value) {
return String(value || "")
.replace(/^\uFEFF/, "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function normalizeMatch(value) {
return String(value || "").trim().toLocaleLowerCase().replace(/\s+/g, " ");
}
function countDelimiter(line, delimiter) {
let quoted = false;
let count = 0;
for (let index = 0; index < line.length; index += 1) {
if (line[index] === '"') {
if (quoted && line[index + 1] === '"') index += 1;
else quoted = !quoted;
} else if (!quoted && line[index] === delimiter) {
count += 1;
}
}
return count;
}
function detectDelimiter(text) {
const firstLine = String(text || "").split(/\r?\n/).find((line) => line.trim()) || "";
return [",", ";", "\t"]
.map((delimiter) => ({ delimiter, count: countDelimiter(firstLine, delimiter) }))
.sort((left, right) => right.count - left.count)[0]?.delimiter || ",";
}
export function parseCsvRows(text) {
const source = String(text || "");
if (!source.trim()) throw new Error("CSV file is empty");
const delimiter = detectDelimiter(source);
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index];
if (quoted) {
if (char === '"' && source[index + 1] === '"') {
field += '"';
index += 1;
} else if (char === '"') {
quoted = false;
} else {
field += char;
}
} else if (char === '"') {
quoted = true;
} else if (char === delimiter) {
row.push(field.trim());
field = "";
} else if (char === "\n" || char === "\r") {
if (char === "\r" && source[index + 1] === "\n") index += 1;
row.push(field.trim());
if (row.some((value) => value)) rows.push(row);
row = [];
field = "";
} else {
field += char;
}
}
if (quoted) throw new Error("CSV file has an unclosed quoted field");
row.push(field.trim());
if (row.some((value) => value)) rows.push(row);
if (rows.length < 2) throw new Error("CSV file has no data rows");
const headers = rows[0].map(normalizeHeader);
if (!headers.some(Boolean)) throw new Error("CSV file has no header row");
return rows.slice(1).map((values, rowIndex) => ({
rowNumber: rowIndex + 2,
values: Object.fromEntries(headers.map((header, index) => [header, String(values[index] || "").trim()])),
}));
}
function readColumn(record, aliases) {
for (const alias of aliases) {
const value = record[normalizeHeader(alias)];
if (value != null && String(value).trim()) return String(value).trim();
}
return "";
}
function readMappedRecord(record, columns) {
return Object.fromEntries(Object.entries(columns).map(([key, aliases]) => [key, readColumn(record, aliases)]));
}
function mergeNonEmpty(current, incoming, fields) {
const next = { ...current };
fields.forEach((field) => {
if (String(incoming[field] ?? "").trim()) next[field] = incoming[field];
});
return next;
}
function resolveClass(classValue, classes, uid) {
const value = String(classValue || "").trim();
if (!value) return "";
const existing = classes.find((item) => String(item.id || "") === value || normalizeMatch(item.name) === normalizeMatch(value));
if (existing) return existing.id;
const created = { id: uid("class"), name: value };
classes.push(created);
return created.id;
}
export function importDriversCsv(text, context) {
const classes = (context.classes || []).map((item) => ({ ...item }));
const drivers = (context.drivers || []).map((item) => ({ ...item }));
const uid = context.uid;
const parsedRows = parseCsvRows(text);
let added = 0;
let updated = 0;
let skipped = 0;
parsedRows.forEach(({ values }) => {
const row = readMappedRecord(values, DRIVER_COLUMNS);
if (!row.name) {
skipped += 1;
return;
}
const classId = resolveClass(row.className, classes, uid);
const normalizedName = normalizeMatch(row.name);
const byNameAndClass = drivers.filter((item) => (
normalizeMatch(item.name) === normalizedName && (!classId || String(item.classId || "") === classId)
));
const existing = drivers.find((item) => row.id && String(item.id || "") === row.id)
|| drivers.find((item) => row.transponder && String(item.transponder || "") === row.transponder)
|| (byNameAndClass.length === 1 ? byNameAndClass[0] : null);
const samePerson = drivers.find((item) => normalizeMatch(item.name) === normalizedName && item.profileId);
const incoming = {
id: row.id || uid("driver"),
name: row.name,
classId,
brand: row.brand,
transponder: row.transponder,
speechName: row.speechName,
club: row.club,
country: row.country,
profileId: row.profileId || samePerson?.profileId || uid("profile"),
};
if (existing) {
const index = drivers.indexOf(existing);
drivers[index] = mergeNonEmpty(existing, incoming, ["name", "classId", "brand", "transponder", "speechName", "club", "country", "profileId"]);
updated += 1;
} else {
drivers.push(incoming);
added += 1;
}
});
return { classes, drivers, added, updated, skipped };
}
export function importCarsCsv(text, context) {
const cars = (context.cars || []).map((item) => ({ ...item }));
const uid = context.uid;
const parsedRows = parseCsvRows(text);
let added = 0;
let updated = 0;
let skipped = 0;
parsedRows.forEach(({ values }) => {
const row = readMappedRecord(values, CAR_COLUMNS);
if (!row.name) {
skipped += 1;
return;
}
const matchingNames = cars.filter((item) => normalizeMatch(item.name) === normalizeMatch(row.name));
const existing = cars.find((item) => row.id && String(item.id || "") === row.id)
|| cars.find((item) => row.transponder && String(item.transponder || "") === row.transponder)
|| (matchingNames.length === 1 ? matchingNames[0] : null);
const incoming = {
id: row.id || uid("car"),
name: row.name,
brand: row.brand,
transponder: row.transponder,
};
if (existing) {
const index = cars.indexOf(existing);
cars[index] = mergeNonEmpty(existing, incoming, ["name", "brand", "transponder"]);
updated += 1;
} else {
cars.push(incoming);
added += 1;
}
});
return { cars, added, updated, skipped };
}