Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3734b919b6 | |||
| 8dee673c41 |
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: release-tag
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
release-image:
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
env:
|
||||||
|
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
|
||||||
|
DOCKER_LATEST: latest
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with: # replace it with your local IP
|
||||||
|
config-inline: |
|
||||||
|
[registry."${{ vars.DOCKER_REGISTRY }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: | # replace it with your local IP and tags
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||||
389
main.go
389
main.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -17,14 +18,18 @@ import (
|
|||||||
type record map[string]string
|
type record map[string]string
|
||||||
|
|
||||||
var (
|
var (
|
||||||
addr = flag.String("addr", ":8080", "HTTP listen address")
|
addr = flag.String("addr", ":8080", "HTTP listen address")
|
||||||
csvPath = flag.String("csv", "data.csv", "path to CSV with header row")
|
csvPath = flag.String("csv", "data.csv", "path to CSV with header row")
|
||||||
keyCol = flag.String("key", "", "column name to search in (default: first column)")
|
keyCol = flag.String("key", "", "column name to search in (default: first column)")
|
||||||
valueCol = flag.String("value", "", "column name to return (default: second 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
|
records []record
|
||||||
headers []string
|
headers []string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
exactMap map[string]record // canon(key) -> record
|
||||||
|
suffixMap map[string][]record // lastN(canon(key), N) -> records
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -33,24 +38,22 @@ func main() {
|
|||||||
if err := loadCSV(*csvPath); err != nil {
|
if err := loadCSV(*csvPath); err != nil {
|
||||||
log.Fatalf("CSV laden fehlgeschlagen: %v", err)
|
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("/", handleIndex)
|
||||||
http.HandleFunc("/search", handleSearch)
|
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)
|
log.Printf("Starte Server auf %s …", *addr)
|
||||||
if err := http.ListenAndServe(*addr, nil); err != nil {
|
if err := http.ListenAndServe(*addr, nil); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func staticDir() string {
|
func errUnknownCol(kind, name string, headers []string) error {
|
||||||
dir := "static"
|
return errors.New(kind + " column '" + name + "' nicht gefunden. Verfügbare Spalten: " + strings.Join(headers, ", "))
|
||||||
if _, err := os.Stat(dir); err == nil {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
return "."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadCSV(path string) error {
|
func loadCSV(path string) error {
|
||||||
@@ -61,6 +64,11 @@ func loadCSV(path string) error {
|
|||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
r := csv.NewReader(f)
|
r := csv.NewReader(f)
|
||||||
|
if *sep != "" {
|
||||||
|
r.Comma = []rune(*sep)[0]
|
||||||
|
} else {
|
||||||
|
r.Comma = ','
|
||||||
|
}
|
||||||
r.FieldsPerRecord = -1
|
r.FieldsPerRecord = -1
|
||||||
|
|
||||||
rows, err := r.ReadAll()
|
rows, err := r.ReadAll()
|
||||||
@@ -72,25 +80,27 @@ func loadCSV(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
headers = rows[0]
|
headers = rows[0]
|
||||||
idxKey := 0
|
|
||||||
idxVal := 1
|
|
||||||
if len(headers) < 2 {
|
if len(headers) < 2 {
|
||||||
return errors.New("mindestens zwei Spalten benötigt")
|
return errors.New("mindestens zwei Spalten benötigt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spaltenindizes bestimmen (EqualFold)
|
||||||
|
idxKey := 0
|
||||||
|
idxVal := 1
|
||||||
if *keyCol != "" {
|
if *keyCol != "" {
|
||||||
idxKey = indexOf(headers, *keyCol)
|
if i := indexOf(headers, *keyCol); i >= 0 {
|
||||||
if idxKey == -1 {
|
idxKey = i
|
||||||
|
} else {
|
||||||
return errUnknownCol("key", *keyCol, headers)
|
return errUnknownCol("key", *keyCol, headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if *valueCol != "" {
|
if *valueCol != "" {
|
||||||
idxVal = indexOf(headers, *valueCol)
|
if i := indexOf(headers, *valueCol); i >= 0 {
|
||||||
if idxVal == -1 {
|
idxVal = i
|
||||||
|
} else {
|
||||||
return errUnknownCol("value", *valueCol, headers)
|
return errUnknownCol("value", *valueCol, headers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if *keyCol == "" {
|
if *keyCol == "" {
|
||||||
*keyCol = headers[idxKey]
|
*keyCol = headers[idxKey]
|
||||||
}
|
}
|
||||||
@@ -98,6 +108,7 @@ func loadCSV(path string) error {
|
|||||||
*valueCol = headers[idxVal]
|
*valueCol = headers[idxVal]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zeilen einlesen + trimmen
|
||||||
var recs []record
|
var recs []record
|
||||||
for i := 1; i < len(rows); i++ {
|
for i := 1; i < len(rows); i++ {
|
||||||
row := rows[i]
|
row := rows[i]
|
||||||
@@ -108,23 +119,162 @@ func loadCSV(path string) error {
|
|||||||
}
|
}
|
||||||
m := make(record, len(headers))
|
m := make(record, len(headers))
|
||||||
for c, h := range headers {
|
for c, h := range headers {
|
||||||
|
val := ""
|
||||||
if c < len(row) {
|
if c < len(row) {
|
||||||
m[h] = row[c]
|
val = strings.TrimSpace(row[c])
|
||||||
} else {
|
|
||||||
m[h] = ""
|
|
||||||
}
|
}
|
||||||
|
m[h] = val
|
||||||
}
|
}
|
||||||
recs = append(recs, m)
|
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()
|
mu.Lock()
|
||||||
records = recs
|
records = recs
|
||||||
|
exactMap = ex
|
||||||
|
suffixMap = sx
|
||||||
mu.Unlock()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func errUnknownCol(kind, name string, headers []string) error {
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
return errors.New(kind + " column '" + name + "' nicht gefunden. Verfügbare Spalten: " + strings.Join(headers, ", "))
|
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 {
|
func indexOf(ss []string, s string) int {
|
||||||
@@ -136,13 +286,28 @@ func indexOf(ss []string, s string) int {
|
|||||||
return -1
|
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 {
|
type searchResp struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
ExactHit *item `json:"exact_hit,omitempty"`
|
ExactHit *item `json:"exact_hit,omitempty"`
|
||||||
TopCandidate *item `json:"top_candidate,omitempty"`
|
SuffixUsed bool `json:"suffix_used,omitempty"`
|
||||||
Candidates []item `json:"candidates,omitempty"`
|
TopCandidate *item `json:"top_candidate,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Candidates []item `json:"candidates,omitempty"`
|
||||||
Headers []string `json:"headers,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 {
|
type item struct {
|
||||||
@@ -151,71 +316,16 @@ type item struct {
|
|||||||
FullRow record `json:"full_row,omitempty"`
|
FullRow record `json:"full_row,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
func toItems(rs []record) []item {
|
||||||
tpl := template.Must(template.New("index").Parse(indexHTML))
|
out := make([]item, 0, len(rs))
|
||||||
data := struct {
|
for _, rec := range rs {
|
||||||
File string
|
out = append(out, item{
|
||||||
KeyCol string
|
Key: rec[*keyCol],
|
||||||
ValCol string
|
Value: rec[*valueCol],
|
||||||
}{
|
FullRow: rec,
|
||||||
File: filepath.Base(*csvPath),
|
})
|
||||||
KeyCol: *keyCol,
|
|
||||||
ValCol: *valueCol,
|
|
||||||
}
|
}
|
||||||
_ = tpl.Execute(w, data)
|
return out
|
||||||
}
|
|
||||||
|
|
||||||
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 = `
|
const indexHTML = `
|
||||||
@@ -227,8 +337,8 @@ const indexHTML = `
|
|||||||
<title>CSV-Suche</title>
|
<title>CSV-Suche</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
|
||||||
.wrap { max-width: 840px; margin: 0 auto; }
|
.wrap { max-width: 880px; margin: 0 auto; }
|
||||||
input[type="text"] { width: 100%; font-size: 1.25rem; padding: .75rem 1rem; box-sizing: border-box; }
|
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; }
|
.muted { color: #666; font-size: .95rem; margin-top: .5rem; }
|
||||||
.result { margin-top: 1rem; padding: 1rem; border: 1px solid #ddd; border-radius: .5rem; }
|
.result { margin-top: 1rem; padding: 1rem; border: 1px solid #ddd; border-radius: .5rem; }
|
||||||
.hit { font-weight: 600; margin-bottom: .5rem; }
|
.hit { font-weight: 600; margin-bottom: .5rem; }
|
||||||
@@ -237,16 +347,17 @@ const indexHTML = `
|
|||||||
.row { padding: .25rem 0; border-bottom: 1px dashed #eee; }
|
.row { padding: .25rem 0; border-bottom: 1px dashed #eee; }
|
||||||
code { background: #f6f8fa; padding: .15rem .35rem; border-radius: .25rem; }
|
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; }
|
.pill { display: inline-block; padding: .1rem .5rem; border: 1px solid #ddd; border-radius: 999px; margin-right: .25rem; font-size: .85rem; }
|
||||||
|
.green { color: #0a7d32; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>CSV-Suche</h1>
|
<h1>Barcode-Suche</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Datei: <code>{{ .File }}</code> • Suche in <span class="pill">{{ .KeyCol }}</span> → zeige <span class="pill">{{ .ValCol }}</span>
|
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>
|
</p>
|
||||||
|
|
||||||
<input id="q" type="text" placeholder="Suchtext eingeben …" autocomplete="off" autofocus />
|
<input id="q" type="text" placeholder="Barcode scannen …" autocomplete="off" autofocus />
|
||||||
|
|
||||||
<div id="result" class="result" aria-live="polite"></div>
|
<div id="result" class="result" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,66 +367,75 @@ const indexHTML = `
|
|||||||
var qEl = document.getElementById('q');
|
var qEl = document.getElementById('q');
|
||||||
var resEl = document.getElementById('result');
|
var resEl = document.getElementById('result');
|
||||||
|
|
||||||
// Fokus immer halten
|
// Fokus halten & Starttext
|
||||||
window.addEventListener('load', function() { qEl.focus(); qEl.select(); });
|
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(); });
|
window.addEventListener('click', function() { qEl.focus(); });
|
||||||
|
|
||||||
var timer = null;
|
// NUR Enter löst eine Suche aus
|
||||||
qEl.addEventListener('input', function() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(runSearch, 120);
|
|
||||||
});
|
|
||||||
qEl.addEventListener('keydown', function(e) {
|
qEl.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
runSearch(true);
|
var submitted = qEl.value.trim();
|
||||||
|
if (!submitted) { qEl.focus(); return; }
|
||||||
|
runSearch(true, submitted, true); // highlight=true, clearAfter=true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function runSearch(highlight) {
|
/**
|
||||||
var q = qEl.value.trim();
|
* 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) {
|
if (!q) {
|
||||||
resEl.innerHTML = '<span class="muted">Gib einen Suchbegriff ein.</span>';
|
resEl.innerHTML = '<span class="muted">Gib einen Barcode ein und bestätige mit Enter.</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch('/search?q=' + encodeURIComponent(q))
|
fetch('/search?q=' + encodeURIComponent(q))
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(d) { render(d, !!highlight); })
|
||||||
render(data, !!highlight);
|
.catch(function() { resEl.innerHTML = '<span class="muted">Fehler bei der Suche.</span>'; })
|
||||||
|
.finally(function() {
|
||||||
|
if (clearAfter) { qEl.value = ''; }
|
||||||
qEl.focus();
|
qEl.focus();
|
||||||
var len = qEl.value.length;
|
try {
|
||||||
try { qEl.setSelectionRange(len, len); } catch(e) {}
|
var len = qEl.value.length;
|
||||||
})
|
qEl.setSelectionRange(len, len);
|
||||||
.catch(function() {
|
} catch(e) {}
|
||||||
resEl.innerHTML = '<span class="muted">Fehler bei der Suche.</span>';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(d, highlight) {
|
function render(d, highlight) {
|
||||||
if (d.ExactHit) {
|
if (d.exact_hit) {
|
||||||
resEl.innerHTML =
|
resEl.innerHTML =
|
||||||
'<div class="hit">Exakter Treffer für <code>' + escapeHtml(d.Query) + '</code>:</div>' +
|
'<div class="hit">Exakter Treffer für <code>' + escapeHtml(d.query) + '</code>:</div>' +
|
||||||
'<div class="value">' + escapeHtml(d.ExactHit.Value || '') + '</div>' +
|
'<div class="value"><h2>' + escapeHtml(d.exact_hit.value || '') + '</h2></div>' +
|
||||||
renderRow(d.ExactHit.FullRow, d.Headers);
|
renderRow(d.exact_hit.full_row, d.headers);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.TopCandidate) {
|
if (d.top_candidate) {
|
||||||
|
var badge = d.suffix_used ? ' <span class="pill green">Suffix-Match (' + (d.suffix_len||'') + ')</span>' : '';
|
||||||
var list = '';
|
var list = '';
|
||||||
if (d.Candidates && d.Candidates.length > 0) {
|
if (d.candidates && d.candidates.length > 0) {
|
||||||
list = '<div class="list"><div class="muted">Weitere Kandidaten:</div>' +
|
list = '<div class="list"><div class="muted">Weitere Kandidaten:</div>' +
|
||||||
d.Candidates.map(function(c) {
|
d.candidates.map(function(c) {
|
||||||
return '<div class="row"><code>' + escapeHtml(c.Key) + '</code> → ' + escapeHtml(c.Value || '') + '</div>';
|
return '<div class="row"><code>' + escapeHtml(c.key) + '</code> → ' + escapeHtml(c.value || '') + '</div>';
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
resEl.innerHTML =
|
resEl.innerHTML =
|
||||||
'<div class="hit">Bester Treffer zu <code>' + escapeHtml(d.Query) + '</code>:</div>' +
|
'<div class="hit">Bester Treffer zu <code>' + escapeHtml(d.query) + '</code>:' + badge + '</div>' +
|
||||||
'<div class="value">' + escapeHtml(d.TopCandidate.Value || '') + '</div>' +
|
'<div class="value"><h2>' + escapeHtml(d.top_candidate.value || '') + '</h2></div>' +
|
||||||
renderRow(d.TopCandidate.FullRow, d.Headers) +
|
renderRow(d.top_candidate.full_row, d.headers) +
|
||||||
list;
|
list;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resEl.innerHTML = '<span class="muted">' + (d.Message || 'Kein Treffer.') + '</span>';
|
resEl.innerHTML = '<span class="muted">' + (d.message || 'Kein Treffer.') + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(row, headers) {
|
function renderRow(row, headers) {
|
||||||
@@ -331,11 +451,10 @@ const indexHTML = `
|
|||||||
return ({'&':'&','<':'<','>':'>','"':'"', "'":'''}[m]);
|
return ({'&':'&','<':'<','>':'>','"':'"', "'":'''}[m]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial
|
|
||||||
runSearch(false);
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user