diff --git a/pilots.go b/pilots.go index 548cc3d..4ae1fc5 100644 --- a/pilots.go +++ b/pilots.go @@ -164,6 +164,18 @@ func handleDeletePilot(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// detectSeparator counts commas vs semicolons in the first line to pick the delimiter. +func detectSeparator(data []byte) rune { + sample := string(data) + if idx := strings.IndexByte(sample, '\n'); idx > 0 { + sample = sample[:idx] + } + if strings.Count(sample, ";") > strings.Count(sample, ",") { + return ';' + } + return ',' +} + func handleImportPilots(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { @@ -174,13 +186,34 @@ func handleImportPilots(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } - // Cap the upload to keep memory bounded. - body, err := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024)) - if err != nil { - writeError(w, http.StatusBadRequest, "read_error") - return + + var body []byte + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + if err := r.ParseMultipartForm(2 * 1024 * 1024); err != nil { + writeError(w, http.StatusBadRequest, "read_error") + return + } + f, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "missing_file") + return + } + defer f.Close() + body, err = io.ReadAll(io.LimitReader(f, 2*1024*1024)) + if err != nil { + writeError(w, http.StatusBadRequest, "read_error") + return + } + } else { + body, err = io.ReadAll(io.LimitReader(r.Body, 2*1024*1024)) + if err != nil { + writeError(w, http.StatusBadRequest, "read_error") + return + } } + reader := csv.NewReader(strings.NewReader(string(body))) + reader.Comma = detectSeparator(body) reader.FieldsPerRecord = -1 records, err := reader.ReadAll() if err != nil { diff --git a/web/api.js b/web/api.js index 2b28159..10c623a 100644 --- a/web/api.js +++ b/web/api.js @@ -48,11 +48,20 @@ const API = { createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b), updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b), deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`), - importPilots: async (id, csv) => { + importPilots: async (id, data) => { + let body, headers = {}; + if (data instanceof File) { + const form = new FormData(); + form.append("file", data); + body = form; + } else { + headers["Content-Type"] = "text/csv"; + body = data; + } const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), { method: "POST", - headers: { "Content-Type": "text/csv" }, - body: csv, + headers, + body, credentials: "include", }); if (!res.ok) throw new Error("import_failed"); diff --git a/web/competition.js b/web/competition.js index 284b9f7..61f4864 100644 --- a/web/competition.js +++ b/web/competition.js @@ -953,15 +953,33 @@ function openImportModal() { const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); + let selectedFile = null; + const fileNameSpan = el("span", { class: "muted small", style: { marginLeft: "0.5rem" } }, ""); + const fileInput = el("input", { type: "file", accept: ".csv,text/csv", style: { display: "none" }, + onchange: (e) => { + selectedFile = e.target.files[0] || null; + fileNameSpan.textContent = selectedFile ? selectedFile.name : ""; + }, + }); const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, t("import_csv")), + el("p", { class: "muted small", style: { marginBottom: "0.5rem" } }, t("csv_separator_hint")), + el("div", { style: { marginBottom: "0.75rem", display: "flex", alignItems: "center" } }, + fileInput, + el("button", { onclick: () => fileInput.click() }, t("upload_file")), + fileNameSpan, + ), textarea, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { try { - await API.importPilots(competitionId, textarea.value); + const data = selectedFile || textarea.value; + if (!data || (typeof data === "string" && !data.trim())) { + alert(t("csv_empty")); return; + } + await API.importPilots(competitionId, data); await loadPilots(); backdrop.remove(); render(); diff --git a/web/i18n/de.json b/web/i18n/de.json index f450913..ad2db9f 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -142,5 +142,8 @@ "confirm_delete_competition": "Diesen Wettbewerb löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.", "confirm_delete_competition_named": "Wettbewerb '{name}' löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.", "backup_written": "Sicherung erstellt: {file}", - "competition_closed": "Wettbewerb ist beendet" + "competition_closed": "Wettbewerb ist beendet", + "upload_file": "Datei hochladen", + "csv_separator_hint": "Komma (,) und Semikolon (;) werden automatisch erkannt.", + "csv_empty": "Bitte CSV einfügen oder eine Datei wählen." } diff --git a/web/i18n/en.json b/web/i18n/en.json index 3616a60..b2f10d1 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -142,5 +142,8 @@ "confirm_delete_competition": "Delete this competition? All pilots, penalties and members will be permanently removed.", "confirm_delete_competition_named": "Delete competition '{name}'? All pilots, penalties and members will be permanently removed.", "backup_written": "Backup written: {file}", - "competition_closed": "Competition is closed" + "competition_closed": "Competition is closed", + "upload_file": "Upload file", + "csv_separator_hint": "Comma (,) and semicolon (;) are detected automatically.", + "csv_empty": "Please paste CSV or choose a file." } diff --git a/web/i18n/es.json b/web/i18n/es.json index a69b699..0a4ccb4 100644 --- a/web/i18n/es.json +++ b/web/i18n/es.json @@ -79,5 +79,8 @@ "save_settings": "Guardar ajustes", "saved": "Guardado", "yes": "Sí", - "no": "No" + "no": "No", + "upload_file": "Subir archivo", + "csv_separator_hint": "La coma (,) y el punto y coma (;) se detectan automáticamente.", + "csv_empty": "Por favor, pegue CSV o elija un archivo." } diff --git a/web/i18n/fr.json b/web/i18n/fr.json index 517039d..978b84b 100644 --- a/web/i18n/fr.json +++ b/web/i18n/fr.json @@ -79,5 +79,8 @@ "save_settings": "Enregistrer", "saved": "Enregistré", "yes": "Oui", - "no": "Non" + "no": "Non", + "upload_file": "Téléverser un fichier", + "csv_separator_hint": "La virgule (,) et le point-virgule (;) sont détectés automatiquement.", + "csv_empty": "Veuillez coller du CSV ou choisir un fichier." } diff --git a/web/i18n/pl.json b/web/i18n/pl.json index 9c7431f..75dac23 100644 --- a/web/i18n/pl.json +++ b/web/i18n/pl.json @@ -79,5 +79,8 @@ "save_settings": "Zapisz ustawienia", "saved": "Zapisano", "yes": "Tak", - "no": "Nie" + "no": "Nie", + "upload_file": "Prześlij plik", + "csv_separator_hint": "Przecinek (,) i średnik (;) są wykrywane automatycznie.", + "csv_empty": "Wklej CSV lub wybierz plik." } diff --git a/web/i18n/pt.json b/web/i18n/pt.json index ad55623..89af95f 100644 --- a/web/i18n/pt.json +++ b/web/i18n/pt.json @@ -79,5 +79,8 @@ "save_settings": "Guardar definições", "saved": "Guardado", "yes": "Sim", - "no": "Não" + "no": "Não", + "upload_file": "Carregar ficheiro", + "csv_separator_hint": "A vírgula (,) e o ponto e vírgula (;) são detectados automaticamente.", + "csv_empty": "Por favor, cole CSV ou escolha um ficheiro." } diff --git a/web/i18n/ru.json b/web/i18n/ru.json index 47adfdb..b2be1da 100644 --- a/web/i18n/ru.json +++ b/web/i18n/ru.json @@ -79,5 +79,8 @@ "save_settings": "Сохранить настройки", "saved": "Сохранено", "yes": "Да", - "no": "Нет" + "no": "Нет", + "upload_file": "Загрузить файл", + "csv_separator_hint": "Запятая (,) и точка с запятой (;) определяются автоматически.", + "csv_empty": "Вставьте CSV или выберите файл." }