diff --git a/main.go b/main.go index 0bb003f..f467225 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "flag" + "fmt" "html/template" "log" "net/http" @@ -17,14 +18,18 @@ import ( 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)") + 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)") + suffixLen = flag.Int("suffix", 0, "if >0, also match by last N characters of the scanned code") + sep = flag.String("sep", ",", "CSV field separator (e.g. ',' or ';')") - records []record - headers []string - mu sync.RWMutex + records []record + headers []string + mu sync.RWMutex + exactMap map[string]record // canon(key) -> record + suffixMap map[string][]record // lastN(canon(key), N) -> records ) func main() { @@ -33,24 +38,22 @@ func main() { 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)) + log.Printf("CSV geladen: %s (%d Zeilen, %d Spalten) | key=%q value=%q sep=%q suffix=%d", + *csvPath, len(records), len(headers), *keyCol, *valueCol, *sep, *suffixLen) http.HandleFunc("/", handleIndex) http.HandleFunc("/search", handleSearch) - http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir())))) + fmt.Println(headers) + fmt.Println(records) 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 errUnknownCol(kind, name string, headers []string) error { + return errors.New(kind + " column '" + name + "' nicht gefunden. Verfügbare Spalten: " + strings.Join(headers, ", ")) } func loadCSV(path string) error { @@ -61,6 +64,11 @@ func loadCSV(path string) error { defer f.Close() r := csv.NewReader(f) + if *sep != "" { + r.Comma = []rune(*sep)[0] + } else { + r.Comma = ',' + } r.FieldsPerRecord = -1 rows, err := r.ReadAll() @@ -72,25 +80,27 @@ func loadCSV(path string) error { } headers = rows[0] - idxKey := 0 - idxVal := 1 if len(headers) < 2 { return errors.New("mindestens zwei Spalten benötigt") } + // Spaltenindizes bestimmen (EqualFold) + idxKey := 0 + idxVal := 1 if *keyCol != "" { - idxKey = indexOf(headers, *keyCol) - if idxKey == -1 { + if i := indexOf(headers, *keyCol); i >= 0 { + idxKey = i + } else { return errUnknownCol("key", *keyCol, headers) } } if *valueCol != "" { - idxVal = indexOf(headers, *valueCol) - if idxVal == -1 { + if i := indexOf(headers, *valueCol); i >= 0 { + idxVal = i + } else { return errUnknownCol("value", *valueCol, headers) } } - if *keyCol == "" { *keyCol = headers[idxKey] } @@ -98,6 +108,7 @@ func loadCSV(path string) error { *valueCol = headers[idxVal] } + // Zeilen einlesen + trimmen var recs []record for i := 1; i < len(rows); i++ { row := rows[i] @@ -108,23 +119,162 @@ func loadCSV(path string) error { } m := make(record, len(headers)) for c, h := range headers { + val := "" if c < len(row) { - m[h] = row[c] - } else { - m[h] = "" + val = strings.TrimSpace(row[c]) } + m[h] = val } recs = append(recs, m) } + // Indizes bauen + ex := make(map[string]record, len(recs)) + var sx map[string][]record + if *suffixLen > 0 { + sx = make(map[string][]record, len(recs)) + } + for _, rec := range recs { + k := rec[*keyCol] + if k == "" { + continue + } + ck := canon(k) + ex[ck] = rec + if *suffixLen > 0 { + suf := lastN(ck, *suffixLen) + if suf != "" { + sx[suf] = append(sx[suf], rec) + } + } + } + mu.Lock() records = recs + exactMap = ex + suffixMap = sx mu.Unlock() + + // kleine Sichtprobe für Debug + log.Printf("Header: %v", headers) + if len(recs) > 0 { + log.Printf("Sample Key raw=%q canon=%q", recs[0][*keyCol], canon(recs[0][*keyCol])) + } + 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 handleIndex(w http.ResponseWriter, r *http.Request) { + tpl := template.Must(template.New("index").Parse(indexHTML)) + data := struct { + File string + KeyCol string + ValCol string + Suffix int + HasSuf bool + }{ + File: filepath.Base(*csvPath), + KeyCol: *keyCol, + ValCol: *valueCol, + Suffix: *suffixLen, + HasSuf: *suffixLen > 0, + } + _ = tpl.Execute(w, data) +} + +func handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + qc := canon(q) // WICHTIG: normalisieren + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + if q == "" { + _ = json.NewEncoder(w).Encode(searchResp{ + Query: q, + Message: "Gib einen Barcode ein.", + Headers: headers, + SuffixLen: *suffixLen, + SearchKeyCol: *keyCol, + ReturnValueCol: *valueCol, + }) + return + } + + mu.RLock() + defer mu.RUnlock() + + resp := searchResp{ + Query: q, + Headers: headers, + SuffixLen: *suffixLen, + SearchKeyCol: *keyCol, + ReturnValueCol: *valueCol, + } + + // 1) Exakter Treffer (case/whitespace-insensitive) + if rec, ok := exactMap[qc]; ok { + resp.ExactHit = &item{ + Key: rec[*keyCol], + Value: rec[*valueCol], + FullRow: rec, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + + // 2) Suffix-Treffer (letzte N Zeichen) mit canon + if *suffixLen > 0 && len(qc) >= *suffixLen { + suf := lastN(qc, *suffixLen) + if cands, ok := suffixMap[suf]; ok && len(cands) > 0 { + resp.SuffixUsed = true + resp.TopCandidate = &item{ + Key: cands[0][*keyCol], + Value: cands[0][*valueCol], + FullRow: cands[0], + } + if len(cands) > 1 { + limit := len(cands) + if limit > 10 { + limit = 10 + } + resp.Candidates = toItems(cands[1:limit]) + } + _ = json.NewEncoder(w).Encode(resp) + return + } + } + + // 3) Teiltreffer (Substring) – in der Key-Spalte + var partial []item + for _, rec := range records { + if strings.Contains(canon(rec[*keyCol]), qc) { + partial = append(partial, item{ + Key: rec[*keyCol], + Value: rec[*valueCol], + FullRow: rec, + }) + if len(partial) >= 20 { + break + } + } + } + if len(partial) > 0 { + resp.TopCandidate = &partial[0] + if len(partial) > 1 { + if len(partial) > 10 { + resp.Candidates = partial[1:10] + } else { + resp.Candidates = partial[1:] + } + } + } else { + resp.Message = "Kein Treffer." + } + + _ = json.NewEncoder(w).Encode(resp) +} + +func canon(s string) string { + return strings.ToLower(strings.TrimSpace(s)) } func indexOf(ss []string, s string) int { @@ -136,13 +286,28 @@ func indexOf(ss []string, s string) int { return -1 } +func lastN(s string, n int) string { + if n <= 0 { + return "" + } + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[len(r)-n:]) +} + 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"` + Query string `json:"query"` + ExactHit *item `json:"exact_hit,omitempty"` + SuffixUsed bool `json:"suffix_used,omitempty"` + TopCandidate *item `json:"top_candidate,omitempty"` + Candidates []item `json:"candidates,omitempty"` + Message string `json:"message,omitempty"` + Headers []string `json:"headers,omitempty"` + SuffixLen int `json:"suffix_len,omitempty"` + SearchKeyCol string `json:"search_key_col,omitempty"` + ReturnValueCol string `json:"return_value_col,omitempty"` } type item struct { @@ -151,71 +316,16 @@ type item struct { 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, +func toItems(rs []record) []item { + out := make([]item, 0, len(rs)) + for _, rec := range rs { + out = append(out, item{ + Key: rec[*keyCol], + Value: rec[*valueCol], + FullRow: rec, + }) } - _ = 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) + return out } const indexHTML = ` @@ -227,8 +337,8 @@ const indexHTML = `
- Datei: {{ .File }} • Suche in {{ .KeyCol }} → zeige {{ .ValCol }}
+ Datei: {{ .File }} • Suche in {{ .KeyCol }} → zeige {{ .ValCol }}{{if .HasSuf}} • Suffix: {{ .Suffix }}{{end}}
' + escapeHtml(d.Query) + ':' + escapeHtml(d.query) + ':' + escapeHtml(c.Key) + ' → ' + escapeHtml(c.Value || '') + '' + escapeHtml(c.key) + ' → ' + escapeHtml(c.value || '') + '' + escapeHtml(d.Query) + ':' + escapeHtml(d.query) + ':' + badge + '