feat(csv-import): Update CSV import functionality and enhance analysis features
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Bumped version of csv_import to 0.3.0, reflecting new analysis capabilities.
- Modified analyze_csv endpoint to allow optional module filtering, improving flexibility in template selection.
- Enhanced the import process to support both system and user-defined templates, ensuring backward compatibility.
- Updated frontend to streamline mapping choices and improve user experience during CSV analysis and import.
- Added detailed error handling and user feedback for import operations.
This commit is contained in:
Lars 2026-04-10 06:15:21 +02:00
parent 7e9da46fe5
commit 5e5f3b4e5a
4 changed files with 216 additions and 178 deletions

View File

@ -183,16 +183,19 @@ def copy_csv_mapping(
@router.post("/analyze")
async def analyze_csv(
file: UploadFile = File(...),
module: str = Form(...),
module: Optional[str] = Form(default=None),
delimiter: Optional[str] = Form(default=None),
session: dict = Depends(require_auth),
):
"""
Erste Zeilen parsen, Signatur bilden, System-Templates nach Ähnlichkeit ranken.
Erste Zeilen parsen, Signatur bilden, Vorlagen nach Ähnlichkeit ranken.
Ohne `module`: alle System- + eigene User-Vorlagen (echte Format-Erkennung über Spalten-Signatur).
Mit `module`: nur Vorlagen dieses Moduls (Abwärtskompatibilität).
"""
if not get_module_definition(module):
if module and not get_module_definition(module):
raise HTTPException(400, f"Unbekanntes Modul: {module}")
pid = str(session["profile_id"])
raw = await file.read()
limits = _load_import_limits()
max_bytes = limits.get("max_file_bytes", 52_428_800)
@ -213,20 +216,32 @@ async def analyze_csv(
headers, sample_rows, used_delim = parse_csv_sample(text, delimiter=delim, max_data_rows=5)
sig = column_signature(headers)
mod_def = get_module_definition(module)
available_fields = mod_def["fields"] if mod_def else {}
mod_def = get_module_definition(module) if module else None
available_fields = mod_def["fields"] if mod_def else None
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, module, mapping_name, description, column_signature,
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
FROM csv_field_mappings
WHERE is_system = true AND module = %s
""",
(module,),
)
if module:
cur.execute(
"""
SELECT id, module, mapping_name, description, column_signature,
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
FROM csv_field_mappings
WHERE module = %s
AND (is_system = true OR (is_system = false AND profile_id = %s::uuid))
""",
(module, pid),
)
else:
cur.execute(
"""
SELECT id, module, mapping_name, description, column_signature,
delimiter, encoding, has_header, field_mappings, type_conversions, is_system
FROM csv_field_mappings
WHERE is_system = true OR (is_system = false AND profile_id = %s::uuid)
""",
(pid,),
)
templates = [r2d(r) for r in cur.fetchall()]
ranked = []
@ -237,27 +252,38 @@ async def analyze_csv(
ranked.append(
{
"mapping_id": t["id"],
"module": t["module"],
"mapping_name": t["mapping_name"],
"is_system": bool(t.get("is_system")),
"confidence": round(score, 4),
"match_type": "signature_jaccard",
}
)
ranked.sort(key=lambda x: -x["confidence"])
top = ranked[:25]
recommended = top[0] if top and (top[0]["confidence"] or 0) > 0 else None
return {
"module": module,
"module_filter": module,
"filename": file.filename,
"encoding_note": "utf-8/latin-1 mit BOM-Strip",
"delimiter": used_delim,
"columns": headers,
"column_signature_normalized": sig,
"sample_rows": sample_rows,
"detected_mappings": ranked[:5],
"detected_mappings": top,
"recommended": recommended,
"available_fields": available_fields,
}
def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> dict:
def _fetch_mapping_row(
cur,
mapping_id: int,
profile_id: str,
module: Optional[str] = None,
) -> dict:
cur.execute(
"""
SELECT * FROM csv_field_mappings WHERE id = %s
@ -267,7 +293,7 @@ def _fetch_mapping_row(cur, mapping_id: int, profile_id: str, module: str) -> di
m = r2d(cur.fetchone())
if not m:
raise HTTPException(404, "Mapping nicht gefunden")
if m.get("module") != module:
if module is not None and m.get("module") != module:
raise HTTPException(400, "Mapping gehört zu einem anderen Modul")
if not m.get("is_system"):
if str(m.get("profile_id") or "") != profile_id:
@ -297,24 +323,15 @@ def _check_module_feature_access(pid: str, module: str) -> None:
@router.post("/import")
async def csv_import_execute(
file: UploadFile = File(...),
module: str = Form(...),
mapping_id: int = Form(...),
module: Optional[str] = Form(default=None),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""
Universal-CSV-Import mit gespeichertem Mapping (Issue #21).
Unterstützt: nutrition, weight, blood_pressure. activity: noch nicht.
Universal-CSV-Import: Zielmodul kommt aus der gewählten Vorlage (`mapping_id`).
Optional `module` dient nur der Absicherung (muss mit Vorlage übereinstimmen).
"""
if module == "activity":
raise HTTPException(
501,
"Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
"(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
)
if not get_module_definition(module):
raise HTTPException(400, f"Unbekanntes oder nicht unterstütztes Modul: {module}")
pid = get_pid(x_profile_id)
access_di = check_feature_access(pid, "data_import")
@ -326,8 +343,6 @@ async def csv_import_execute(
f"{access_di.get('used')}/{access_di.get('limit')}",
)
_check_module_feature_access(pid, module)
raw = await file.read()
limits = _load_import_limits()
max_bytes = limits.get("max_file_bytes", 52_428_800)
@ -349,11 +364,28 @@ async def csv_import_execute(
log_id: int | None = None
err_response: HTTPException | None = None
result: dict | None = None
resolved_module: str | None = None
try:
with get_db() as conn:
cur = get_cursor(conn)
m = _fetch_mapping_row(cur, mapping_id, pid, module)
exec_module = m["module"]
resolved_module = exec_module
if exec_module == "activity":
raise HTTPException(
501,
"Aktivitäts-CSV über den Universal-Importer ist noch nicht freigeschaltet "
"(Training-Type-Mapping). Bitte weiterhin /api/activity/import nutzen.",
)
if not get_module_definition(exec_module):
raise HTTPException(
400,
f"Modul der Vorlage wird vom Importer noch nicht unterstützt: {exec_module}",
)
_check_module_feature_access(pid, exec_module)
cur.execute(
"""
@ -367,7 +399,7 @@ async def csv_import_execute(
'running', NULL, NULL
) RETURNING id
""",
(pid, mapping_id, module, file.filename or "upload.csv"),
(pid, mapping_id, exec_module, file.filename or "upload.csv"),
)
log_id = cur.fetchone()["id"]
@ -376,7 +408,7 @@ async def csv_import_execute(
result = run_universal_csv_import(
cur,
pid,
module,
exec_module,
text,
file.filename or "upload.csv",
m,
@ -444,16 +476,17 @@ async def csv_import_execute(
increment_feature_usage(pid, "data_import")
ne = result.get("new_entries", result["rows_imported"])
if module == "nutrition":
if resolved_module == "nutrition":
for _ in range(ne):
increment_feature_usage(pid, "nutrition_entries")
elif module == "weight":
elif resolved_module == "weight":
for _ in range(ne):
increment_feature_usage(pid, "weight_entries")
return {
"success": True,
"import_log_id": log_id,
"module": resolved_module,
"stats": {
"total_rows": result["rows_total"],
"imported": result["rows_imported"],

View File

@ -31,7 +31,7 @@ MODULE_VERSIONS = {
"membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
"csv_import": "0.2.0", # Issue #21: + POST /csv/import (nutrition, weight, blood_pressure)
"csv_import": "0.3.0", # Analyse ohne Modul-Filter; Import nur mapping_id (Modul aus Vorlage)
"admin_csv_templates": "0.1.0", # Issue #21: System-Templates + Import-Limits (Admin)
}
@ -45,6 +45,7 @@ CHANGELOG = [
"API /api/csv: modules, limits, mappings, analyze, copy",
"API /api/admin/csv-templates: CRUD System-Templates, import-limits (system_config)",
"Issue #21: POST /api/csv/import + executor (nutrition Aggregat/Tag, weight, Blutdruck); activity 501",
"Issue #21: GET-Analyse über alle Vorlagen; Import nur mapping_id; GUI ohne Modul-Dropdown",
"v9c_cleanup_features.sql: FK-sichere csv_import→data_import Reihenfolge",
],
},

View File

@ -1,10 +1,54 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, FileSpreadsheet, Loader2 } from 'lucide-react'
import { api } from '../utils/api'
/** Module mit funktionierendem Universal-Executor; Aktivität: Legacy-Import auf der Aktivitätsseite. */
const UNIVERSAL_IMPORT_IDS = new Set(['nutrition', 'weight', 'blood_pressure'])
/** Ziele, die der Universal-Executor bereits schreiben kann (ohne manuelle Modul-Wahl). */
const EXECUTOR_READY = new Set(['nutrition', 'weight', 'blood_pressure'])
const MODULE_LABEL = {
nutrition: 'Ernährung',
weight: 'Gewicht',
blood_pressure: 'Blutdruck',
activity: 'Aktivität',
}
function mergeMappingChoices(detected, mapData) {
const sys = mapData.system_templates || []
const usr = mapData.user_mappings || []
const flat = [
...sys.map((m) => ({ ...m, _isSys: true })),
...usr.map((m) => ({ ...m, _isSys: false })),
]
const byId = new Map(flat.map((m) => [m.id, m]))
const out = []
const seen = new Set()
for (const d of detected || []) {
const row = byId.get(d.mapping_id)
if (row) {
out.push({
id: row.id,
module: row.module,
name: row.name,
is_system: row.is_system,
confidence: d.confidence ?? 0,
})
seen.add(row.id)
}
}
for (const row of flat) {
if (!seen.has(row.id)) {
out.push({
id: row.id,
module: row.module,
name: row.name,
is_system: row.is_system,
confidence: 0,
})
}
}
return out
}
function SampleTable({ sampleRows, columns }) {
if (!sampleRows?.length || !columns?.length) return null
@ -57,124 +101,75 @@ function SampleTable({ sampleRows, columns }) {
export default function UniversalCsvImportPage() {
const navigate = useNavigate()
const [modules, setModules] = useState([])
const [moduleId, setModuleId] = useState('')
const [file, setFile] = useState(null)
const [analyzeResult, setAnalyzeResult] = useState(null)
const [mappingsList, setMappingsList] = useState({ system_templates: [], user_mappings: [] })
const [mappingChoices, setMappingChoices] = useState([])
const [mappingId, setMappingId] = useState('')
const [loadingModules, setLoadingModules] = useState(true)
const [loadingAnalyze, setLoadingAnalyze] = useState(false)
const [loadingImport, setLoadingImport] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const loadMappings = useCallback(async (mid) => {
if (!mid) {
setMappingsList({ system_templates: [], user_mappings: [] })
return
}
const data = await api.getCsvMappings(mid)
setMappingsList({
system_templates: data.system_templates || [],
user_mappings: data.user_mappings || [],
})
}, [])
useEffect(() => {
let cancel = false
;(async () => {
setLoadingModules(true)
setError(null)
try {
const data = await api.getCsvModules()
const list = (data.modules || []).filter((m) => UNIVERSAL_IMPORT_IDS.has(m.id))
if (!cancel) {
setModules(list)
if (list.length && !moduleId) setModuleId(list[0].id)
}
} catch (e) {
if (!cancel) setError(e.message || 'Module konnten nicht geladen werden')
} finally {
if (!cancel) setLoadingModules(false)
}
})()
return () => {
cancel = true
}
}, [])
useEffect(() => {
if (moduleId) {
loadMappings(moduleId)
setAnalyzeResult(null)
setMappingId('')
setSuccess(null)
}
}, [moduleId, loadMappings])
const allMappingOptions = [
...(mappingsList.system_templates || []).map((m) => ({
id: m.id,
label: `${m.name} (System)`,
isSystem: true,
})),
...(mappingsList.user_mappings || []).map((m) => ({
id: m.id,
label: m.name,
isSystem: false,
})),
]
const selectedChoice = useMemo(
() => mappingChoices.find((c) => String(c.id) === String(mappingId)),
[mappingChoices, mappingId],
)
const importAllowed = selectedChoice && EXECUTOR_READY.has(selectedChoice.module)
const handleAnalyze = async () => {
if (!file || !moduleId) {
setError('Bitte Modul und Datei wählen')
if (!file) {
setError('Bitte eine CSV-Datei wählen')
return
}
setLoadingAnalyze(true)
setError(null)
setSuccess(null)
setMappingId('')
try {
const res = await api.analyzeCsv(file, moduleId)
const res = await api.analyzeCsv(file)
setAnalyzeResult(res)
const mapData = await api.getCsvMappings(moduleId)
const sys = mapData.system_templates || []
const usr = mapData.user_mappings || []
setMappingsList({ system_templates: sys, user_mappings: usr })
const options = [
...sys.map((m) => ({ id: m.id, name: m.name, isSystem: true })),
...usr.map((m) => ({ id: m.id, name: m.name, isSystem: false })),
]
const detected = res.detected_mappings || []
const best = detected[0]
let pick = options[0]?.id
if (best && (best.confidence || 0) >= 0.5 && options.some((o) => o.id === best.mapping_id)) {
pick = best.mapping_id
const mapData = await api.getCsvMappings()
const merged = mergeMappingChoices(res.detected_mappings || [], mapData)
setMappingChoices(merged)
const best = res.recommended || (res.detected_mappings || [])[0]
let pick = ''
if (best && merged.some((m) => m.id === best.mapping_id)) {
pick = String(best.mapping_id)
} else if (merged.length) {
const firstReady = merged.find((m) => EXECUTOR_READY.has(m.module))
pick = String((firstReady || merged[0]).id)
}
if (pick != null) setMappingId(String(pick))
else setMappingId('')
setMappingId(pick)
} catch (e) {
setError(e.message || 'Analyse fehlgeschlagen')
setAnalyzeResult(null)
setMappingChoices([])
} finally {
setLoadingAnalyze(false)
}
}
const handleImport = async () => {
if (!file || !moduleId || !mappingId) {
setError('Bitte Datei, Modul und Vorlage wählen')
if (!file || !mappingId) {
setError('Bitte Datei und Vorlage wählen')
return
}
if (!importAllowed) {
setError('Diese Vorlage kann hier noch nicht importiert werden (z.B. Aktivität → Seite „Aktivität“).')
return
}
setLoadingImport(true)
setError(null)
setSuccess(null)
try {
const res = await api.importUniversalCsv(file, moduleId, Number(mappingId))
const res = await api.importUniversalCsv(file, Number(mappingId))
const st = res.stats || {}
const modLabel = MODULE_LABEL[res.module] || res.module || ''
setSuccess(
`Import abgeschlossen: ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`
(modLabel ? `${modLabel}: ` : '') +
`Import fertig — ${st.imported ?? 0} neu, ${st.updated ?? 0} aktualisiert, ` +
`${st.skipped ?? 0} übersprungen, ${st.errors ?? 0} Zeilenfehler.`,
)
} catch (e) {
setError(e.message || 'Import fehlgeschlagen')
@ -204,9 +199,11 @@ export default function UniversalCsvImportPage() {
<FileSpreadsheet size={26} strokeWidth={2} />
CSV-Import
</h1>
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.5 }}>
Bekannte Formate (FDDB, Apple Health, Omron, ) per Vorlage zuordnen und importieren.
Trainingseinheiten weiterhin unter{' '}
<p style={{ fontSize: 14, color: 'var(--text2)', marginBottom: 20, lineHeight: 1.55 }}>
Datei hochladen: Die App vergleicht die Spalten-Struktur mit allen gespeicherten Vorlagen und schlägt
passende Ziele vor (Ernährung, Gewicht, Blutdruck, ). Du bestätigst nur noch die Vorlage ohne
vorher ein Modul raten zu müssen.{' '}
<strong>Später:</strong> eine Datei, mehrere Zieltabellen. Trainingseinheiten aktuell weiter unter{' '}
<button
type="button"
className="btn btn-secondary"
@ -247,35 +244,7 @@ export default function UniversalCsvImportPage() {
)}
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">1. Modul</div>
{loadingModules ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text3)' }}>
<Loader2 size={18} style={{ animation: 'spin 0.7s linear infinite' }} /> Lade
</div>
) : (
<select
className="form-input"
value={moduleId}
onChange={(e) => setModuleId(e.target.value)}
style={{ width: '100%', marginTop: 8 }}
>
{modules.map((m) => (
<option key={m.id} value={m.id}>
{m.id === 'nutrition'
? 'Ernährung'
: m.id === 'weight'
? 'Gewicht'
: m.id === 'blood_pressure'
? 'Blutdruck'
: m.id}
</option>
))}
</select>
)}
</div>
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">2. CSV-Datei</div>
<div className="form-label">1. CSV-Datei</div>
<input
type="file"
accept=".csv,text/csv"
@ -284,6 +253,8 @@ export default function UniversalCsvImportPage() {
onChange={(e) => {
setFile(e.target.files?.[0] || null)
setAnalyzeResult(null)
setMappingChoices([])
setMappingId('')
setSuccess(null)
}}
/>
@ -291,37 +262,59 @@ export default function UniversalCsvImportPage() {
type="button"
className="btn btn-primary"
style={{ marginTop: 12, width: '100%' }}
disabled={!file || !moduleId || loadingAnalyze}
disabled={!file || loadingAnalyze}
onClick={handleAnalyze}
>
{loadingAnalyze ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Analysiere
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Datei
wird erkannt
</>
) : (
'Vorschau und Erkennung'
'Datei analysieren'
)}
</button>
</div>
{analyzeResult && (
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="form-label">3. Vorschau</div>
<div className="form-label">2. Erkennung &amp; Vorschau</div>
<div style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8 }}>
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten: {analyzeResult.columns?.length ?? 0}
Trennzeichen: <strong>{analyzeResult.delimiter}</strong> · Spalten:{' '}
{analyzeResult.columns?.length ?? 0}
</div>
{(analyzeResult.detected_mappings || []).length > 0 && (
<div style={{ marginTop: 10, fontSize: 13 }}>
<strong>Erkannte Vorlagen:</strong>
<ul style={{ margin: '6px 0 0 18px', padding: 0 }}>
{analyzeResult.detected_mappings.map((d) => (
{analyzeResult.recommended && (
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 12,
background: 'var(--surface2)',
fontSize: 14,
}}
>
<strong>Vorschlag:</strong>{' '}
{MODULE_LABEL[analyzeResult.recommended.module] || analyzeResult.recommended.module} {' '}
{analyzeResult.recommended.mapping_name} (
{Math.round((analyzeResult.recommended.confidence || 0) * 100)} % Übereinstimmung der Spalten)
</div>
)}
{(analyzeResult.detected_mappings || []).length > 1 && (
<details style={{ marginTop: 12, fontSize: 13 }}>
<summary style={{ cursor: 'pointer', color: 'var(--text2)' }}>Weitere Treffer</summary>
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
{analyzeResult.detected_mappings.slice(1, 8).map((d) => (
<li key={d.mapping_id}>
{d.mapping_name} · Übereinstimmung ca. {Math.round((d.confidence || 0) * 100)} %
{MODULE_LABEL[d.module] || d.module}: {d.mapping_name} ·{' '}
{Math.round((d.confidence || 0) * 100)} %
</li>
))}
</ul>
</div>
</details>
)}
<SampleTable sampleRows={analyzeResult.sample_rows} columns={analyzeResult.columns} />
<div className="form-label" style={{ marginTop: 16 }}>
@ -333,27 +326,38 @@ export default function UniversalCsvImportPage() {
onChange={(e) => setMappingId(e.target.value)}
style={{ width: '100%', marginTop: 8 }}
>
{allMappingOptions.length === 0 ? (
<option value="">Keine Vorlage für dieses Modul</option>
{mappingChoices.length === 0 ? (
<option value="">Keine Vorlage geladen</option>
) : (
allMappingOptions.map((o) => (
mappingChoices.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
{MODULE_LABEL[o.module] || o.module} {o.name}
{o.is_system ? ' (System)' : ''}
{o.confidence > 0 ? ` · ${Math.round(o.confidence * 100)} %` : ''}
{!EXECUTOR_READY.has(o.module) ? ' · Import: noch nicht hier' : ''}
</option>
))
)}
</select>
{selectedChoice && !importAllowed && (
<p style={{ fontSize: 13, color: 'var(--text3)', marginTop: 10 }}>
Diese Vorlage passt strukturell, wird aber an der passenden Stelle importiert (z.B. Aktivität
Apple-Health-CSV dort).
</p>
)}
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 16, width: '100%' }}
disabled={!file || !mappingId || loadingImport}
disabled={!file || !mappingId || !importAllowed || loadingImport}
onClick={handleImport}
>
{loadingImport ? (
<>
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import läuft
<Loader2 size={18} style={{ marginRight: 8, animation: 'spin 0.7s linear infinite' }} /> Import
läuft
</>
) : (
'Import starten'

View File

@ -489,11 +489,10 @@ export const api = {
req(module ? `/csv/mappings?module=${encodeURIComponent(module)}` : '/csv/mappings'),
copyCsvMapping: (mappingId, body = null) =>
req(`/csv/mappings/${mappingId}/copy`, body ? json(body) : { method: 'POST' }),
/** Universal-CSV (Issue #21): file + module + mapping_id aus /csv/mappings */
importUniversalCsv: async (file, module, mappingId) => {
/** Universal-CSV (Issue #21): Zielmodul steckt in der Vorlage; nur file + mapping_id */
importUniversalCsv: async (file, mappingId) => {
const fd = new FormData()
fd.append('file', file)
fd.append('module', module)
fd.append('mapping_id', String(mappingId))
const res = await fetch(BASE + '/csv/import', { method: 'POST', headers: hdrs(), body: fd })
if (!res.ok) {
@ -506,10 +505,11 @@ export const api = {
}
return res.json()
},
analyzeCsv: async (file, module, delimiter = null) => {
/** optional module = nur diese Vorlagen-Gruppe (Abwärtskompatibilität); ohne = globale Erkennung */
analyzeCsv: async (file, module = null, delimiter = null) => {
const fd = new FormData()
fd.append('file', file)
fd.append('module', module)
if (module) fd.append('module', module)
if (delimiter) fd.append('delimiter', delimiter)
const res = await fetch(BASE + '/csv/analyze', { method: 'POST', headers: hdrs(), body: fd })
if (!res.ok) {