Add CSV file upload and auto-delimiter detection for pilot import

Allows uploading a CSV file directly instead of only pasting text, and
auto-detects comma vs semicolon separators to support more locales.
This commit is contained in:
Jan Meinl
2026-08-19 15:32:26 +02:00
parent 4b5f0eb326
commit 65bb2b351f
10 changed files with 97 additions and 16 deletions
+38 -5
View File
@@ -164,6 +164,18 @@ func handleDeletePilot(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) 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) { func handleImportPilots(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil { if err != nil {
@@ -174,13 +186,34 @@ func handleImportPilots(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "forbidden") writeError(w, http.StatusForbidden, "forbidden")
return return
} }
// Cap the upload to keep memory bounded.
body, err := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024)) var body []byte
if err != nil { if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
writeError(w, http.StatusBadRequest, "read_error") if err := r.ParseMultipartForm(2 * 1024 * 1024); err != nil {
return 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 := csv.NewReader(strings.NewReader(string(body)))
reader.Comma = detectSeparator(body)
reader.FieldsPerRecord = -1 reader.FieldsPerRecord = -1
records, err := reader.ReadAll() records, err := reader.ReadAll()
if err != nil { if err != nil {
+12 -3
View File
@@ -48,11 +48,20 @@ const API = {
createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b), createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b),
updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b), updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b),
deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`), 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`), { const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), {
method: "POST", method: "POST",
headers: { "Content-Type": "text/csv" }, headers,
body: csv, body,
credentials: "include", credentials: "include",
}); });
if (!res.ok) throw new Error("import_failed"); if (!res.ok) throw new Error("import_failed");
+19 -1
View File
@@ -953,15 +953,33 @@
function openImportModal() { function openImportModal() {
const backdrop = el("div", { class: "modal-backdrop", const backdrop = el("div", { class: "modal-backdrop",
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); 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" } }); const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } });
backdrop.appendChild(el("div", { class: "modal" }, backdrop.appendChild(el("div", { class: "modal" },
el("h3", null, t("import_csv")), 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, textarea,
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { onclick: () => backdrop.remove() }, t("cancel")),
el("button", { class: "primary", onclick: async () => { el("button", { class: "primary", onclick: async () => {
try { 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(); await loadPilots();
backdrop.remove(); backdrop.remove();
render(); render();
+4 -1
View File
@@ -142,5 +142,8 @@
"confirm_delete_competition": "Diesen Wettbewerb löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.", "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.", "confirm_delete_competition_named": "Wettbewerb '{name}' löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.",
"backup_written": "Sicherung erstellt: {file}", "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."
} }
+4 -1
View File
@@ -142,5 +142,8 @@
"confirm_delete_competition": "Delete this competition? All pilots, penalties and members will be permanently removed.", "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.", "confirm_delete_competition_named": "Delete competition '{name}'? All pilots, penalties and members will be permanently removed.",
"backup_written": "Backup written: {file}", "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."
} }
+4 -1
View File
@@ -79,5 +79,8 @@
"save_settings": "Guardar ajustes", "save_settings": "Guardar ajustes",
"saved": "Guardado", "saved": "Guardado",
"yes": "Sí", "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."
} }
+4 -1
View File
@@ -79,5 +79,8 @@
"save_settings": "Enregistrer", "save_settings": "Enregistrer",
"saved": "Enregistré", "saved": "Enregistré",
"yes": "Oui", "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."
} }
+4 -1
View File
@@ -79,5 +79,8 @@
"save_settings": "Zapisz ustawienia", "save_settings": "Zapisz ustawienia",
"saved": "Zapisano", "saved": "Zapisano",
"yes": "Tak", "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."
} }
+4 -1
View File
@@ -79,5 +79,8 @@
"save_settings": "Guardar definições", "save_settings": "Guardar definições",
"saved": "Guardado", "saved": "Guardado",
"yes": "Sim", "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."
} }
+4 -1
View File
@@ -79,5 +79,8 @@
"save_settings": "Сохранить настройки", "save_settings": "Сохранить настройки",
"saved": "Сохранено", "saved": "Сохранено",
"yes": "Да", "yes": "Да",
"no": "Нет" "no": "Нет",
"upload_file": "Загрузить файл",
"csv_separator_hint": "Запятая (,) и точка с запятой (;) определяются автоматически.",
"csv_empty": "Вставьте CSV или выберите файл."
} }