package main import ( "encoding/csv" "encoding/json" "errors" "flag" "html/template" "log" "net/http" "os" "path/filepath" "strings" "sync" ) type record map[string]string var ( addr = flag.String("addr", ":8080", "HTTP listen address") csvPath = flag.String("csv", "data.csv", "path to CSV with header row") keyCol = flag.String("key", "", "column name to search in (default: first column)") valueCol = flag.String("value", "", "column name to return (default: second column)") records []record headers []string mu sync.RWMutex ) func main() { flag.Parse() if err := loadCSV(*csvPath); err != nil { log.Fatalf("CSV laden fehlgeschlagen: %v", err) } log.Printf("CSV geladen: %s (%d Zeilen, %d Spalten)", *csvPath, len(records), len(headers)) http.HandleFunc("/", handleIndex) http.HandleFunc("/search", handleSearch) http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir())))) log.Printf("Starte Server auf %s …", *addr) if err := http.ListenAndServe(*addr, nil); err != nil { log.Fatal(err) } } func staticDir() string { dir := "static" if _, err := os.Stat(dir); err == nil { return dir } return "." } func loadCSV(path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() r := csv.NewReader(f) r.FieldsPerRecord = -1 rows, err := r.ReadAll() if err != nil { return err } if len(rows) == 0 { return errors.New("leere CSV") } headers = rows[0] idxKey := 0 idxVal := 1 if len(headers) < 2 { return errors.New("mindestens zwei Spalten benötigt") } if *keyCol != "" { idxKey = indexOf(headers, *keyCol) if idxKey == -1 { return errUnknownCol("key", *keyCol, headers) } } if *valueCol != "" { idxVal = indexOf(headers, *valueCol) if idxVal == -1 { return errUnknownCol("value", *valueCol, headers) } } if *keyCol == "" { *keyCol = headers[idxKey] } if *valueCol == "" { *valueCol = headers[idxVal] } var recs []record for i := 1; i < len(rows); i++ { row := rows[i] if len(row) < len(headers) { tmp := make([]string, len(headers)) copy(tmp, row) row = tmp } m := make(record, len(headers)) for c, h := range headers { if c < len(row) { m[h] = row[c] } else { m[h] = "" } } recs = append(recs, m) } mu.Lock() records = recs mu.Unlock() return nil } func errUnknownCol(kind, name string, headers []string) error { return errors.New(kind + " column '" + name + "' nicht gefunden. Verfügbare Spalten: " + strings.Join(headers, ", ")) } func indexOf(ss []string, s string) int { for i, v := range ss { if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(s)) { return i } } return -1 } type searchResp struct { Query string `json:"query"` ExactHit *item `json:"exact_hit,omitempty"` TopCandidate *item `json:"top_candidate,omitempty"` Candidates []item `json:"candidates,omitempty"` Message string `json:"message,omitempty"` Headers []string `json:"headers,omitempty"` } type item struct { Key string `json:"key"` Value string `json:"value"` FullRow record `json:"full_row,omitempty"` } func handleIndex(w http.ResponseWriter, r *http.Request) { tpl := template.Must(template.New("index").Parse(indexHTML)) data := struct { File string KeyCol string ValCol string }{ File: filepath.Base(*csvPath), KeyCol: *keyCol, ValCol: *valueCol, } _ = tpl.Execute(w, data) } func handleSearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) w.Header().Set("Content-Type", "application/json; charset=utf-8") if q == "" { _ = json.NewEncoder(w).Encode(searchResp{Query: q, Message: "Gib einen Suchbegriff ein.", Headers: headers}) return } mu.RLock() defer mu.RUnlock() lq := strings.ToLower(q) var exact *item var partial []item for _, rec := range records { k := rec[*keyCol] if strings.EqualFold(k, q) { exact = &item{ Key: k, Value: rec[*valueCol], FullRow: rec, } break } if strings.Contains(strings.ToLower(k), lq) { partial = append(partial, item{ Key: k, Value: rec[*valueCol], FullRow: rec, }) } } resp := searchResp{Query: q, Headers: headers} if exact != nil { resp.ExactHit = exact } else if len(partial) > 0 { resp.TopCandidate = &partial[0] if len(partial) > 1 { limit := len(partial) if limit > 10 { limit = 10 } resp.Candidates = partial[:limit] } } else { resp.Message = "Kein Treffer." } _ = json.NewEncoder(w).Encode(resp) } const indexHTML = ` CSV-Suche

CSV-Suche

Datei: {{ .File }} • Suche in {{ .KeyCol }} → zeige {{ .ValCol }}

`