init
This commit is contained in:
341
main.go
Normal file
341
main.go
Normal file
@@ -0,0 +1,341 @@
|
||||
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 = `
|
||||
<!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: 840px; margin: 0 auto; }
|
||||
input[type="text"] { width: 100%; font-size: 1.25rem; padding: .75rem 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>CSV-Suche</h1>
|
||||
<p class="muted">
|
||||
Datei: <code>{{ .File }}</code> • Suche in <span class="pill">{{ .KeyCol }}</span> → zeige <span class="pill">{{ .ValCol }}</span>
|
||||
</p>
|
||||
|
||||
<input id="q" type="text" placeholder="Suchtext eingeben …" 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 immer halten
|
||||
window.addEventListener('load', function() { qEl.focus(); qEl.select(); });
|
||||
window.addEventListener('click', function() { qEl.focus(); });
|
||||
|
||||
var timer = null;
|
||||
qEl.addEventListener('input', function() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(runSearch, 120);
|
||||
});
|
||||
qEl.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runSearch(true);
|
||||
}
|
||||
});
|
||||
|
||||
function runSearch(highlight) {
|
||||
var q = qEl.value.trim();
|
||||
if (!q) {
|
||||
resEl.innerHTML = '<span class="muted">Gib einen Suchbegriff ein.</span>';
|
||||
return;
|
||||
}
|
||||
fetch('/search?q=' + encodeURIComponent(q))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
render(data, !!highlight);
|
||||
qEl.focus();
|
||||
var len = qEl.value.length;
|
||||
try { qEl.setSelectionRange(len, len); } catch(e) {}
|
||||
})
|
||||
.catch(function() {
|
||||
resEl.innerHTML = '<span class="muted">Fehler bei der Suche.</span>';
|
||||
});
|
||||
}
|
||||
|
||||
function render(d, highlight) {
|
||||
if (d.ExactHit) {
|
||||
resEl.innerHTML =
|
||||
'<div class="hit">Exakter Treffer für <code>' + escapeHtml(d.Query) + '</code>:</div>' +
|
||||
'<div class="value">' + escapeHtml(d.ExactHit.Value || '') + '</div>' +
|
||||
renderRow(d.ExactHit.FullRow, d.Headers);
|
||||
return;
|
||||
}
|
||||
if (d.TopCandidate) {
|
||||
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>:</div>' +
|
||||
'<div class="value">' + escapeHtml(d.TopCandidate.Value || '') + '</div>' +
|
||||
renderRow(d.TopCandidate.FullRow, 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 ({'&':'&','<':'<','>':'>','"':'"', "'":'''}[m]);
|
||||
});
|
||||
}
|
||||
|
||||
// initial
|
||||
runSearch(false);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
Reference in New Issue
Block a user