diff --git a/README.md b/README.md index e302480..9d0eb83 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.sv.md b/README.sv.md index 45e2c6c..ebcf08f 100644 --- a/README.sv.md +++ b/README.sv.md @@ -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 diff --git a/src/app.js b/src/app.js index 2744a0b..bdf855a 100644 --- a/src/app.js +++ b/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() {
${t("settings.storage_note_full")}
+${t("settings.storage_note_directory")}
+${escapeHtml(settingsStorageNotice)}
` : ""} @@ -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)),