Add race and directory export/import
This commit is contained in:
@@ -34,6 +34,9 @@ JMK RB RaceController is an RC timing and race-control system with support for s
|
||||
- UI separation:
|
||||
- `Event` = sponsor events with shared cars/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:
|
||||
- grouped `Participants` and `Teams` sections in `Manage`
|
||||
- a four-step `Create Race Wizard` for new races
|
||||
|
||||
@@ -32,6 +32,9 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- UI-separering:
|
||||
- `Event` = sponsor-event med delade bilar/transpondrar
|
||||
- `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:
|
||||
- grupperade sektioner för `Deltagare` och `Lag` i `Hantera`
|
||||
- en fyrstegs `Create Race Wizard` för nya race
|
||||
|
||||
181
src/app.js
181
src/app.js
@@ -551,6 +551,15 @@ const TRANSLATIONS = {
|
||||
"settings.test_backend": "Testa backend",
|
||||
"settings.sync_now": "Synka nu",
|
||||
"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.brand": "Märke",
|
||||
"table.class": "Klass",
|
||||
@@ -743,6 +752,8 @@ const TRANSLATIONS = {
|
||||
"guide.sqlite_title": "SQLite-lagring",
|
||||
"guide.sqlite_1": "Databasfil: data/rc_timing.sqlite",
|
||||
"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/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Synk misslyckades: {msg}",
|
||||
@@ -1289,6 +1300,15 @@ const TRANSLATIONS = {
|
||||
"settings.test_backend": "Test Backend",
|
||||
"settings.sync_now": "Sync Now",
|
||||
"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.brand": "Brand",
|
||||
"table.class": "Class",
|
||||
@@ -1481,6 +1501,8 @@ const TRANSLATIONS = {
|
||||
"guide.sqlite_title": "SQLite storage",
|
||||
"guide.sqlite_1": "Database file: data/rc_timing.sqlite",
|
||||
"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/",
|
||||
"error.backend_offline": "Backend offline: {msg}",
|
||||
"error.sync_failed": "Sync failed: {msg}",
|
||||
@@ -1535,6 +1557,8 @@ let lastOverlayLeaderKeyBySession = {};
|
||||
let lastOverlayTop3BySession = {};
|
||||
let lastOverlayBestLapByKey = {};
|
||||
let activeModalEscapeHandler = null;
|
||||
let settingsStorageNotice = "";
|
||||
let settingsStorageNoticeIsError = false;
|
||||
const backend = {
|
||||
available: false,
|
||||
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() {
|
||||
if (window.location.protocol.startsWith("http") && window.location.hostname) {
|
||||
return `${window.location.protocol}//${window.location.hostname}:8081`;
|
||||
@@ -5942,6 +6042,8 @@ function renderGuide() {
|
||||
<ul class="guide-list">
|
||||
<li>${t("guide.sqlite_1")}</li>
|
||||
<li>${t("guide.sqlite_2")}</li>
|
||||
<li>${t("guide.sqlite_3")}</li>
|
||||
<li>${t("guide.sqlite_4")}</li>
|
||||
</ul>
|
||||
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
|
||||
</div>
|
||||
@@ -6678,7 +6780,21 @@ function renderSettings() {
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -6760,16 +6876,7 @@ function renderSettings() {
|
||||
});
|
||||
|
||||
document.getElementById("exportData")?.addEventListener("click", () => {
|
||||
const payload = {
|
||||
classes: state.classes,
|
||||
drivers: state.drivers,
|
||||
cars: state.cars,
|
||||
events: state.events,
|
||||
sessions: state.sessions,
|
||||
resultsBySession: state.resultsBySession,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -6779,6 +6886,58 @@ function renderSettings() {
|
||||
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", () => {
|
||||
const payload = {
|
||||
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),
|
||||
|
||||
Reference in New Issue
Block a user