Add race and directory export/import

This commit is contained in:
larssand
2026-03-20 18:39:19 +01:00
parent 410e811e2c
commit 1637f63dad
3 changed files with 176 additions and 11 deletions

View File

@@ -34,6 +34,9 @@ JMK RB RaceController is an RC timing and race-control system with support for s
- UI separation: - UI separation:
- `Event` = sponsor events with shared cars/transponders - `Event` = sponsor events with shared cars/transponders
- `Race Setup` = competition races with personal driver transponders - `Race Setup` = competition races with personal driver transponders
- Storage/import tools:
- full race-data export/import for backup or moving to another machine
- directory export/import for only classes, drivers and cars
- Race Setup includes: - Race Setup includes:
- grouped `Participants` and `Teams` sections in `Manage` - grouped `Participants` and `Teams` sections in `Manage`
- a four-step `Create Race Wizard` for new races - a four-step `Create Race Wizard` for new races

View File

@@ -32,6 +32,9 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
- UI-separering: - UI-separering:
- `Event` = sponsor-event med delade bilar/transpondrar - `Event` = sponsor-event med delade bilar/transpondrar
- `Race Setup` = riktiga race med personlig transponder per förare - `Race Setup` = riktiga race med personlig transponder per förare
- 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
- `Race Setup` innehåller nu även: - `Race Setup` innehåller nu även:
- grupperade sektioner för `Deltagare` och `Lag` i `Hantera` - grupperade sektioner för `Deltagare` och `Lag` i `Hantera`
- en fyrstegs `Create Race Wizard` för nya race - en fyrstegs `Create Race Wizard` för nya race

View File

