Files
snscanner/main.go
groot 8dee673c41
All checks were successful
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
build-binaries / publish-agent (push) Has been skipped
main.go aktualisiert
2025-11-06 07:48:29 +00:00

461 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/csv"
"encoding/json"
"errors"
"flag"
"fmt"
"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)")
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
exactMap map[string]record // canon(key) -> record
suffixMap map[string][]record // lastN(canon(key), N) -> records
)
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) | key=%q value=%q sep=%q suffix=%d",
*csvPath, len(records), len(headers), *keyCol, *valueCol, *sep, *suffixLen)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/search", handleSearch)
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 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 {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
r := csv.NewReader(f)
if *sep != "" {
r.Comma = []rune(*sep)[0]
} else {
r.Comma = ','
}
r.FieldsPerRecord = -1
rows, err := r.ReadAll()
if err != nil {
return err
}
if len(rows) == 0 {
return errors.New("leere CSV")
}
headers = rows[0]
if len(headers) < 2 {
return errors.New("mindestens zwei Spalten benötigt")
}
// Spaltenindizes bestimmen (EqualFold)
idxKey := 0
idxVal := 1
if *keyCol != "" {
if i := indexOf(headers, *keyCol); i >= 0 {
idxKey = i
} else {
return errUnknownCol("key", *keyCol, headers)
}
}
if *valueCol != "" {
if i := indexOf(headers, *valueCol); i >= 0 {
idxVal = i
} else {
return errUnknownCol("value", *valueCol, headers)
}
}
if *keyCol == "" {
*keyCol = headers[idxKey]
}
if *valueCol == "" {
*valueCol = headers[idxVal]
}
// Zeilen einlesen + trimmen
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 {
val := ""
if c < len(row) {
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 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 {
for i, v := range ss {
if strings.EqualFold(strings.TrimSpace(v), strings.TrimSpace(s)) {
return i
}
}
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"`
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 {
Key string `json:"key"`
Value string `json:"value"`
FullRow record `json:"full_row,omitempty"`
}
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,
})
}
return out
}
const indexHTML = `
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CSV-Suche</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
.wrap { max-width: 880px; margin: 0 auto; }
input[type="text"] { width: 100%; font-size: 1.35rem; padding: .8rem 1rem; box-sizing: border-box; }
.muted { color: #666; font-size: .95rem; margin-top: .5rem; }
.result { margin-top: 1rem; padding: 1rem; border: 1px solid #ddd; border-radius: .5rem; }
.hit { font-weight: 600; margin-bottom: .5rem; }
.value { font-size: 1.15rem; }
.list { margin-top: .5rem; }
.row { padding: .25rem 0; border-bottom: 1px dashed #eee; }
code { background: #f6f8fa; padding: .15rem .35rem; border-radius: .25rem; }
.pill { display: inline-block; padding: .1rem .5rem; border: 1px solid #ddd; border-radius: 999px; margin-right: .25rem; font-size: .85rem; }
.green { color: #0a7d32; }
</style>
</head>
<body>
<div class="wrap">
<h1>Barcode-Suche</h1>
<p class="muted">
Datei: <code>{{ .File }}</code> • Suche in <span class="pill">{{ .KeyCol }}</span> → zeige <span class="pill">{{ .ValCol }}</span>{{if .HasSuf}} • Suffix: <span class="pill">{{ .Suffix }}</span>{{end}}
</p>
<input id="q" type="text" placeholder="Barcode scannen …" autocomplete="off" autofocus />
<div id="result" class="result" aria-live="polite"></div>
</div>
<script>
(function() {
var qEl = document.getElementById('q');
var resEl = document.getElementById('result');
// Fokus halten & Starttext
window.addEventListener('load', function() {
qEl.focus(); qEl.select();
resEl.innerHTML = '<span class="muted">Gib einen Barcode ein und bestätige mit Enter.</span>';
});
window.addEventListener('click', function() { qEl.focus(); });
// NUR Enter löst eine Suche aus
qEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
var submitted = qEl.value.trim();
if (!submitted) { qEl.focus(); return; }
runSearch(true, submitted, true); // highlight=true, clearAfter=true
}
});
/**
* runSearch(highlight, qOverride, clearAfter)
* - highlight: bool (nur als Flag nutzbar, falls du später Styling willst)
* - qOverride: string | null, wenn gesetzt wird dieser Wert verwendet
* - clearAfter: bool, Eingabefeld nach dem Request leeren
*/
function runSearch(highlight, qOverride, clearAfter) {
var q = (qOverride !== null && qOverride !== undefined) ? qOverride : qEl.value.trim();
if (!q) {
resEl.innerHTML = '<span class="muted">Gib einen Barcode ein und bestätige mit Enter.</span>';
return;
}
fetch('/search?q=' + encodeURIComponent(q))
.then(function(r) { return r.json(); })
.then(function(d) { render(d, !!highlight); })
.catch(function() { resEl.innerHTML = '<span class="muted">Fehler bei der Suche.</span>'; })
.finally(function() {
if (clearAfter) { qEl.value = ''; }
qEl.focus();
try {
var len = qEl.value.length;
qEl.setSelectionRange(len, len);
} catch(e) {}
});
}
function render(d, highlight) {
if (d.exact_hit) {
resEl.innerHTML =
'<div class="hit">Exakter Treffer für <code>' + escapeHtml(d.query) + '</code>:</div>' +
'<div class="value"><h2>' + escapeHtml(d.exact_hit.value || '') + '</h2></div>' +
renderRow(d.exact_hit.full_row, d.headers);
return;
}
if (d.top_candidate) {
var badge = d.suffix_used ? ' <span class="pill green">Suffix-Match (' + (d.suffix_len||'') + ')</span>' : '';
var list = '';
if (d.candidates && d.candidates.length > 0) {
list = '<div class="list"><div class="muted">Weitere Kandidaten:</div>' +
d.candidates.map(function(c) {
return '<div class="row"><code>' + escapeHtml(c.key) + '</code> → ' + escapeHtml(c.value || '') + '</div>';
}).join('') +
'</div>';
}
resEl.innerHTML =
'<div class="hit">Bester Treffer zu <code>' + escapeHtml(d.query) + '</code>:' + badge + '</div>' +
'<div class="value"><h2>' + escapeHtml(d.top_candidate.value || '') + '</h2></div>' +
renderRow(d.top_candidate.full_row, d.headers) +
list;
return;
}
resEl.innerHTML = '<span class="muted">' + (d.message || 'Kein Treffer.') + '</span>';
}
function renderRow(row, headers) {
if (!row || !headers) return '';
var rows = headers.map(function(h) {
return '<div class="row"><strong>' + escapeHtml(h) + ':</strong> ' + escapeHtml(row[h] || '') + '</div>';
}).join('');
return '<div class="list" style="margin-top:.75rem">' + rows + '</div>';
}
function escapeHtml(s) {
return String(s).replace(/[&<>\"']/g, function(m) {
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;', "'":'&#039;'}[m]);
});
}
})();
</script>
</body>
</html>
`