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:
@@ -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))
|
||||
|
||||
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 {
|
||||
|
||||
+12
-3
@@ -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");
|
||||
|
||||
+19
-1
@@ -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();
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -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."
|
||||
}
|
||||
|
||||
+4
-1
@@ -79,5 +79,8 @@
|
||||
"save_settings": "Сохранить настройки",
|
||||
"saved": "Сохранено",
|
||||
"yes": "Да",
|
||||
"no": "Нет"
|
||||
"no": "Нет",
|
||||
"upload_file": "Загрузить файл",
|
||||
"csv_separator_hint": "Запятая (,) и точка с запятой (;) определяются автоматически.",
|
||||
"csv_empty": "Вставьте CSV или выберите файл."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user