@@ -551,6 +551,15 @@ const TRANSLATIONS = {
"settings.test_backend": "Testa backend", "settings.test_backend": "Testa backend",
"settings.sync_now": "Synka nu", "settings.sync_now": "Synka nu",
"settings.export_json": "Exportera JSON", "settings.export_json": "Exportera JSON",
"settings.export_all_data": "Exportera all race-data",
"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.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.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}",
"table.name": "Namn", "table.name": "Namn",
"table.brand": "Märke", "table.brand": "Märke",
"table.class": "Klass", "table.class": "Klass",
@@ -743,6 +752,8 @@ const TRANSLATIONS = {
"guide.sqlite_title": "SQLite-lagring", "guide.sqlite_title": "SQLite-lagring",
"guide.sqlite_1": "Databasfil: data/rc_timing.sqlite", "guide.sqlite_1": "Databasfil: data/rc_timing.sqlite",
"guide.sqlite_2": "API: /api/state och /api/passings", "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.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/", "guide.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/",
"error.backend_offline": "Backend offline: {msg}", "error.backend_offline": "Backend offline: {msg}",
"error.sync_failed": "Synk misslyckades: {msg}", "error.sync_failed": "Synk misslyckades: {msg}",
@@ -1289,6 +1300,15 @@ const TRANSLATIONS = {
"settings.test_backend": "Test Backend", "settings.test_backend": "Test Backend",
"settings.sync_now": "Sync Now", "settings.sync_now": "Sync Now",
"settings.export_json": "Export JSON", "settings.export_json": "Export JSON",
"settings.export_all_data": "Export all race data",
"settings.import_all_data": "Import all race data",
"settings.export_directory": "Export drivers/classes/cars",
"settings.import_directory": "Import drivers/classes/cars",
"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.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}",
"table.name": "Name", "table.name": "Name",
"table.brand": "Brand", "table.brand": "Brand",
"table.class": "Class", "table.class": "Class",
@@ -1481,6 +1501,8 @@ const TRANSLATIONS = {
"guide.sqlite_title": "SQLite storage", "guide.sqlite_title": "SQLite storage",
"guide.sqlite_1": "Database file: data/rc_timing.sqlite", "guide.sqlite_1": "Database file: data/rc_timing.sqlite",
"guide.sqlite_2": "API endpoints: /api/state and /api/passings", "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.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/", "guide.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/",
"error.backend_offline": "Backend offline: {msg}", "error.backend_offline": "Backend offline: {msg}",
"error.sync_failed": "Sync failed: {msg}", "error.sync_failed": "Sync failed: {msg}",
@@ -1535,6 +1557,8 @@ let lastOverlayLeaderKeyBySession = {};
let lastOverlayTop3BySession = {}; let lastOverlayTop3BySession = {};
let lastOverlayBestLapByKey = {}; let lastOverlayBestLapByKey = {};
let activeModalEscapeHandler = null; let activeModalEscapeHandler = null;
let settingsStorageNotice = "";
let settingsStorageNoticeIsError = false;
const backend = { const backend = {
available: false, available: false,
lastSyncAt: null, lastSyncAt: null,
@@ -1854,6 +1878,82 @@ function buildPersistableState() {
}; };
} }
function normalizeImportedClass(classItem) {
return {
id: String(classItem?.id || uid("class")),
name: String(classItem?.name || "").trim(),
};
}
function buildDataExportPayload() {
return {
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 {
classes: state.classes,
drivers: state.drivers,
cars: state.cars,
exportedAt: new Date().toISOString(),
};
}
function mergeCollectionById(currentItems, incomingItems, normalizer) {
const map = new Map((currentItems || []).map((item) => [item.id, normalizer(item)]));
(incomingItems || []).map((item) => normalizer(item)).forEach((item) => {
if (item && item.id) {
map.set(item.id, item);
}
});
return [...map.values()];
}
function importDataPayload(parsed, mode = "full") {
const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : [];
const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : [];
const incomingCars = Array.isArray(parsed?.cars) ? parsed.cars : [];
if (mode === "directory") {
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);
settingsStorageNotice = t("settings.import_directory_success");
settingsStorageNoticeIsError = false;
saveState();
renderView();
return;
}
const incomingEvents = Array.isArray(parsed?.events) ? parsed.events : [];
const incomingSessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
if (!incomingClasses.length && !incomingDrivers.length && !incomingCars.length && !incomingEvents.length && !incomingSessions.length) {
throw new Error("No importable data found");
}
state.classes = incomingClasses.map((item) => normalizeImportedClass(item)).filter((item) => item.name);
state.drivers = incomingDrivers.map((item) => normalizeDriver(item)).filter((item) => item.name);
state.cars = incomingCars.map((item) => normalizeCar(item)).filter((item) => item.name);
state.events = incomingEvents.map((item) => normalizeEvent(item));
state.sessions = incomingSessions.map((item) => normalizeSession(item));
state.resultsBySession = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {};
if (!state.sessions.some((session) => session.id === state.activeSessionId)) {
state.activeSessionId = null;
}
settingsStorageNotice = t("settings.import_full_success");
settingsStorageNoticeIsError = false;
saveState();
renderNav();
renderView();
}
function getDefaultBackendUrl() { function getDefaultBackendUrl() {
if (window.location.protocol.startsWith("http") && window.location.hostname) { if (window.location.protocol.startsWith("http") && window.location.hostname) {
return `${window.location.protocol}//${window.location.hostname}:8081`; return `${window.location.protocol}//${window.location.hostname}:8081`;
@@ -5942,6 +6042,8 @@ function renderGuide() {
<ul class="guide-list"> <ul class="guide-list">
<li>${t("guide.sqlite_1")}</li> <li>${t("guide.sqlite_1")}</li>
<li>${t("guide.sqlite_2")}</li> <li>${t("guide.sqlite_2")}</li>
<li>${t("guide.sqlite_3")}</li>
<li>${t("guide.sqlite_4")}</li>
</ul> </ul>
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p> <p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
</div> </div>
@@ -6678,7 +6780,21 @@ function renderSettings() {
<button id="settingsTestBackend" class="btn" type="button">${t("settings.test_backend")}</button> <button id="settingsTestBackend" class="btn" type="button">${t("settings.test_backend")}</button>
<button id="settingsSyncNow" class="btn btn-primary" type="button">${t("settings.sync_now")}</button> <button id="settingsSyncNow" class="btn btn-primary" type="button">${t("settings.sync_now")}</button>
</div> </div>
<button id="exportData" class="btn">${t("settings.export_json")}</button> <div class="mt-16">
<p class="hint">${t("settings.storage_note_full")}</p>
<div class="actions">
<button id="exportData" class="btn" type="button">${t("settings.export_all_data")}</button>
<input id="importData" type="file" accept="application/json" />
</div>
</div>
<div class="mt-16">
<p class="hint">${t("settings.storage_note_directory")}</p>
<div class="actions">
<button id="exportDirectoryData" class="btn" type="button">${t("settings.export_directory")}</button>
<input id="importDirectoryData" type="file" accept="application/json" />
</div>
</div>
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
</div> </div>
</section> </section>
@@ -6760,16 +6876,7 @@ function renderSettings() {
}); });
document.getElementById("exportData")?.addEventListener("click", () => { document.getElementById("exportData")?.addEventListener("click", () => {
const payload = { const payload = buildDataExportPayload();
classes: state.classes,
drivers: state.drivers,
cars: state.cars,
events: state.events,
sessions: state.sessions,
resultsBySession: state.resultsBySession,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -6779,6 +6886,58 @@ function renderSettings() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
document.getElementById("importData")?.addEventListener("change", (event) => {
const input = event.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 || "{}"));
importDataPayload(parsed, "full");
} catch (error) {
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
settingsStorageNoticeIsError = true;
renderView();
}
};
reader.readAsText(file);
});
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);
});
document.getElementById("importDirectoryData")?.addEventListener("change", (event) => {
const input = event.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 || "{}"));
importDataPayload(parsed, "directory");
} catch (error) {
settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) });
settingsStorageNoticeIsError = true;
renderView();
}
};
reader.readAsText(file);
});
document.getElementById("exportRacePresets")?.addEventListener("click", () => { document.getElementById("exportRacePresets")?.addEventListener("click", () => {
const payload = { const payload = {
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)), racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),