Add CSV imports and granular competition data cleanup
This commit is contained in:
158
src/app.js
158
src/app.js
@@ -19,6 +19,7 @@ import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFro
|
||||
import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js";
|
||||
import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js";
|
||||
import { renderTrainingView } from "./training_views.js";
|
||||
import { importDriversCsv, importCarsCsv } from "./csv_import.js";
|
||||
|
||||
|
||||
import { renderTimingView, renderJudgingView } from "./timing_views.js";
|
||||
@@ -1021,9 +1022,29 @@ const TRANSLATIONS = {
|
||||
"settings.import_directory": "Importera förare/klasser/bilar",
|
||||
"settings.export_drivers_csv": "Exportera förare CSV",
|
||||
"settings.export_cars_csv": "Exportera bilar CSV",
|
||||
"settings.import_drivers_csv": "Importera förare CSV",
|
||||
"settings.import_cars_csv": "Importera bilar CSV",
|
||||
"settings.import_csv_success": "{type}: {added} tillagda, {updated} uppdaterade och {skipped} hoppade rader.",
|
||||
"settings.csv_type_drivers": "Förare importerade",
|
||||
"settings.csv_type_cars": "Bilar importerade",
|
||||
"settings.storage_note_full": "Fullt datapaket innehåller klasser, förare, bilar, race/event, sessioner och resultat.",
|
||||
"settings.storage_note_directory": "Registerpaket innehåller bara klasser, förare och bilar och mergeas in i befintlig data.",
|
||||
"settings.storage_note_csv": "CSV-exporten är snabbast för registerlistor och enklare att öppna i Excel eller LibreOffice.",
|
||||
"settings.storage_note_csv": "CSV kan exporteras eller importeras från Excel och LibreOffice. Komma, semikolon och tabb stöds. Importen slår ihop på ID, transponder eller namn i stället för att radera registret.",
|
||||
"settings.clear_data_title": "Rensa data",
|
||||
"settings.clear_data_note": "Exportera gärna en backup först. Rensningen kan inte ångras, men system-, decoder-, tema- och brandinginställningar behålls. Råa passeringar i SQLite behålls som revisionslogg.",
|
||||
"settings.clear_drivers": "Rensa förare",
|
||||
"settings.clear_cars": "Rensa bilar",
|
||||
"settings.clear_classes": "Rensa klasser",
|
||||
"settings.clear_races": "Rensa race och sessioner",
|
||||
"settings.clear_training": "Rensa träningsstatistik",
|
||||
"settings.clear_all": "Rensa all tävlingsdata",
|
||||
"settings.clear_drivers_confirm": "Rensa alla förare, deras träningsdata och förarreferenser i race? Detta kan inte ångras.",
|
||||
"settings.clear_cars_confirm": "Rensa alla bilar och bilreferenser i race? Detta kan inte ångras.",
|
||||
"settings.clear_classes_confirm": "Rensa alla klasser och koppla loss dem från förare och race? Detta kan inte ångras.",
|
||||
"settings.clear_races_confirm": "Rensa alla event, race, sessioner och resultat? Befintliga träningsvarv arkiveras först. Detta kan inte ångras.",
|
||||
"settings.clear_training_confirm": "Rensa all träningsstatistik, mål, belöningar och publika profillänkar? Detta kan inte ångras.",
|
||||
"settings.clear_all_confirm": "Rensa alla klasser, förare, bilar, race, sessioner, resultat och all träningsstatistik? Systeminställningar behålls. Detta kan inte ångras.",
|
||||
"settings.clear_success": "Valda data har rensats.",
|
||||
"settings.import_full_success": "Race-data importerad och ersatte nuvarande tävlingsdata.",
|
||||
"settings.import_directory_success": "Klasser, förare och bilar importerades in i befintlig data.",
|
||||
"settings.import_failed": "Import misslyckades: {msg}",
|
||||
@@ -1288,7 +1309,8 @@ const TRANSLATIONS = {
|
||||
"guide.sqlite_2": "API: /api/state och /api/passings",
|
||||
"guide.sqlite_3": "I Inställningar -> Lagring kan du exportera hela racepaket för backup eller flytt till annan server/laptop.",
|
||||
"guide.sqlite_4": "Du kan också exportera eller importera bara klasser, förare och bilar om du vill börja om med race men behålla registret.",
|
||||
"guide.sqlite_5": "Exportera förare och bilar som CSV om du snabbt vill öppna registret i Excel eller skriva ut en enkel lista.",
|
||||
"guide.sqlite_5": "CSV-filer för förare och bilar kan både exporteras och importeras. Egna exporter bevarar ID:n; externa komma-, semikolon- eller tabbseparerade listor slås ihop på transponder eller namn och klass.",
|
||||
"guide.sqlite_6": "Rensa data kan tömma förare, bilar, klasser, race eller träningsstatistik separat. Rensa all tävlingsdata behåller decoder-, tema-, branding- och serverinställningar. Råa SQLite-passeringar behålls som revisionslogg. Exportera backup först.",
|
||||
"guide.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Synk misslyckades: {msg}",
|
||||
@@ -2075,9 +2097,29 @@ const TRANSLATIONS = {
|
||||
"settings.import_directory": "Import drivers/classes/cars",
|
||||
"settings.export_drivers_csv": "Export drivers CSV",
|
||||
"settings.export_cars_csv": "Export cars CSV",
|
||||
"settings.import_drivers_csv": "Import drivers CSV",
|
||||
"settings.import_cars_csv": "Import cars CSV",
|
||||
"settings.import_csv_success": "{type}: {added} added, {updated} updated, and {skipped} skipped rows.",
|
||||
"settings.csv_type_drivers": "Drivers imported",
|
||||
"settings.csv_type_cars": "Cars imported",
|
||||
"settings.storage_note_full": "Full data package contains classes, drivers, cars, races/events, sessions and results.",
|
||||
"settings.storage_note_directory": "Directory package only contains classes, drivers and cars and merges into existing data.",
|
||||
"settings.storage_note_csv": "CSV export is the quickest way to open the directory in Excel or LibreOffice.",
|
||||
"settings.storage_note_csv": "CSV can be exported or imported from Excel and LibreOffice. Comma, semicolon, and tab delimiters are supported. Import merges by ID, transponder, or name instead of replacing the directory.",
|
||||
"settings.clear_data_title": "Clear data",
|
||||
"settings.clear_data_note": "Export a backup first. Clearing cannot be undone, but system, decoder, theme, and branding settings are retained. Raw SQLite passings remain as an audit log.",
|
||||
"settings.clear_drivers": "Clear drivers",
|
||||
"settings.clear_cars": "Clear cars",
|
||||
"settings.clear_classes": "Clear classes",
|
||||
"settings.clear_races": "Clear races and sessions",
|
||||
"settings.clear_training": "Clear training statistics",
|
||||
"settings.clear_all": "Clear all competition data",
|
||||
"settings.clear_drivers_confirm": "Clear all drivers, their training data, and driver references in races? This cannot be undone.",
|
||||
"settings.clear_cars_confirm": "Clear all cars and car references in races? This cannot be undone.",
|
||||
"settings.clear_classes_confirm": "Clear all classes and unlink them from drivers and races? This cannot be undone.",
|
||||
"settings.clear_races_confirm": "Clear all events, races, sessions, and results? Existing training laps are archived first. This cannot be undone.",
|
||||
"settings.clear_training_confirm": "Clear all training statistics, goals, rewards, and public profile links? This cannot be undone.",
|
||||
"settings.clear_all_confirm": "Clear all classes, drivers, cars, races, sessions, results, and training statistics? System settings are retained. This cannot be undone.",
|
||||
"settings.clear_success": "The selected data has been cleared.",
|
||||
"settings.import_full_success": "Race data imported and replaced the current competition data.",
|
||||
"settings.import_directory_success": "Classes, drivers and cars were imported into the existing data.",
|
||||
"settings.import_failed": "Import failed: {msg}",
|
||||
@@ -2342,7 +2384,8 @@ const TRANSLATIONS = {
|
||||
"guide.sqlite_2": "API endpoints: /api/state and /api/passings",
|
||||
"guide.sqlite_3": "In Settings -> Storage you can export a full race package for backup or moving to another server or laptop.",
|
||||
"guide.sqlite_4": "You can also export or import only classes, drivers and cars if you want to reset race data but keep the directory.",
|
||||
"guide.sqlite_5": "Export drivers and cars as CSV if you want to open the directory quickly in Excel or print a simple list.",
|
||||
"guide.sqlite_5": "Driver and car CSV files can both be exported and imported. Native exports preserve IDs; external comma-, semicolon-, or tab-separated lists merge by transponder or by name and class.",
|
||||
"guide.sqlite_6": "Clear data can remove drivers, cars, classes, races, or training statistics separately. Clear all competition data retains decoder, theme, branding, and server settings. Raw SQLite passings remain as an audit log. Export a backup first.",
|
||||
"guide.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Sync failed: {msg}",
|
||||
@@ -3063,6 +3106,111 @@ function buildCarsCsvRows() {
|
||||
];
|
||||
}
|
||||
|
||||
function importDirectoryCsv(text, type) {
|
||||
try {
|
||||
if (type === "drivers") {
|
||||
const result = importDriversCsv(text, { classes: state.classes, drivers: state.drivers, uid });
|
||||
state.classes = result.classes.map((item) => normalizeImportedClass(item)).filter((item) => item.name);
|
||||
state.drivers = result.drivers.map((item) => normalizeDriver(item)).filter((item) => item.name);
|
||||
settingsStorageNotice = t("settings.import_csv_success", {
|
||||
type: t("settings.csv_type_drivers"),
|
||||
added: result.added,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
} else if (type === "cars") {
|
||||
const result = importCarsCsv(text, { cars: state.cars, uid });
|
||||
state.cars = result.cars.map((item) => normalizeCar(item)).filter((item) => item.name);
|
||||
settingsStorageNotice = t("settings.import_csv_success", {
|
||||
type: t("settings.csv_type_cars"),
|
||||
added: result.added,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Unsupported CSV import type");
|
||||
}
|
||||
settingsStorageNoticeIsError = false;
|
||||
saveState();
|
||||
} catch (error) {
|
||||
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
|
||||
settingsStorageNoticeIsError = true;
|
||||
}
|
||||
renderView();
|
||||
}
|
||||
|
||||
function clearDataScope(scope) {
|
||||
const clearDriverReferences = () => {
|
||||
state.events = state.events.map((event) => ({
|
||||
...event,
|
||||
raceConfig: {
|
||||
...(event.raceConfig || {}),
|
||||
driverIds: [],
|
||||
teams: (event.raceConfig?.teams || []).map((team) => ({ ...team, driverIds: [] })),
|
||||
},
|
||||
}));
|
||||
state.sessions = state.sessions.map((session) => ({
|
||||
...session,
|
||||
driverIds: [],
|
||||
manualGridIds: [],
|
||||
assignments: (session.assignments || []).map((assignment) => ({ ...assignment, driverId: "" })),
|
||||
}));
|
||||
};
|
||||
const clearCarReferences = () => {
|
||||
state.events = state.events.map((event) => ({
|
||||
...event,
|
||||
raceConfig: {
|
||||
...(event.raceConfig || {}),
|
||||
teams: (event.raceConfig?.teams || []).map((team) => ({ ...team, carIds: [] })),
|
||||
},
|
||||
}));
|
||||
state.sessions = state.sessions.map((session) => ({
|
||||
...session,
|
||||
assignments: (session.assignments || []).map((assignment) => ({ ...assignment, carId: "" })),
|
||||
}));
|
||||
};
|
||||
|
||||
if (scope === "drivers") {
|
||||
state.drivers = [];
|
||||
state.training = normalizeTrainingState({});
|
||||
state.resultsBySession = {};
|
||||
clearDriverReferences();
|
||||
} else if (scope === "cars") {
|
||||
state.cars = [];
|
||||
state.resultsBySession = {};
|
||||
clearCarReferences();
|
||||
} else if (scope === "classes") {
|
||||
state.classes = [];
|
||||
state.drivers = state.drivers.map((driver) => ({ ...driver, classId: "" }));
|
||||
state.events = state.events.map((event) => ({ ...event, classId: "" }));
|
||||
} else if (scope === "races") {
|
||||
syncTrainingLedger();
|
||||
state.events = [];
|
||||
state.sessions = [];
|
||||
state.resultsBySession = {};
|
||||
state.activeSessionId = null;
|
||||
} else if (scope === "training") {
|
||||
state.training = normalizeTrainingState({});
|
||||
} else if (scope === "all") {
|
||||
state.classes = [];
|
||||
state.drivers = [];
|
||||
state.cars = [];
|
||||
state.events = [];
|
||||
state.sessions = [];
|
||||
state.resultsBySession = {};
|
||||
state.training = normalizeTrainingState({});
|
||||
state.activeSessionId = null;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsStorageNotice = t("settings.clear_success");
|
||||
settingsStorageNoticeIsError = false;
|
||||
saveState();
|
||||
renderNav();
|
||||
renderView();
|
||||
}
|
||||
|
||||
function collectEventPackageReferences(event, sessions) {
|
||||
const driverIds = new Set(event.classId ? getDriversForClass(event.classId).map((driver) => driver.id) : []);
|
||||
const carIds = new Set();
|
||||
@@ -3342,6 +3490,8 @@ function renderView() {
|
||||
downloadCsvFile,
|
||||
buildDriversCsvRows,
|
||||
buildCarsCsvRows,
|
||||
importDirectoryCsv,
|
||||
clearDataScope,
|
||||
buildExportMeta,
|
||||
normalizeStoredRacePreset,
|
||||
saveAmmcConfigToBackend,
|
||||
|
||||
217
src/csv_import.js
Normal file
217
src/csv_import.js
Normal file
@@ -0,0 +1,217 @@
|
||||
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 };
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function renderGuideView({ dom, t }) {
|
||||
<li>${t("guide.sqlite_3")}</li>
|
||||
<li>${t("guide.sqlite_4")}</li>
|
||||
<li>${t("guide.sqlite_5")}</li>
|
||||
<li>${t("guide.sqlite_6")}</li>
|
||||
</ul>
|
||||
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,8 @@ export function renderSettings(deps) {
|
||||
downloadCsvFile,
|
||||
buildDriversCsvRows,
|
||||
buildCarsCsvRows,
|
||||
importDirectoryCsv,
|
||||
clearDataScope,
|
||||
buildExportMeta,
|
||||
normalizeStoredRacePreset,
|
||||
saveAmmcConfigToBackend,
|
||||
@@ -388,13 +390,32 @@ export function renderSettings(deps) {
|
||||
<p class="hint">${t("settings.storage_note_csv")}</p>
|
||||
<div class="actions">
|
||||
<button id="exportDriversCsv" class="btn" type="button">${t("settings.export_drivers_csv")}</button>
|
||||
<button id="selectDriversCsv" class="btn" type="button">${t("settings.import_drivers_csv")}</button>
|
||||
<input id="importDriversCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
|
||||
<button id="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
|
||||
<button id="selectCarsCsv" class="btn" type="button">${t("settings.import_cars_csv")}</button>
|
||||
<input id="importCarsCsv" type="file" accept=".csv,text/csv,text/plain" hidden />
|
||||
</div>
|
||||
</div>
|
||||
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16 settings-danger-panel">
|
||||
<div class="panel-header"><h3>${t("settings.clear_data_title")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${t("settings.clear_data_note")}</p>
|
||||
<div class="actions mt-16">
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="drivers">${t("settings.clear_drivers")}</button>
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="cars">${t("settings.clear_cars")}</button>
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="classes">${t("settings.clear_classes")}</button>
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="races">${t("settings.clear_races")}</button>
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="training">${t("settings.clear_training")}</button>
|
||||
<button class="btn btn-danger" type="button" data-clear-scope="all">${t("settings.clear_all")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("settings.race_presets")}</h3></div>
|
||||
<div class="panel-body">
|
||||
@@ -611,6 +632,30 @@ export function renderSettings(deps) {
|
||||
downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows());
|
||||
});
|
||||
|
||||
const bindCsvImport = (selectButtonId, inputId, type) => {
|
||||
const input = document.getElementById(inputId);
|
||||
document.getElementById(selectButtonId)?.addEventListener("click", () => input?.click());
|
||||
input?.addEventListener("change", (event) => {
|
||||
const fileInput = event.currentTarget;
|
||||
const file = fileInput instanceof HTMLInputElement ? fileInput.files?.[0] : null;
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => importDirectoryCsv(String(reader.result || ""), type);
|
||||
reader.onerror = () => importDirectoryCsv("", type);
|
||||
reader.readAsText(file);
|
||||
});
|
||||
};
|
||||
bindCsvImport("selectDriversCsv", "importDriversCsv", "drivers");
|
||||
bindCsvImport("selectCarsCsv", "importCarsCsv", "cars");
|
||||
|
||||
document.querySelectorAll("[data-clear-scope]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const scope = String(button.dataset.clearScope || "");
|
||||
if (!scope || !window.confirm(t(`settings.clear_${scope}_confirm`))) return;
|
||||
clearDataScope(scope);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("importDirectoryData")?.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
|
||||
Reference in New Issue
Block a user