218 lines
7.2 KiB
JavaScript
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 };
|
|
}
|