Add data export/import, CSV exports and race packages
This commit is contained in:
12
README.md
12
README.md
@@ -37,6 +37,8 @@ JMK RB RaceController is an RC timing and race-control system with support for s
|
||||
- Storage/import tools:
|
||||
- full race-data export/import for backup or moving to another machine
|
||||
- directory export/import for only classes, drivers and cars
|
||||
- CSV export for drivers and cars when you want simple spreadsheet lists
|
||||
- single race-package export/import from `Race Setup -> Manage -> Race Actions`
|
||||
- Race Setup includes:
|
||||
- grouped `Participants` and `Teams` sections in `Manage`
|
||||
- a four-step `Create Race Wizard` for new races
|
||||
@@ -156,6 +158,16 @@ JMK RB RaceController is an RC timing and race-control system with support for s
|
||||
- `Generation` is kept separate so actions like qualifying generation, reseeding, finals generation, and bump-up do not clutter the format form.
|
||||
- `Live / results` is where grid, standings, print, PDF, and finals matrix are used once the structure is ready.
|
||||
|
||||
## Export / Import
|
||||
- `Settings -> Storage` now supports:
|
||||
- full JSON export/import for the complete competition database
|
||||
- directory JSON export/import for only classes, drivers, and cars
|
||||
- CSV export for drivers and cars
|
||||
- `Race Setup -> Manage -> Race Actions` now supports single race packages:
|
||||
- export one race with its sessions, results, and referenced classes/drivers/cars
|
||||
- import that package into another installation as a new race entry
|
||||
- JSON export files now include export metadata and a schema version field so future imports can be handled more safely.
|
||||
|
||||
## Race features
|
||||
|
||||
### Follow-up time
|
||||
|
||||
12
README.sv.md
12
README.sv.md
@@ -35,6 +35,8 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- Lagring/import:
|
||||
- full export/import av race-data för backup eller flytt till annan maskin
|
||||
- register-export/import för bara klasser, förare och bilar
|
||||
- CSV-export för förare och bilar när du vill ha enkla listor i kalkylark
|
||||
- export/import av ett enskilt racepaket via `Race Setup -> Hantera -> Race actions`
|
||||
- `Race Setup` innehåller nu även:
|
||||
- grupperade sektioner för `Deltagare` och `Lag` i `Hantera`
|
||||
- en fyrstegs `Create Race Wizard` för nya race
|
||||
@@ -145,6 +147,16 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- `Generering` ligger separat så knappar för kval, reseeding, finaler och bump-up inte blandas ihop med raceformatet.
|
||||
- `Live / resultat` är där grid, standings, print, PDF och finalmatris används när upplägget väl är klart.
|
||||
|
||||
## Export / Import
|
||||
- `Inställningar -> Lagring` stöder nu:
|
||||
- full JSON-export/import för hela tävlingsdatabasen
|
||||
- register-JSON för bara klasser, förare och bilar
|
||||
- CSV-export för förare och bilar
|
||||
- `Race Setup -> Hantera -> Race actions` stöder nu enskilda racepaket:
|
||||
- exportera ett race med dess sessioner, resultat och refererade klasser/förare/bilar
|
||||
- importera paketet på en annan installation som ett nytt race
|
||||
- JSON-exporter innehåller nu även exportmetadata och ett schema-/versionsfält för säkrare import framåt.
|
||||
|
||||
## Nya racefunktioner
|
||||
|
||||
### Follow-up time
|
||||
|
||||
234
src/app.js
234
src/app.js
@@ -17,6 +17,7 @@ const STORAGE_KEY = "rc_timing_control_v1";
|
||||
const DEFAULT_LANGUAGE = "sv";
|
||||
const DEFAULT_THEME = "dark";
|
||||
const AVAILABLE_THEMES = ["dark", "nord", "light"];
|
||||
const EXPORT_SCHEMA_VERSION = 1;
|
||||
|
||||
const TRANSLATIONS = {
|
||||
sv: {
|
||||
@@ -203,6 +204,9 @@ const TRANSLATIONS = {
|
||||
"events.race_summary_generation": "Generering",
|
||||
"events.race_actions_title": "Race actions",
|
||||
"events.race_actions_hint": "Generering och reseeding ligger separat från formatinställningarna så setupen blir lättare att läsa.",
|
||||
"events.export_race_package": "Exportera racepaket",
|
||||
"events.import_race_package": "Importera racepaket",
|
||||
"events.race_package_hint": "Exportera eller importera ett enskilt race med dess sessioner, resultat och refererade registerdata.",
|
||||
"events.context_standard_title": "Klubbrace-läge",
|
||||
"events.context_standard_hint": "Börja med preset och Grundläge. När deltagare och tider ser rätt ut använder du Race actions för kval, reseeding och finaler.",
|
||||
"events.context_endurance_title": "Endurance-läge",
|
||||
@@ -234,6 +238,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_wizard_4": "4. Steg 3 väljer vilka sessioner som ska skapas direkt: practice, kval och/eller team race beroende på preset.",
|
||||
"guide.race_wizard_5": "5. Wizarden skapar de första sessionerna automatiskt. Finaler skapas senare från Race actions när kval eller practice är klara.",
|
||||
"guide.race_wizard_6": "6. Efter skapandet går du vidare till Hantera för raceformat, generering, grid, standings och utskrift.",
|
||||
"guide.race_wizard_7": "7. I Hantera -> Race actions kan du exportera eller importera ett enskilt racepaket om du vill flytta just det racet till en annan installation.",
|
||||
"guide.manage_steps_title": "Hantera race i fyra steg",
|
||||
"guide.manage_steps_1": "1. Setup: välj racedeltagare och bygg eventuella lag för team race eller endurance.",
|
||||
"guide.manage_steps_2": "2. Format: justera Race Format i Grundläge eller Avancerat. Practice/kval, finaler och validering ligger i egna block.",
|
||||
@@ -555,8 +560,11 @@ const TRANSLATIONS = {
|
||||
"settings.import_all_data": "Importera all race-data",
|
||||
"settings.export_directory": "Exportera förare/klasser/bilar",
|
||||
"settings.import_directory": "Importera förare/klasser/bilar",
|
||||
"settings.export_drivers_csv": "Exportera förare CSV",
|
||||
"settings.export_cars_csv": "Exportera bilar CSV",
|
||||
"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.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}",
|
||||
@@ -754,6 +762,7 @@ 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.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Synk misslyckades: {msg}",
|
||||
@@ -952,6 +961,9 @@ const TRANSLATIONS = {
|
||||
"events.race_summary_generation": "Generation",
|
||||
"events.race_actions_title": "Race actions",
|
||||
"events.race_actions_hint": "Generation and reseeding live separately from the format fields so setup is easier to read.",
|
||||
"events.export_race_package": "Export race package",
|
||||
"events.import_race_package": "Import race package",
|
||||
"events.race_package_hint": "Export or import a single race with its sessions, results, and referenced directory data.",
|
||||
"events.context_standard_title": "Club race mode",
|
||||
"events.context_standard_hint": "Start with a preset and Basic mode. When participants and timings look right, use Race Actions for qualifying, reseeding and finals.",
|
||||
"events.context_endurance_title": "Endurance mode",
|
||||
@@ -983,6 +995,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_wizard_4": "4. Step 3 chooses which sessions should be created immediately: practice, qualifying and/or team race depending on the preset.",
|
||||
"guide.race_wizard_5": "5. The wizard creates the initial sessions automatically. Finals are generated later from Race Actions after qualifying or practice is complete.",
|
||||
"guide.race_wizard_6": "6. After creation, continue in Manage for race format, generation, grid, standings and print/export.",
|
||||
"guide.race_wizard_7": "7. In Manage -> Race Actions you can export or import a single race package when you want to move just that race to another installation.",
|
||||
"guide.manage_steps_title": "Manage Race In Four Steps",
|
||||
"guide.manage_steps_1": "1. Setup: choose race participants and build any teams for team race or endurance.",
|
||||
"guide.manage_steps_2": "2. Format: adjust Race Format in Basic or Advanced mode. Practice/qualifying, finals and validation are split into separate blocks.",
|
||||
@@ -1304,8 +1317,11 @@ const TRANSLATIONS = {
|
||||
"settings.import_all_data": "Import all race data",
|
||||
"settings.export_directory": "Export drivers/classes/cars",
|
||||
"settings.import_directory": "Import drivers/classes/cars",
|
||||
"settings.export_drivers_csv": "Export drivers CSV",
|
||||
"settings.export_cars_csv": "Export cars CSV",
|
||||
"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.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}",
|
||||
@@ -1503,6 +1519,7 @@ 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.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Sync failed: {msg}",
|
||||
@@ -1885,24 +1902,123 @@ function normalizeImportedClass(classItem) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportMeta(exportType) {
|
||||
return {
|
||||
app: "JMK RB RaceController",
|
||||
schemaVersion: EXPORT_SCHEMA_VERSION,
|
||||
exportType,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilenameSegment(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]+/gi, "_")
|
||||
.replace(/^_+|_+$/g, "") || "export";
|
||||
}
|
||||
|
||||
function downloadBlobFile(filename, blob) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function downloadJsonFile(filename, payload) {
|
||||
downloadBlobFile(filename, new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }));
|
||||
}
|
||||
|
||||
function rowToCsv(row) {
|
||||
return row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(",");
|
||||
}
|
||||
|
||||
function downloadCsvFile(filename, rows) {
|
||||
const csv = rows.map((row) => rowToCsv(row)).join("\n");
|
||||
downloadBlobFile(filename, new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
||||
}
|
||||
|
||||
function buildDataExportPayload() {
|
||||
return {
|
||||
...buildExportMeta("full_data"),
|
||||
classes: state.classes,
|
||||
drivers: state.drivers,
|
||||
cars: state.cars,
|
||||
events: state.events,
|
||||
sessions: state.sessions,
|
||||
resultsBySession: state.resultsBySession,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectoryExportPayload() {
|
||||
return {
|
||||
...buildExportMeta("directory"),
|
||||
classes: state.classes,
|
||||
drivers: state.drivers,
|
||||
cars: state.cars,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDriversCsvRows() {
|
||||
return [
|
||||
["id", "name", "class", "brand", "transponder"],
|
||||
...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || ""]),
|
||||
];
|
||||
}
|
||||
|
||||
function buildCarsCsvRows() {
|
||||
return [
|
||||
["id", "name", "brand", "transponder"],
|
||||
...state.cars.map((car) => [car.id, car.name, car.brand || "", car.transponder || ""]),
|
||||
];
|
||||
}
|
||||
|
||||
function collectEventPackageReferences(event, sessions) {
|
||||
const driverIds = new Set(event.classId ? getDriversForClass(event.classId).map((driver) => driver.id) : []);
|
||||
const carIds = new Set();
|
||||
(event?.raceConfig?.driverIds || []).forEach((driverId) => driverIds.add(driverId));
|
||||
(event?.raceConfig?.teams || []).forEach((team) => {
|
||||
(team.driverIds || []).forEach((driverId) => driverIds.add(driverId));
|
||||
(team.carIds || []).forEach((carId) => carIds.add(carId));
|
||||
});
|
||||
(sessions || []).forEach((session) => {
|
||||
(session.driverIds || []).forEach((driverId) => driverIds.add(driverId));
|
||||
(session.manualGridIds || []).forEach((driverId) => driverIds.add(driverId));
|
||||
(session.assignments || []).forEach((assignment) => {
|
||||
if (assignment.driverId) driverIds.add(assignment.driverId);
|
||||
if (assignment.carId) carIds.add(assignment.carId);
|
||||
});
|
||||
});
|
||||
return {
|
||||
classes: state.classes.filter((item) => item.id === event.classId),
|
||||
drivers: state.drivers.filter((driver) => driverIds.has(driver.id)),
|
||||
cars: state.cars.filter((car) => carIds.has(car.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRacePackagePayload(eventId) {
|
||||
const event = state.events.find((item) => item.id === eventId);
|
||||
if (!event) {
|
||||
throw new Error("Event not found");
|
||||
}
|
||||
const sessions = getSessionsForEvent(eventId);
|
||||
const refs = collectEventPackageReferences(event, sessions);
|
||||
const resultsBySession = {};
|
||||
sessions.forEach((session) => {
|
||||
if (state.resultsBySession[session.id]) {
|
||||
resultsBySession[session.id] = state.resultsBySession[session.id];
|
||||
}
|
||||
});
|
||||
return {
|
||||
...buildExportMeta("race_package"),
|
||||
classes: refs.classes,
|
||||
drivers: refs.drivers,
|
||||
cars: refs.cars,
|
||||
event,
|
||||
sessions,
|
||||
resultsBySession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1916,6 +2032,55 @@ function mergeCollectionById(currentItems, incomingItems, normalizer) {
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
function importRacePackagePayload(parsed) {
|
||||
const importedEvent = parsed?.event ? normalizeEvent(parsed.event) : null;
|
||||
const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : [];
|
||||
const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : [];
|
||||
const incomingCars = Array.isArray(parsed?.cars) ? parsed.cars : [];
|
||||
const incomingSessions = Array.isArray(parsed?.sessions) ? parsed.sessions.map((item) => normalizeSession(item)) : [];
|
||||
if (!importedEvent || !incomingSessions.length) {
|
||||
throw new Error("No race package found");
|
||||
}
|
||||
|
||||
state.classes = mergeCollectionById(state.classes, incomingClasses, normalizeImportedClass).filter((item) => item.name);
|
||||
state.drivers = mergeCollectionById(state.drivers, incomingDrivers, normalizeDriver).filter((driver) => driver.name);
|
||||
state.cars = mergeCollectionById(state.cars, incomingCars, normalizeCar).filter((car) => car.name);
|
||||
|
||||
const newEventId = uid("event");
|
||||
const sessionIdMap = new Map();
|
||||
const clonedEvent = normalizeEvent({
|
||||
...importedEvent,
|
||||
id: newEventId,
|
||||
});
|
||||
const clonedSessions = incomingSessions.map((session) => {
|
||||
const newSessionId = uid("session");
|
||||
sessionIdMap.set(session.id, newSessionId);
|
||||
return normalizeSession({
|
||||
...session,
|
||||
id: newSessionId,
|
||||
eventId: newEventId,
|
||||
status: "ready",
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
finishedByTimer: false,
|
||||
});
|
||||
});
|
||||
|
||||
const importedResults = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {};
|
||||
Object.entries(importedResults).forEach(([oldSessionId, value]) => {
|
||||
const newSessionId = sessionIdMap.get(oldSessionId);
|
||||
if (newSessionId) {
|
||||
state.resultsBySession[newSessionId] = value;
|
||||
}
|
||||
});
|
||||
|
||||
state.events.push(clonedEvent);
|
||||
state.sessions.push(...clonedSessions);
|
||||
saveState();
|
||||
renderView();
|
||||
renderEventManager(newEventId);
|
||||
}
|
||||
|
||||
function importDataPayload(parsed, mode = "full") {
|
||||
const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : [];
|
||||
const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : [];
|
||||
@@ -4492,6 +4657,11 @@ function renderEventManager(eventId) {
|
||||
<button id="clearGeneratedFinals" class="btn btn-danger" type="button">${t("events.clear_generated_finals")}</button>
|
||||
<button id="applyBumps" class="btn" type="button">${t("events.apply_bumps")}</button>
|
||||
</div>
|
||||
<div class="actions mt-16">
|
||||
<button id="exportRacePackage" class="btn" type="button">${t("events.export_race_package")}</button>
|
||||
<input id="importRacePackage" type="file" accept="application/json" aria-label="${t("events.import_race_package")}" />
|
||||
</div>
|
||||
<p class="hint">${t("events.race_package_hint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -5178,6 +5348,29 @@ function renderEventManager(eventId) {
|
||||
alert(t(applied > 0 ? "events.bumps_applied" : "events.no_bumps_applied"));
|
||||
});
|
||||
|
||||
document.getElementById("exportRacePackage")?.addEventListener("click", () => {
|
||||
const payload = buildRacePackagePayload(eventId);
|
||||
downloadJsonFile(`${sanitizeFilenameSegment(event.name)}_race_package.json`, payload);
|
||||
});
|
||||
|
||||
document.getElementById("importRacePackage")?.addEventListener("change", (importEvent) => {
|
||||
const input = importEvent.currentTarget;
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result || "{}"));
|
||||
importRacePackagePayload(parsed);
|
||||
} catch (error) {
|
||||
alert(t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) }));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
document.getElementById("printStartlists")?.addEventListener("click", () => {
|
||||
openPrintWindow(`${event.name} - ${t("events.start_lists")}`, buildRaceStartListsHtml(event));
|
||||
});
|
||||
@@ -6003,7 +6196,7 @@ function renderGuide() {
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"])}
|
||||
${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6"])}
|
||||
${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"])}
|
||||
</div>
|
||||
|
||||
<div class="guide-section-grid mt-16">
|
||||
@@ -6044,6 +6237,7 @@ function renderGuide() {
|
||||
<li>${t("guide.sqlite_2")}</li>
|
||||
<li>${t("guide.sqlite_3")}</li>
|
||||
<li>${t("guide.sqlite_4")}</li>
|
||||
<li>${t("guide.sqlite_5")}</li>
|
||||
</ul>
|
||||
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
|
||||
</div>
|
||||
@@ -6794,6 +6988,13 @@ function renderSettings() {
|
||||
<input id="importDirectoryData" type="file" accept="application/json" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-16">
|
||||
<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="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
|
||||
</div>
|
||||
</div>
|
||||
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
|
||||
</div>
|
||||
</section>
|
||||
@@ -6877,16 +7078,9 @@ function renderSettings() {
|
||||
|
||||
document.getElementById("exportData")?.addEventListener("click", () => {
|
||||
const payload = buildDataExportPayload();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "rc_timing_export.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJsonFile("live_rc_full_export.json", payload);
|
||||
});
|
||||
|
||||
|
||||
document.getElementById("importData")?.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
@@ -6909,13 +7103,15 @@ function renderSettings() {
|
||||
|
||||
document.getElementById("exportDirectoryData")?.addEventListener("click", () => {
|
||||
const payload = buildDirectoryExportPayload();
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "live_rc_directory_export.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadJsonFile("live_rc_directory_export.json", payload);
|
||||
});
|
||||
|
||||
document.getElementById("exportDriversCsv")?.addEventListener("click", () => {
|
||||
downloadCsvFile("live_rc_drivers.csv", buildDriversCsvRows());
|
||||
});
|
||||
|
||||
document.getElementById("exportCarsCsv")?.addEventListener("click", () => {
|
||||
downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows());
|
||||
});
|
||||
|
||||
document.getElementById("importDirectoryData")?.addEventListener("change", (event) => {
|
||||
@@ -6940,8 +7136,8 @@ function renderSettings() {
|
||||
|
||||
document.getElementById("exportRacePresets")?.addEventListener("click", () => {
|
||||
const payload = {
|
||||
...buildExportMeta("race_presets"),
|
||||
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
Reference in New Issue
Block a user