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