diff --git a/README.md b/README.md index c52d4f6..9806635 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Recommended deployment: - 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 + - CSV export and safe merging CSV import for drivers and cars through Excel or LibreOffice - single race-package export/import from `Race Setup -> Manage -> Race Actions` - Race Setup includes: - `Quick Open Practice`: enter only a name, create an untimed all-transponder session, and continue directly to Timing @@ -262,11 +262,16 @@ The integration test starts its own backend on a temporary localhost port and ne - `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 + - CSV export and merging CSV import for drivers and cars; comma, semicolon, and tab delimiters are supported + - separate clearing of drivers, cars, classes, races/sessions, or training statistics + - `Clear all competition data` retains decoder, theme, branding, and server settings - `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. +- CSV imports from the application's own exports preserve IDs. External lists without IDs first match by transponder and then by name + class. Empty fields do not overwrite existing information, and new class names are created automatically. +- Create a JSON backup before clearing data. Every clear button requires a separate confirmation and cannot be undone. +- Web clearing removes canonical application data but retains the separate raw SQLite `passings` table as an audit log. A physical factory reset of the database must be performed locally on the stopped server, never through an unauthenticated LAN route. ## Race features diff --git a/README.sv.md b/README.sv.md index 4ae5599..dda93e0 100644 --- a/README.sv.md +++ b/README.sv.md @@ -71,7 +71,7 @@ Rekommenderad deploy: - 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 + - CSV-export och säker sammanslagande CSV-import för förare och bilar via Excel eller LibreOffice - export/import av ett enskilt racepaket via `Race Setup -> Hantera -> Race actions` - `Race Setup` innehåller nu även: - `Snabbstart Open Practice`: ange bara ett namn, skapa en otidsstyrd session för alla transpondrar och fortsätt direkt till Tidtagning @@ -251,11 +251,16 @@ Integrationstestet startar en egen backend på en temporär localhost-port och l - `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 + - CSV-export och sammanslagande CSV-import för förare och bilar; komma, semikolon och tabb stöds + - separat rensning av förare, bilar, klasser, race/sessioner eller träningsstatistik + - `Rensa all tävlingsdata` behåller decoder-, tema-, branding- och serverinställningar - `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. +- CSV-import från systemets egen export bevarar ID:n. Externa listor utan ID matchas först på transponder och därefter på namn + klass. Tomma fält skriver inte över befintlig information, och nya klassnamn skapas automatiskt. +- Ta en JSON-backup före rensning. Varje rensningsknapp kräver en separat bekräftelse och åtgärden kan inte ångras. +- Webbrensningen tömmer kanonisk appdata men behåller den separata råa `passings`-tabellen i SQLite som revisionslogg. En fysisk fabriksrensning av databasen ska göras lokalt på servern när processen är stoppad, inte via en oautentiserad LAN-route. ## Nya racefunktioner diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index ca81126..0ab1e59 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -438,6 +438,26 @@ Owns: - OBS settings UI - branding/audio settings UI - import/export settings UI +- destructive data-clear controls and confirmation dialogs + +`settings.js` never parses CSV or mutates canonical collections directly. It reads the selected file and calls the injected `importDirectoryCsv(text, type)` controller from `app.js`. Clear buttons similarly call the injected `clearDataScope(scope)` controller after confirmation. + +### `src/csv_import.js` +Type: +- pure directory CSV parser and merge logic + +Owns: +- RFC-style quoted field parsing for comma-, semicolon-, and tab-separated files +- UTF-8 BOM and Swedish/English column aliases +- driver matching by stable ID, transponder, or unique name + class +- car matching by stable ID, transponder, or unique name +- automatic class creation for imported class names +- non-destructive field merge where empty CSV cells do not erase existing values +- added/updated/skipped import counters + +It does not access DOM, browser storage, or global state. `app.js` passes current collections and `uid`, then normalizes and persists the returned collections. + +Clear operations remove canonical state and dependent references only. The separate SQLite `passings` table is intentionally retained as an audit log because the LAN UI has no authenticated administrator boundary. Do not add a destructive passings endpoint without first implementing real admin authentication and authorization. ### `src/training_logic.js` Type: @@ -557,6 +577,7 @@ Owns: - `src/judging_logic.js` - `src/training_logic.js` - `src/training_views.js` +- `src/csv_import.js` ### Training/profile imports - `src/core_views.js` imports `src/driver_profile_logic.js` @@ -1080,6 +1101,7 @@ sequenceDiagram | `src/core_views.js` | view/render | none | `app.js` | yes | no | no | | `src/misc_views.js` | view/render | none | `app.js` | yes | no | no | | `src/settings.js` | view/render | none | `app.js` | yes | no | no | +| `src/csv_import.js` | pure import logic | none | `app.js`, tests | no | no | no, returns merged collections | | `src/training_logic.js` | logic | none | `app.js` | no | no | no, returns normalized/derived data | | `src/training_views.js` | view/controller | none | `app.js` | yes | no | yes, through injected state access | | `public-training.html` | standalone entry | `public_training.css`, `public_training.js` | `server.js` route | root only | no | no | diff --git a/src/app.js b/src/app.js index 241b7aa..7be9dd0 100644 --- a/src/app.js +++ b/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, diff --git a/src/csv_import.js b/src/csv_import.js new file mode 100644 index 0000000..ae8166d --- /dev/null +++ b/src/csv_import.js @@ -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 }; +} diff --git a/src/misc_views.js b/src/misc_views.js index 59c5759..aa9a5fe 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -90,6 +90,7 @@ export function renderGuideView({ dom, t }) {
${t("settings.storage_note_csv")}
${escapeHtml(settingsStorageNotice)}
` : ""} +${t("settings.clear_data_note")}
+