init
All checks were successful
release-tag / release-image (push) Successful in 1m35s

This commit is contained in:
2026-07-28 23:11:40 +02:00
parent 8eb01af82e
commit e5bc62ebf2
23 changed files with 3976 additions and 1 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
.gitignore
backups
knowledge
*.zip
kb-editor

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# Betriebsmodus desselben Images:
# editor = vollständiger Einzel-/Masseneditor mit Schreibzugriff
# google = schreibgeschützte Helpdesk-Suchmaschine mit Artikel-Viewer
APP_MODE=editor
# Optionales Branding. Leer lassen, um die zum Modus passenden Standards zu verwenden.
# Editor-Standard: "Knowledge Base Editor"
# Google-Standard: "Helpdesk Search"
APP_TITLE=
APP_SUBTITLE=
# Automatisches Neu-Einlesen des Index.
# Standard: google=60s, editor=aus. "0" oder "off" deaktiviert.
# AUTO_RELOAD_INTERVAL=60s
# Pfad zum Verzeichnis mit den JSON-Dateien auf dem Docker-Host.
# Beispiel für dein Kompendium:
# KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge
KB_DATA_PATH=./knowledge
# Im Google-Modus empfiehlt sich "ro" als zusätzliche Docker-Schutzschicht.
# Im Editor-Modus muss dieser Wert "rw" sein.
KB_DATA_MOUNT_MODE=rw
# Backups werden im Editor-Modus getrennt von den Produktivdateien gespeichert.
KB_BACKUP_PATH=./backups
KB_EDITOR_PORT=8080
# Optional. Entweder beide Werte setzen oder beide leer lassen.
# Für Zugriff außerhalb eines vertrauenswürdigen lokalen Netzes dringend empfohlen.
BASIC_AUTH_USER=
BASIC_AUTH_PASSWORD=

View File

@@ -0,0 +1,51 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-latest
env:
DOCKER_ORG: sendnrw
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."git.send.nrw"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: git.send.nrw # 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
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/backups/*
!/backups/.gitkeep
/knowledge/*
!/knowledge/.gitkeep
/kb-editor
.env
.DS_Store

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/kb-helpdesk ./cmd/server
FROM alpine:3.24
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /out/kb-helpdesk /usr/local/bin/kb-helpdesk
ENV APP_MODE=editor \
DATA_DIR=/data/knowledge \
BACKUP_DIR=/data/backups \
LISTEN_ADDR=:8080
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/kb-helpdesk"]

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: build test run run-google docker clean
build:
go build -o kb-helpdesk ./cmd/server
test:
go test ./...
run:
APP_MODE=editor go run ./cmd/server -data ./knowledge
run-google:
APP_MODE=google go run ./cmd/server -data ./knowledge
docker:
docker compose up --build
clean:
rm -f kb-helpdesk kb-editor

329
README.md
View File

@@ -1,2 +1,329 @@
# glpi-ai-knowledgebase
# KB Helpdesk Editor & Google-Modus
Ein einziges Go-/Docker-Image für zwei Rollen auf derselben JSON-Wissensbasis:
1. **Editor-Modus** vollständiger Einzel- und Masseneditor mit Backups.
2. **Google-Modus** moderne, schreibgeschützte interne Helpdesk-Suche mit Artikel-Viewer.
Der Betriebsmodus wird ausschließlich über `APP_MODE` gewählt. Es ist kein zweiter Build und kein anderes Image nötig.
## Modi
### `APP_MODE=editor`
Der bekannte Administrationsmodus:
- Volltextsuche über ID, Titel, Text, Antwort, Keywords, Kategorien, Quelle und Pfad
- Filter für `auto_reply`, Sprache, Kommunikationsstil und Quelle
- Pagination für große Bestände mit 10.000+ Dateien
- Einzelbearbeitung als Formular
- vollständiger Raw-JSON-Editor
- Massenbearbeitung für Auswahl oder alle aktuellen Treffer
- Bulk-Setzen von `auto_reply`, `min_score`, `language`, `communication_style`, `source`, `source_uri`
- Keywords/Kategorien hinzufügen oder entfernen
- Suchen & Ersetzen in `title`, `text` und `answer`, optional per Regex
- Dry-Run/Vorschau vor Massenänderungen
- automatische Backups
- atomisches Schreiben per Temp-Datei + Rename
- Schutz vor extern veränderten Dateien
### `APP_MODE=google`
Reiner Helpdesk-/Viewer-Modus:
- große, reduzierte Suchoberfläche im Stil einer internen Suchmaschine
- Relevanzranking statt alphabetischer Trefferreihenfolge
- Gewichtung von ID/Fehlercode, Titel, Keywords, Kategorien, Problemtext und Antwort
- hervorgehobene Suchbegriffe
- Treffer-Auszüge aus Problem bzw. Lösung
- dynamische Schnellzugriffe aus den häufigsten Kategorien
- Pagination und URL-basierte Suchanfragen
- lesefreundlicher Artikel-Viewer
- Problem und Lösung visuell getrennt
- Antwort direkt in die Zwischenablage kopieren
- dauerhafter Link zu einem geöffneten Wissensartikel
- Quellenlink, sofern `source_uri` vorhanden ist
- responsive Oberfläche für Desktop, Tablet und Smartphone
- automatisches Neu-Einlesen des Dateiindex (standardmäßig alle 60 Sekunden)
- **keine Bearbeitungsoberfläche**
- **PUT-/Bulk-/Reload-Endpunkte werden serverseitig mit HTTP 403 gesperrt**
`viewer` und `search` werden zusätzlich als Alias für `google` akzeptiert. Für Deployments sollte aus Gründen der Eindeutigkeit `editor` oder `google` verwendet werden.
## Schnellstart
```bash
cp .env.example .env
docker compose up --build -d
```
Danach:
```text
http://localhost:8080
```
## Editor-Deployment
`.env`:
```dotenv
APP_MODE=editor
APP_TITLE=Knowledge Base Editor
APP_SUBTITLE=JSON · Massenbearbeitung · Docker
KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge
KB_DATA_MOUNT_MODE=rw
KB_BACKUP_PATH=./backups
KB_EDITOR_PORT=8080
BASIC_AUTH_USER=admin
BASIC_AUTH_PASSWORD=ein-langes-zufaelliges-passwort
```
Wichtig: Der Editor benötigt für das Knowledge-Verzeichnis `rw`.
## Google-/Helpdesk-Deployment
Dasselbe Image, nur andere ENV-Werte:
```dotenv
APP_MODE=google
APP_TITLE=IT Helpdesk Wissen
APP_SUBTITLE=Interne Lösungsdatenbank für Support und Service Desk
AUTO_RELOAD_INTERVAL=60s
KB_DATA_PATH=../glpi-ai-agent-kb-microsoft-errorcodes-kompendium/knowledge
KB_DATA_MOUNT_MODE=ro
KB_BACKUP_PATH=./backups
KB_EDITOR_PORT=8081
BASIC_AUTH_USER=helpdesk
BASIC_AUTH_PASSWORD=ein-langes-zufaelliges-passwort
```
Für den Google-Modus wird `KB_DATA_MOUNT_MODE=ro` empfohlen. Damit existieren zwei Schutzschichten:
1. Die Go-Anwendung stellt keine schreibende Funktion bereit und blockiert die schreibenden API-Endpunkte.
2. Docker mountet die JSON-Dateien zusätzlich read-only.
Der Backup-Pfad wird im Google-Modus nicht benutzt; er bleibt nur Teil derselben Compose-Konfiguration.
## Ein Image, zwei Container
Als fertiges Beispiel liegt `docker-compose.dual.yml` bei. Es startet denselben Build gleichzeitig als Editor auf Port 8080 und als read-only Helpdesk-Suche auf Port 8081:
```bash
docker compose -f docker-compose.dual.yml up --build -d
```
Alternativ kann ein gebautes Image manuell zweimal gestartet werden:
```bash
docker build -t kb-helpdesk:local .
```
Editor:
```bash
docker run -d \
--name kb-editor \
-p 8080:8080 \
-e APP_MODE=editor \
-e APP_TITLE="KB Administration" \
-v /srv/kb/knowledge:/data/knowledge:rw \
-v /srv/kb/backups:/data/backups:rw \
kb-helpdesk:local
```
Helpdesk-Suche:
```bash
docker run -d \
--name kb-search \
-p 8081:8080 \
-e APP_MODE=google \
-e APP_TITLE="IT Helpdesk Wissen" \
-e APP_SUBTITLE="Interne Lösungsdatenbank" \
-v /srv/kb/knowledge:/data/knowledge:ro \
kb-helpdesk:local
```
Beide Container lesen damit denselben Bestand. Im Google-Modus wird der Dateiindex standardmäßig alle 60 Sekunden automatisch neu aufgebaut, sodass Änderungen aus dem Editor ohne Container-Neustart sichtbar werden. Mit `AUTO_RELOAD_INTERVAL=0` kann das deaktiviert werden. Der Editor besitzt zusätzlich einen manuellen Reload-Button.
## Konfiguration
| Variable | Standard | Bedeutung |
|---|---|---|
| `APP_MODE` | `editor` | `editor` oder `google`; zusätzlich Aliase `viewer`/`search` |
| `APP_TITLE` | modusabhängig | Name in Browser und Kopfzeile |
| `APP_SUBTITLE` | modusabhängig | Untertitel/Helpdesk-Beschreibung |
| `AUTO_RELOAD_INTERVAL` | Google: `60s`, Editor: aus | Dateiindex regelmäßig neu aufbauen; `0`/`off` deaktiviert |
| `DATA_DIR` | `./data/knowledge` | Wurzelverzeichnis der JSON-Dateien |
| `BACKUP_DIR` | `.kb-editor-backups` neben dem Datenordner | Backup-Ziel im Editor-Modus |
| `LISTEN_ADDR` | `:8080` | HTTP Listen-Adresse |
| `BASIC_AUTH_USER` | leer | Optionaler Basic-Auth-Benutzer |
| `BASIC_AUTH_PASSWORD` | leer | Optionales Basic-Auth-Passwort |
| `KB_DATA_PATH` | `./knowledge` | Hostpfad für Docker Compose |
| `KB_DATA_MOUNT_MODE` | `rw` | `rw` für Editor, empfohlen `ro` für Google-Modus |
| `KB_BACKUP_PATH` | `./backups` | Hostpfad für Backups |
| `KB_EDITOR_PORT` | `8080` | veröffentlichter Host-Port |
## Suche und Ranking im Google-Modus
Eine Suche muss alle eingegebenen Suchbegriffe im indexierten Dokument finden. Anschließend werden die Treffer gewichtet. Besonders hoch bewertet werden:
1. exakte ID-/Fehlercode-Treffer,
2. Titel,
3. Keywords,
4. Kategorien,
5. Problem-/Erkennungstext,
6. Antwort/Lösung,
7. Quelle.
Dadurch steht beispielsweise ein Artikel mit `0x80070005` direkt in ID/Titel vor einem Artikel, der denselben Code nur beiläufig im Lösungstext erwähnt.
Die Such-URL ist teilbar:
```text
/?q=0x80070005
```
Ein geöffneter Artikel erhält zusätzlich `doc=<interner-key>` und kann so intern direkt verlinkt werden.
## Tastatur
Im Google-Modus fokussiert `/` von überall die Suche.
Im Editor gelten zusätzlich die bereits vorhandenen Tastaturfunktionen, unter anderem `Ctrl+S`/`Cmd+S` zum Speichern.
## JSON-Verhalten
Die Anwendung arbeitet direkt mit `.json`-Dateien. Für Suche und Navigation liegt ein Index im RAM. Unbekannte zusätzliche JSON-Felder bleiben beim Bearbeiten erhalten.
Das bekannte Schema kann beispielsweise enthalten:
```json
{
"id": "KB-MSERR-...",
"title": "...",
"text": "...",
"answer": "...",
"auto_reply": true,
"min_score": 0.78,
"categories": ["Windows"],
"keywords": ["0x80070005"],
"source": "Microsoft Learn",
"source_uri": "https://learn.microsoft.com/...",
"language": "de-DE",
"communication_style": "formal"
}
```
## Backups im Editor-Modus
Bei einem normalen Speichern entsteht ein Zeitstempelverzeichnis, zum Beispiel:
```text
backups/
└── 20260728-153012.123456789/
└── KB-MSERR-ACT-00001.json
```
Bei einer Massenänderung werden alle Originaldateien desselben Vorgangs gemeinsam gesichert. Die relative Unterverzeichnisstruktur bleibt erhalten.
Backups werden nicht automatisch gelöscht.
## Externe Dateiänderungen
Der Editor erkennt beim Speichern, wenn die betreffende Datei seit dem Indexieren außerhalb der Anwendung verändert wurde. In diesem Fall wird das Überschreiben verweigert.
Der Index wird beim Prozessstart aufgebaut. Im Google-Modus wird er standardmäßig alle 60 Sekunden erneut aus den Dateien aufgebaut. Das Intervall lässt sich mit `AUTO_RELOAD_INTERVAL` ändern (`30s`, `2m` usw.); Werte unter fünf Sekunden werden abgelehnt. Im Editor erfolgt kein automatischer Reload, damit laufende Bearbeitungen nicht überraschend überlagert werden; dort steht **„Neu einlesen“** zur Verfügung.
## API
Lesend in beiden Modi:
- `GET /api/health`
- `GET /api/config`
- `GET /api/items?q=...&page=1&page_size=60`
- `GET /api/search?q=...&page=1&page_size=20`
- `GET /api/facets?limit=10`
- `GET /api/items/{key}`
Nur im Editor-Modus:
- `PUT /api/items/{key}`
- `POST /api/bulk`
- `POST /api/reload`
Im Google-Modus antworten diese drei Endpunkte mit HTTP `403 Forbidden`.
## Sicherheit
Für interne Remote-Nutzung sollte mindestens Basic Auth aktiviert und die Anwendung hinter einem Reverse Proxy mit TLS veröffentlicht werden.
Das Compose-Setup:
- startet das Container-Root-Filesystem read-only,
- entfernt Linux-Capabilities,
- setzt `no-new-privileges`,
- verwendet `/tmp` als kleines tmpfs,
- kann den Knowledge-Mount im Google-Modus zusätzlich read-only einbinden.
Die Oberfläche hat keine externen CDN-/JavaScript-Abhängigkeiten.
## Ohne Docker
Voraussetzung: Go 1.23 oder neuer.
Editor:
```bash
APP_MODE=editor go run ./cmd/server -data /pfad/zum/knowledge
```
Google-Modus:
```bash
APP_MODE=google \
APP_TITLE="IT Helpdesk Wissen" \
APP_SUBTITLE="Interne Wissenssuche" \
go run ./cmd/server -data /pfad/zum/knowledge
```
Tests/Build:
```bash
go test ./...
go vet ./...
go build -o kb-helpdesk ./cmd/server
```
## Projektstruktur
```text
.
├── cmd/server/
│ ├── app.go
│ ├── main.go
│ ├── web/ # Editor-Oberfläche
│ │ ├── index.html
│ │ ├── app.js
│ │ └── style.css
│ └── viewer/ # Google-/Helpdesk-Oberfläche
│ ├── index.html
│ ├── app.js
│ └── style.css
├── internal/store/
│ ├── store.go
│ └── store_test.go
├── knowledge/
├── backups/
├── Dockerfile
├── docker-compose.yml
├── docker-compose.dual.yml
├── .env.example
├── Makefile
└── go.mod
```

0
backups/.gitkeep Normal file
View File

226
cmd/server/app.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"encoding/json"
"errors"
"io"
"io/fs"
"net/http"
"os"
"strconv"
"strings"
"kb-editor/internal/store"
)
type appConfig struct {
Mode string `json:"mode"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Writable bool `json:"writable"`
}
type app struct {
store *store.Store
web fs.FS
config appConfig
}
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
cfg := appConfig{Mode: "editor", Title: "Knowledge Base Editor", Subtitle: "JSON · Massenbearbeitung · Docker", Writable: true}
if len(configs) > 0 {
cfg = configs[0]
}
return &app{store: s, web: web, config: cfg}
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", a.handleHealth)
mux.HandleFunc("GET /api/config", a.handleConfig)
mux.HandleFunc("GET /api/items", a.handleList)
mux.HandleFunc("GET /api/search", a.handleSearch)
mux.HandleFunc("GET /api/facets", a.handleFacets)
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
if a.config.Writable {
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
mux.HandleFunc("POST /api/bulk", a.handleBulk)
mux.HandleFunc("POST /api/reload", a.handleReload)
} else {
mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly)
mux.HandleFunc("POST /api/bulk", a.handleReadOnly)
mux.HandleFunc("POST /api/reload", a.handleReadOnly)
}
static := http.FileServer(http.FS(a.web))
mux.Handle("GET /", static)
return securityHeaders(mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"ok": true,
"count": a.store.Count(),
"data_dir": a.store.DataDir(),
"mode": a.config.Mode,
"writable": a.config.Writable,
}
if a.config.Writable {
payload["backup_dir"] = a.store.BackupDir()
}
writeJSON(w, http.StatusOK, payload)
}
func (a *app) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.config)
}
func (a *app) handleList(w http.ResponseWriter, r *http.Request) {
q := queryFromURL(r)
writeJSON(w, http.StatusOK, a.store.List(q))
}
func (a *app) handleSearch(w http.ResponseWriter, r *http.Request) {
q := queryFromURL(r)
writeJSON(w, http.StatusOK, a.store.Search(q))
}
func (a *app) handleFacets(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, http.StatusOK, a.store.Facets(limit))
}
func queryFromURL(r *http.Request) store.Query {
v := r.URL.Query()
page, _ := strconv.Atoi(v.Get("page"))
pageSize, _ := strconv.Atoi(v.Get("page_size"))
return store.Query{
Q: v.Get("q"),
AutoReply: v.Get("auto_reply"),
Language: v.Get("language"),
CommunicationStyle: v.Get("communication_style"),
Source: v.Get("source"),
Page: page,
PageSize: pageSize,
}
}
func (a *app) handleGet(w http.ResponseWriter, r *http.Request) {
doc, meta, err := a.store.Get(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"document": doc, "meta": meta})
}
func (a *app) handleReadOnly(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Diese Instanz läuft im Google-/Viewer-Modus und ist schreibgeschützt")
}
func (a *app) handlePut(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
var doc map[string]any
if err := decodeJSON(r, &doc); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON: "+err.Error())
return
}
meta, backup, err := a.store.Save(r.PathValue("key"), doc)
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "meta": meta, "backup": backup})
}
type bulkRequest struct {
Keys []string `json:"keys"`
AllMatching bool `json:"all_matching"`
Query store.Query `json:"query"`
Patch store.BulkPatch `json:"patch"`
DryRun bool `json:"dry_run"`
}
func (a *app) handleBulk(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
var req bulkRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error())
return
}
keys := req.Keys
if req.AllMatching {
keys = a.store.MatchingKeys(req.Query)
}
if len(keys) == 0 {
writeError(w, http.StatusBadRequest, "Keine Zieldateien ausgewählt")
return
}
result, err := a.store.ApplyBulk(keys, req.Patch, req.DryRun)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) handleReload(w http.ResponseWriter, r *http.Request) {
if !mustJSONContentType(w, r) {
return
}
if err := a.store.Reload(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "count": a.store.Count()})
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 8<<20))
dec.UseNumber()
if err := dec.Decode(dst); err != nil {
return err
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return errors.New("mehr als ein JSON-Wert im Request")
}
return err
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{"error": strings.TrimSpace(message)})
}

129
cmd/server/app_test.go Normal file
View File

@@ -0,0 +1,129 @@
package main
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"kb-editor/internal/store"
)
func TestBulkAllMatchingUsesJSONFilterNames(t *testing.T) {
dir := t.TempDir()
backup := filepath.Join(t.TempDir(), "backups")
t.Setenv("BACKUP_DIR", backup)
write := func(name string, auto bool) {
t.Helper()
b, _ := json.Marshal(map[string]any{"id": name, "title": name, "auto_reply": auto, "language": "de-DE"})
if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
write("true-one", true)
write("false-one", false)
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "web")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web).routes()
body := []byte(`{"keys":[],"all_matching":true,"query":{"auto_reply":"false"},"patch":{"set_language":"en-US"},"dry_run":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var result store.BulkResult
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Targeted != 1 || result.Changed != 1 {
t.Fatalf("unexpected result: %+v", result)
}
}
func TestGoogleModeBlocksWrites(t *testing.T) {
dir := t.TempDir()
b, _ := json.Marshal(map[string]any{"id": "KB-1", "title": "Test", "answer": "Lösung"})
if err := os.WriteFile(filepath.Join(dir, "one.json"), b, 0o644); err != nil {
t.Fatal(err)
}
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "viewer")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes()
item := s.List(store.Query{Page: 1, PageSize: 10}).Items[0]
req := httptest.NewRequest(http.MethodPut, "/api/items/"+item.Key, bytes.NewBufferString(`{"title":"changed"}`))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
doc, _, err := s.Get(item.Key)
if err != nil {
t.Fatal(err)
}
if doc["title"] != "Test" {
t.Fatalf("document changed in google mode: %+v", doc)
}
}
func TestSearchEndpointReturnsRankedHits(t *testing.T) {
dir := t.TempDir()
write := func(name string, doc map[string]any) {
t.Helper()
b, _ := json.Marshal(doc)
if err := os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
write("exact", map[string]any{"id": "0x80070005", "title": "Zugriff verweigert", "text": "Berechtigungen prüfen"})
write("mention", map[string]any{"id": "KB-2", "title": "Allgemeiner Windows-Fehler", "answer": "Kann 0x80070005 enthalten"})
s, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "viewer")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false}).routes()
req := httptest.NewRequest(http.MethodGet, "/api/search?q=0x80070005&page=1&page_size=20", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var result store.SearchResult
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Total != 2 || len(result.Items) != 2 {
t.Fatalf("unexpected result: %+v", result)
}
if result.Items[0].ID != "0x80070005" {
t.Fatalf("exact ID should rank first: %+v", result.Items)
}
}

174
cmd/server/main.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"crypto/subtle"
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"kb-editor/internal/store"
)
//go:embed web/* viewer/*
var webFS embed.FS
func main() {
var dataDir string
var listen string
flag.StringVar(&dataDir, "data", envOr("DATA_DIR", "./data/knowledge"), "directory containing JSON knowledge files")
flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address")
flag.Parse()
cfg, staticDir, err := configFromEnv()
if err != nil {
log.Fatal(err)
}
s, err := store.New(dataDir)
if err != nil {
log.Fatalf("initialize store: %v", err)
}
reloadInterval, err := autoReloadInterval(cfg.Mode)
if err != nil {
log.Fatal(err)
}
if reloadInterval > 0 {
go startAutoReload(s, reloadInterval)
}
sub, err := fs.Sub(webFS, staticDir)
if err != nil {
log.Fatal(err)
}
app := newApp(s, sub, cfg)
handler := requestLogger(optionalBasicAuth(app.routes()))
srv := &http.Server{
Addr: listen,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 90 * time.Second,
}
log.Printf("KB service listening on %s", listen)
log.Printf("Mode: %s (writable=%t)", cfg.Mode, cfg.Writable)
log.Printf("Data directory: %s (%d JSON files indexed)", s.DataDir(), s.Count())
if reloadInterval > 0 {
log.Printf("Automatic index reload: %s", reloadInterval)
}
if u := os.Getenv("BASIC_AUTH_USER"); u != "" {
log.Printf("Basic authentication enabled for user %q", u)
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func configFromEnv() (appConfig, string, error) {
mode := strings.ToLower(strings.TrimSpace(envOr("APP_MODE", "editor")))
switch mode {
case "editor":
return appConfig{
Mode: "editor",
Title: envOr("APP_TITLE", "Knowledge Base Editor"),
Subtitle: envOr("APP_SUBTITLE", "JSON · Massenbearbeitung · Docker"),
Writable: true,
}, "web", nil
case "google", "viewer", "search":
return appConfig{
Mode: "google",
Title: envOr("APP_TITLE", "Helpdesk Search"),
Subtitle: envOr("APP_SUBTITLE", "Interne Wissenssuche für den Helpdesk"),
Writable: false,
}, "viewer", nil
default:
return appConfig{}, "", fmt.Errorf("invalid APP_MODE %q: expected editor or google", mode)
}
}
func autoReloadInterval(mode string) (time.Duration, error) {
raw := strings.TrimSpace(os.Getenv("AUTO_RELOAD_INTERVAL"))
if raw == "" {
if mode == "google" {
return 60 * time.Second, nil
}
return 0, nil
}
if raw == "0" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
return 0, nil
}
d, err := time.ParseDuration(raw)
if err != nil {
return 0, fmt.Errorf("invalid AUTO_RELOAD_INTERVAL %q: %w", raw, err)
}
if d < 5*time.Second {
return 0, fmt.Errorf("AUTO_RELOAD_INTERVAL must be 0/off or at least 5s")
}
return d, nil
}
func startAutoReload(s *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := s.Reload(); err != nil {
log.Printf("automatic index reload failed: %v", err)
}
}
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func optionalBasicAuth(next http.Handler) http.Handler {
user := os.Getenv("BASIC_AUTH_USER")
pass := os.Getenv("BASIC_AUTH_PASSWORD")
if user == "" && pass == "" {
return next
}
if user == "" || pass == "" {
log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
if !ok || !userOK || !passOK {
w.Header().Set("WWW-Authenticate", `Basic realm="KB Helpdesk", charset="UTF-8"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.RequestURI(), time.Since(start).Round(time.Millisecond))
})
}
func mustJSONContentType(w http.ResponseWriter, r *http.Request) bool {
ct := r.Header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
http.Error(w, fmt.Sprintf("Content-Type must be application/json, got %q", ct), http.StatusUnsupportedMediaType)
return false
}
return true
}

382
cmd/server/viewer/app.js Normal file
View File

@@ -0,0 +1,382 @@
(() => {
'use strict';
const $ = (selector, root = document) => root.querySelector(selector);
const state = {
query: '',
page: 1,
pageSize: 20,
total: 0,
totalPages: 0,
facets: null,
config: null,
currentKey: null,
currentDoc: null,
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), countBadge: $('#countBadge'),
hero: $('#hero'), heroSearchForm: $('#heroSearchForm'), heroSearch: $('#heroSearch'), quickLinks: $('#quickLinks'),
resultsView: $('#resultsView'), topSearchForm: $('#topSearchForm'), topSearch: $('#topSearch'),
resultCount: $('#resultCount'), resultHint: $('#resultHint'), clearSearch: $('#clearSearch'), sideFacets: $('#sideFacets'),
loading: $('#loading'), noResults: $('#noResults'), resultList: $('#resultList'), pagination: $('#pagination'),
articleDialog: $('#articleDialog'), closeArticle: $('#closeArticle'), articleEyebrow: $('#articleEyebrow'),
articleTitle: $('#articleTitle'), articleMeta: $('#articleMeta'), problemSection: $('#problemSection'),
articleProblem: $('#articleProblem'), answerSection: $('#answerSection'), articleAnswer: $('#articleAnswer'),
tagsSection: $('#tagsSection'), articleTags: $('#articleTags'), sourceSection: $('#sourceSection'),
articleSource: $('#articleSource'), articleSourceUri: $('#articleSourceUri'), sourceLink: $('#sourceLink'),
articlePath: $('#articlePath'), copyAnswer: $('#copyAnswer'), copyLink: $('#copyLink'), toastHost: $('#toastHost'),
};
async function api(url) {
const response = await fetch(url, {headers: {'Accept': 'application/json'}});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error || `${response.status} ${response.statusText}`);
return body;
}
function escapeHTML(value) {
return String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
}
function escapeRegex(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function highlighted(value, query = state.query) {
let safe = escapeHTML(value);
const terms = [...new Set(String(query).trim().split(/\s+/).filter(Boolean))]
.sort((a, b) => b.length - a.length);
if (!terms.length) return safe;
const regex = new RegExp(`(${terms.map(escapeRegex).join('|')})`, 'gi');
return safe.replace(regex, '<mark>$1</mark>');
}
function qs(params) {
const out = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== '' && value !== null && value !== undefined) out.set(key, String(value));
});
return out.toString();
}
function updateURL({replace = false} = {}) {
const params = new URLSearchParams();
if (state.query) params.set('q', state.query);
if (state.page > 1) params.set('page', String(state.page));
if (state.currentKey) params.set('doc', state.currentKey);
const url = `${location.pathname}${params.toString() ? `?${params}` : ''}`;
history[replace ? 'replaceState' : 'pushState']({}, '', url);
}
async function loadBootstrap() {
try {
const [config, health, facets] = await Promise.all([
api('/api/config'), api('/api/health'), api('/api/facets?limit=10')
]);
state.config = config;
state.facets = facets;
document.title = config.title || 'Helpdesk Search';
els.brandTitle.textContent = config.title || 'Helpdesk Search';
els.brandSubtitle.textContent = config.subtitle || 'Interne Wissenssuche für den Helpdesk';
els.countBadge.textContent = `${Number(health.count || 0).toLocaleString('de-DE')} Wissenseinträge`;
renderFacets();
} catch (error) {
els.countBadge.textContent = 'Wissensbasis nicht erreichbar';
toast(error.message, 'error');
}
}
function renderFacets() {
const categories = (state.facets?.categories?.length ? state.facets.categories : state.facets?.keywords) || [];
els.quickLinks.innerHTML = '';
els.sideFacets.innerHTML = '';
categories.slice(0, 7).forEach((facet) => {
const heroButton = document.createElement('button');
heroButton.type = 'button';
heroButton.className = 'quick-chip';
heroButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
heroButton.addEventListener('click', () => submitSearch(facet.name));
els.quickLinks.appendChild(heroButton);
const sideButton = document.createElement('button');
sideButton.type = 'button';
sideButton.className = 'facet-button';
sideButton.innerHTML = `<span>${escapeHTML(facet.name)}</span><small>${facet.count.toLocaleString('de-DE')}</small>`;
sideButton.addEventListener('click', () => submitSearch(facet.name));
els.sideFacets.appendChild(sideButton);
});
}
async function runSearch() {
const query = state.query.trim();
if (!query) {
showHome();
return;
}
showResults();
els.loading.classList.remove('hidden');
els.noResults.classList.add('hidden');
els.resultList.innerHTML = '';
els.pagination.classList.add('hidden');
try {
const data = await api(`/api/search?${qs({q: query, page: state.page, page_size: state.pageSize})}`);
state.page = data.page || 1;
state.total = data.total || 0;
state.totalPages = data.total_pages || 0;
renderResults(data.items || []);
renderPagination();
els.resultCount.textContent = `${state.total.toLocaleString('de-DE')} ${state.total === 1 ? 'Treffer' : 'Treffer'}`;
els.resultHint.textContent = `für „${query}`;
if (!state.total) els.noResults.classList.remove('hidden');
} catch (error) {
els.resultCount.textContent = 'Suche fehlgeschlagen';
els.resultHint.textContent = '';
toast(error.message, 'error');
} finally {
els.loading.classList.add('hidden');
}
}
function renderResults(items) {
els.resultList.innerHTML = '';
for (const item of items) {
const card = document.createElement('article');
card.className = 'result-card';
const tags = [...(item.categories || []), ...(item.keywords || [])].slice(0, 4);
card.innerHTML = `
<button class="result-main" type="button">
<div class="result-overline">
<span class="result-id">${highlighted(item.id || item.rel_path)}</span>
${item.source ? `<span class="result-source">${escapeHTML(item.source)}</span>` : ''}
</div>
<h2>${highlighted(item.title || '(ohne Titel)')}</h2>
${item.excerpt ? `<p>${highlighted(item.excerpt)}</p>` : '<p class="muted">Kein Beschreibungstext hinterlegt.</p>'}
<div class="result-tags">${tags.map(tag => `<span>${highlighted(tag)}</span>`).join('')}</div>
</button>
<div class="result-arrow" aria-hidden="true">→</div>`;
$('.result-main', card).addEventListener('click', () => openArticle(item.key));
card.addEventListener('dblclick', () => openArticle(item.key));
els.resultList.appendChild(card);
}
}
function renderPagination() {
els.pagination.innerHTML = '';
if (state.totalPages <= 1) {
els.pagination.classList.add('hidden');
return;
}
els.pagination.classList.remove('hidden');
const add = (label, page, {active = false, disabled = false, aria = ''} = {}) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = `page-btn${active ? ' active' : ''}`;
button.disabled = disabled;
if (aria) button.setAttribute('aria-label', aria);
button.addEventListener('click', () => goPage(page));
els.pagination.appendChild(button);
};
add('', state.page - 1, {disabled: state.page <= 1, aria: 'Vorherige Seite'});
const pages = pageWindow(state.page, state.totalPages);
let previous = 0;
pages.forEach(page => {
if (previous && page - previous > 1) {
const gap = document.createElement('span');
gap.className = 'page-gap';
gap.textContent = '…';
els.pagination.appendChild(gap);
}
add(String(page), page, {active: page === state.page, aria: `Seite ${page}`});
previous = page;
});
add('', state.page + 1, {disabled: state.page >= state.totalPages, aria: 'Nächste Seite'});
}
function pageWindow(current, total) {
const candidates = new Set([1, total]);
for (let page = current - 2; page <= current + 2; page++) {
if (page >= 1 && page <= total) candidates.add(page);
}
return [...candidates].sort((a, b) => a - b);
}
function goPage(page) {
if (page < 1 || page > state.totalPages || page === state.page) return;
state.page = page;
state.currentKey = null;
updateURL();
runSearch();
window.scrollTo({top: 0, behavior: 'smooth'});
}
function submitSearch(value) {
const query = String(value ?? '').trim();
if (!query) return;
state.query = query;
state.page = 1;
state.currentKey = null;
els.heroSearch.value = query;
els.topSearch.value = query;
updateURL();
runSearch();
}
function showHome() {
state.query = '';
state.page = 1;
state.currentKey = null;
els.hero.classList.remove('hidden');
els.resultsView.classList.add('hidden');
els.heroSearch.value = '';
updateURL({replace: true});
setTimeout(() => els.heroSearch.focus(), 0);
}
function showResults() {
els.hero.classList.add('hidden');
els.resultsView.classList.remove('hidden');
els.topSearch.value = state.query;
}
async function openArticle(key, {updateHistory = true} = {}) {
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document || {};
renderArticle(state.currentDoc, data.meta || {});
if (updateHistory) updateURL();
if (!els.articleDialog.open) els.articleDialog.showModal();
} catch (error) {
toast(error.message, 'error');
}
}
function renderArticle(doc, meta) {
els.articleEyebrow.textContent = doc.id || meta.rel_path || 'Wissensartikel';
els.articleTitle.textContent = doc.title || '(ohne Titel)';
els.articleProblem.textContent = doc.text || '';
els.articleAnswer.textContent = doc.answer || '';
els.problemSection.classList.toggle('hidden', !doc.text);
els.answerSection.classList.toggle('hidden', !doc.answer);
const metaParts = [];
if (doc.language) metaParts.push(doc.language);
if (doc.communication_style) metaParts.push(doc.communication_style);
if (typeof doc.auto_reply === 'boolean') metaParts.push(`auto_reply: ${doc.auto_reply}`);
if (doc.min_score !== undefined && doc.min_score !== null) metaParts.push(`min_score: ${doc.min_score}`);
els.articleMeta.innerHTML = metaParts.map(value => `<span>${escapeHTML(value)}</span>`).join('');
const tags = [...new Set([...(Array.isArray(doc.categories) ? doc.categories : []), ...(Array.isArray(doc.keywords) ? doc.keywords : [])])];
els.articleTags.innerHTML = tags.map(tag => `<span>${escapeHTML(tag)}</span>`).join('');
els.tagsSection.classList.toggle('hidden', tags.length === 0);
const source = String(doc.source || '').trim();
const sourceURI = safeURL(doc.source_uri);
els.articleSource.textContent = source || 'Quelle';
els.articleSourceUri.textContent = sourceURI || '';
els.sourceSection.classList.toggle('hidden', !source && !sourceURI);
els.sourceLink.classList.toggle('hidden', !sourceURI);
if (sourceURI) els.sourceLink.href = sourceURI;
else els.sourceLink.removeAttribute('href');
els.articlePath.textContent = meta.rel_path || '';
}
function safeURL(value) {
const raw = String(value || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw);
return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '';
} catch (_) {
return '';
}
}
function closeArticle({updateHistory = true} = {}) {
if (els.articleDialog.open) els.articleDialog.close();
state.currentKey = null;
state.currentDoc = null;
if (updateHistory) updateURL({replace: true});
}
async function copyText(text, message) {
try {
await navigator.clipboard.writeText(text);
toast(message, 'success');
} catch (_) {
toast('Kopieren wurde vom Browser blockiert.', 'error');
}
}
function toast(message, type = '') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 3200);
}
function bindEvents() {
els.heroSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.heroSearch.value);
});
els.topSearchForm.addEventListener('submit', event => {
event.preventDefault();
submitSearch(els.topSearch.value);
});
els.clearSearch.addEventListener('click', showHome);
els.closeArticle.addEventListener('click', () => closeArticle());
els.articleDialog.addEventListener('click', event => {
if (event.target === els.articleDialog) closeArticle();
});
els.articleDialog.addEventListener('cancel', event => {
event.preventDefault();
closeArticle();
});
els.copyAnswer.addEventListener('click', () => copyText(String(state.currentDoc?.answer || ''), 'Antwort kopiert.'));
els.copyLink.addEventListener('click', () => copyText(location.href, 'Artikellink kopiert.'));
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
event.preventDefault();
(state.query ? els.topSearch : els.heroSearch).focus();
}
});
window.addEventListener('popstate', () => hydrateFromURL({historyNavigation: true}));
}
async function hydrateFromURL({historyNavigation = false} = {}) {
const params = new URLSearchParams(location.search);
state.query = (params.get('q') || '').trim();
state.page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1);
const docKey = params.get('doc') || '';
if (state.query) {
els.heroSearch.value = state.query;
els.topSearch.value = state.query;
await runSearch();
} else {
showHome();
}
if (docKey) await openArticle(docKey, {updateHistory: false});
else if (historyNavigation && els.articleDialog.open) closeArticle({updateHistory: false});
}
async function init() {
bindEvents();
await loadBootstrap();
await hydrateFromURL();
}
init();
})();

View File

@@ -0,0 +1,142 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Helpdesk Search</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Zur Startseite">
<span class="brand-mark">H</span>
<span class="brand-copy">
<strong id="brandTitle">Helpdesk Search</strong>
<small id="brandSubtitle">Interne Wissenssuche für den Helpdesk</small>
</span>
</a>
<div class="top-meta">
<span class="mode-badge">Nur lesen</span>
<span id="countBadge" class="count-badge">Wissensbasis lädt …</span>
</div>
</header>
<main>
<section id="hero" class="hero">
<div class="hero-orb orb-one"></div>
<div class="hero-orb orb-two"></div>
<div class="hero-content">
<div class="eyebrow">INTERNES HELPDESK-WISSEN</div>
<h1>Was möchtest du lösen?</h1>
<p>Durchsuche Fehlercodes, Symptome, Produkte, Keywords und dokumentierte Lösungen in einer zentralen Wissensbasis.</p>
<form id="heroSearchForm" class="search-box hero-search" role="search">
<span class="search-icon" aria-hidden="true"></span>
<input id="heroSearch" type="search" placeholder="z. B. 0x80070005, Outlook startet nicht, BitLocker …" autocomplete="off" autofocus>
<kbd>/</kbd>
<button type="submit">Suchen</button>
</form>
<div id="quickLinks" class="quick-links" aria-label="Häufige Kategorien"></div>
</div>
</section>
<section id="resultsView" class="results-view hidden">
<div class="results-header">
<form id="topSearchForm" class="search-box top-search" role="search">
<span class="search-icon" aria-hidden="true"></span>
<input id="topSearch" type="search" autocomplete="off" aria-label="Wissensbasis durchsuchen">
<button type="submit">Suchen</button>
</form>
<div class="results-summary">
<div>
<strong id="resultCount">0 Treffer</strong>
<span id="resultHint"></span>
</div>
<button id="clearSearch" class="text-btn" type="button">Neue Suche</button>
</div>
</div>
<div class="results-layout">
<aside class="side-panel">
<div class="side-card">
<span class="side-label">Schnellzugriff</span>
<div id="sideFacets" class="facet-list"></div>
</div>
<div class="side-card help-card">
<span class="help-icon">?</span>
<strong>Such-Tipp</strong>
<p>Fehlercodes wie <code>0x80070005</code> oder konkrete Meldungsteile liefern meist die präzisesten Treffer.</p>
</div>
</aside>
<div class="results-column">
<div id="loading" class="loading hidden"><span></span><span></span><span></span></div>
<div id="noResults" class="no-results hidden">
<div class="no-results-icon"></div>
<h2>Keine passenden Einträge gefunden</h2>
<p>Versuche einen kürzeren Fehlercode, einen Produktnamen oder einzelne Wörter aus der Fehlermeldung.</p>
</div>
<div id="resultList" class="result-list" aria-live="polite"></div>
<nav id="pagination" class="pagination hidden" aria-label="Suchergebnisse"></nav>
</div>
</div>
</section>
</main>
<dialog id="articleDialog" class="article-dialog">
<article class="article-shell">
<header class="article-head">
<div>
<div id="articleEyebrow" class="article-eyebrow"></div>
<h2 id="articleTitle"></h2>
</div>
<button id="closeArticle" class="close-btn" type="button" aria-label="Artikel schließen">×</button>
</header>
<div class="article-body">
<div id="articleMeta" class="meta-row"></div>
<section id="problemSection" class="article-section">
<div class="section-kicker">PROBLEM / ERKENNUNG</div>
<div id="articleProblem" class="article-text"></div>
</section>
<section id="answerSection" class="article-section answer-section">
<div class="answer-head">
<div>
<div class="section-kicker">LÖSUNG / ANTWORT</div>
<strong>Empfohlene Vorgehensweise</strong>
</div>
<button id="copyAnswer" class="copy-btn" type="button">Antwort kopieren</button>
</div>
<div id="articleAnswer" class="article-text answer-text"></div>
</section>
<section id="tagsSection" class="article-section compact-section">
<div class="section-kicker">EINORDNUNG</div>
<div id="articleTags" class="tag-list"></div>
</section>
<section id="sourceSection" class="article-section source-section hidden">
<div class="section-kicker">QUELLE</div>
<div class="source-line">
<div>
<strong id="articleSource"></strong>
<span id="articleSourceUri"></span>
</div>
<a id="sourceLink" class="source-link" href="#" target="_blank" rel="noopener noreferrer">Quelle öffnen ↗</a>
</div>
</section>
</div>
<footer class="article-foot">
<span id="articlePath"></span>
<button id="copyLink" class="text-btn" type="button">Link zu diesem Artikel kopieren</button>
</footer>
</article>
</dialog>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>

231
cmd/server/viewer/style.css Normal file
View File

@@ -0,0 +1,231 @@
:root {
color-scheme: dark;
--bg: #08101f;
--bg-soft: #0d1729;
--panel: rgba(14, 25, 44, .86);
--panel-solid: #101c30;
--line: rgba(139, 164, 205, .16);
--line-strong: rgba(139, 164, 205, .28);
--text: #ecf3ff;
--muted: #95a8c6;
--faint: #6d819f;
--accent: #79a7ff;
--accent-2: #8f7cff;
--accent-soft: rgba(121, 167, 255, .12);
--green: #64d9ad;
--danger: #ff8490;
--shadow: 0 22px 70px rgba(0, 0, 0, .36);
--radius: 18px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { min-height: 100%; background: var(--bg); }
body {
min-height: 100vh;
margin: 0;
color: var(--text);
background:
radial-gradient(circle at 14% -10%, rgba(84, 122, 255, .11), transparent 30rem),
radial-gradient(circle at 95% 24%, rgba(127, 91, 255, .08), transparent 27rem),
var(--bg);
}
button, input { font: inherit; }
button { color: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
.hidden { display: none !important; }
.topbar {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 clamp(20px, 4vw, 64px);
border-bottom: 1px solid var(--line);
background: rgba(8, 16, 31, .76);
backdrop-filter: blur(18px);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: inherit; min-width: 0; }
.brand-mark {
width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto;
border: 1px solid rgba(121, 167, 255, .35); border-radius: 12px;
background: linear-gradient(135deg, rgba(121,167,255,.22), rgba(143,124,255,.16));
color: #cfe0ff; font-weight: 800; box-shadow: inset 0 1px rgba(255,255,255,.08);
}
.brand-copy { min-width: 0; display: grid; gap: 2px; }
.brand-copy strong { font-size: 14px; letter-spacing: .01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.brand-copy small { color: var(--muted); font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top-meta { display: flex; align-items: center; gap: 9px; }
.mode-badge, .count-badge {
border: 1px solid var(--line); background: rgba(255,255,255,.025); border-radius: 999px;
color: var(--muted); padding: 6px 10px; font-size: 10px; white-space: nowrap;
}
.mode-badge { color: #9ee5ca; border-color: rgba(100,217,173,.2); background: rgba(100,217,173,.06); }
.hero { min-height: calc(100vh - 72px); display: grid; place-items: center; position: relative; overflow: hidden; padding: 56px 24px 100px; }
.hero-content { width: min(900px, 100%); text-align: center; position: relative; z-index: 2; }
.eyebrow, .section-kicker, .side-label, .article-eyebrow {
color: #93b6fa; font-weight: 760; font-size: 10px; letter-spacing: .13em;
}
.hero h1 { margin: 17px 0 13px; font-size: clamp(36px, 6vw, 64px); line-height: 1.02; letter-spacing: -.045em; }
.hero p { color: var(--muted); margin: 0 auto 34px; max-width: 680px; font-size: clamp(14px, 2vw, 17px); line-height: 1.65; }
.hero-orb { position: absolute; border-radius: 50%; filter: blur(1px); pointer-events: none; }
.orb-one { width: 420px; height: 420px; top: 12%; left: -250px; background: radial-gradient(circle, rgba(69,128,255,.11), transparent 68%); }
.orb-two { width: 520px; height: 520px; bottom: -270px; right: -180px; background: radial-gradient(circle, rgba(127,91,255,.11), transparent 68%); }
.search-box {
display: flex; align-items: center; gap: 10px;
border: 1px solid var(--line-strong); background: rgba(14, 25, 44, .92);
box-shadow: 0 16px 60px rgba(0,0,0,.25), inset 0 1px rgba(255,255,255,.035);
transition: border-color .18s, box-shadow .18s, transform .18s;
}
.search-box:focus-within { border-color: rgba(121,167,255,.7); box-shadow: 0 18px 70px rgba(0,0,0,.3), 0 0 0 4px rgba(121,167,255,.08); }
.hero-search { min-height: 64px; padding: 7px 8px 7px 20px; border-radius: 21px; }
.top-search { min-height: 54px; padding: 5px 6px 5px 17px; border-radius: 16px; width: min(840px, 100%); }
.search-icon { color: #a8bfeb; font-size: 24px; line-height: 1; transform: rotate(-15deg); }
.search-box input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 16px; }
.hero-search input { font-size: clamp(15px, 2vw, 18px); }
.search-box input::placeholder { color: #6f83a2; }
.search-box button {
border: 0; border-radius: 14px; background: linear-gradient(135deg, #6f9df5, #8072ec);
min-height: 46px; padding: 0 20px; font-weight: 720; font-size: 12px; cursor: pointer;
box-shadow: inset 0 1px rgba(255,255,255,.2), 0 8px 24px rgba(87,107,224,.2);
}
kbd { border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; color: var(--faint); background: rgba(255,255,255,.025); font-size: 10px; }
.quick-links { margin-top: 22px; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; }
.quick-chip, .facet-button {
border: 1px solid var(--line); background: rgba(255,255,255,.025); color: #bbcae2; cursor: pointer;
transition: border-color .15s, background .15s, transform .15s;
}
.quick-chip:hover, .facet-button:hover { border-color: rgba(121,167,255,.4); background: rgba(121,167,255,.08); transform: translateY(-1px); }
.quick-chip { border-radius: 999px; padding: 7px 11px; display: inline-flex; gap: 8px; align-items: center; font-size: 10px; }
.quick-chip small, .facet-button small { color: var(--faint); }
.results-view { width: min(1240px, calc(100% - 40px)); margin: 0 auto; padding: 42px 0 80px; }
.results-header { padding: 0 min(280px, 22vw) 24px 0; }
.results-summary { min-height: 48px; display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-top: 20px; border-bottom: 1px solid var(--line); padding-bottom: 15px; }
.results-summary > div { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px; }
.results-summary strong { font-size: 13px; }
.results-summary span { color: var(--muted); font-size: 12px; }
.text-btn { border: 0; background: transparent; color: #93b6fa; cursor: pointer; padding: 5px; font-size: 11px; }
.text-btn:hover { color: #c5d8ff; }
.results-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 34px; align-items: start; }
.side-panel { display: grid; gap: 13px; position: sticky; top: 102px; }
.side-card { border: 1px solid var(--line); background: rgba(13,23,41,.6); border-radius: 15px; padding: 14px; }
.facet-list { display: grid; gap: 4px; margin-top: 9px; }
.facet-button { width: 100%; border-radius: 9px; border-color: transparent; background: transparent; padding: 8px; display: flex; justify-content: space-between; text-align: left; font-size: 11px; }
.help-card { padding: 15px; }
.help-card .help-icon { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 8px; color: #a9c4fa; background: var(--accent-soft); margin-bottom: 11px; font-size: 11px; font-weight: 800; }
.help-card strong { display: block; font-size: 11px; }
.help-card p { color: var(--muted); font-size: 10px; line-height: 1.55; margin: 6px 0 0; }
.help-card code { color: #b9cefa; }
.results-column { min-width: 0; }
.result-list { display: grid; gap: 12px; }
.result-card {
position: relative; display: grid; grid-template-columns: minmax(0,1fr) 38px; align-items: center;
border: 1px solid var(--line); background: linear-gradient(140deg, rgba(16,28,48,.82), rgba(11,21,38,.72));
border-radius: var(--radius); overflow: hidden; transition: border-color .16s, transform .16s, box-shadow .16s;
}
.result-card:hover { border-color: rgba(121,167,255,.34); transform: translateY(-1px); box-shadow: 0 13px 42px rgba(0,0,0,.17); }
.result-main { appearance: none; border: 0; background: transparent; color: inherit; text-align: left; padding: 20px 10px 20px 22px; cursor: pointer; min-width: 0; }
.result-overline { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 8px; }
.result-id { font: 650 10px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #91b6ff; }
.result-source { color: var(--faint); font-size: 9px; border-left: 1px solid var(--line-strong); padding-left: 9px; }
.result-main h2 { margin: 0; font-size: 17px; line-height: 1.35; letter-spacing: -.012em; }
.result-main p { color: #a9bad2; font-size: 12px; line-height: 1.65; margin: 9px 0 0; max-width: 850px; }
.result-main p.muted { color: var(--faint); }
.result-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 13px; }
.result-tags span { color: #91a7c7; background: rgba(255,255,255,.025); border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; font-size: 9px; }
.result-arrow { color: #7892bd; font-size: 17px; }
mark { color: #ddecff; background: rgba(121,167,255,.16); border-radius: 3px; padding: 0 1px; }
.loading { display: flex; justify-content: center; gap: 7px; padding: 70px; }
.loading span { width: 7px; height: 7px; border-radius: 50%; background: #8baef1; animation: pulse 1s infinite ease-in-out; }
.loading span:nth-child(2) { animation-delay: .13s; }.loading span:nth-child(3) { animation-delay: .26s; }
@keyframes pulse { 0%,100% { opacity:.25; transform:translateY(0) } 50% { opacity:1; transform:translateY(-4px) } }
.no-results { text-align: center; padding: 70px 20px; border: 1px dashed var(--line-strong); border-radius: var(--radius); }
.no-results-icon { font-size: 32px; color: #7795c8; transform: rotate(-15deg); }
.no-results h2 { font-size: 18px; margin: 15px 0 5px; }
.no-results p { color: var(--muted); font-size: 12px; max-width: 520px; margin: 0 auto; line-height: 1.6; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 28px; }
.page-btn { width: 35px; height: 35px; border-radius: 9px; border: 1px solid var(--line); background: rgba(255,255,255,.025); color: #b6c5dd; cursor: pointer; font-size: 11px; }
.page-btn:hover:not(:disabled) { border-color: rgba(121,167,255,.45); background: rgba(121,167,255,.08); }
.page-btn.active { color: #edf4ff; background: rgba(121,167,255,.15); border-color: rgba(121,167,255,.45); }
.page-btn:disabled { opacity: .3; cursor: default; }
.page-gap { color: var(--faint); }
.article-dialog { width: min(940px, calc(100vw - 32px)); max-height: calc(100vh - 32px); padding: 0; color: var(--text); background: #0c1729; border: 1px solid var(--line-strong); border-radius: 22px; box-shadow: var(--shadow); overflow: hidden; }
.article-dialog::backdrop { background: rgba(2, 6, 13, .76); backdrop-filter: blur(7px); }
.article-shell { display: grid; grid-template-rows: auto minmax(0,1fr) auto; max-height: calc(100vh - 34px); }
.article-head { display: flex; justify-content: space-between; align-items: start; gap: 20px; padding: 26px 28px 20px; border-bottom: 1px solid var(--line); background: linear-gradient(145deg, rgba(121,167,255,.06), transparent 60%); }
.article-head h2 { margin: 8px 0 0; font-size: clamp(20px, 3vw, 29px); line-height: 1.25; letter-spacing: -.025em; }
.close-btn { width: 36px; height: 36px; flex: 0 0 auto; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.025); cursor: pointer; color: #aabbd4; font-size: 22px; line-height: 1; }
.close-btn:hover { border-color: var(--line-strong); color: var(--text); }
.article-body { overflow: auto; padding: 24px 28px 34px; }
.meta-row { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 22px; }
.meta-row span { border: 1px solid var(--line); background: rgba(255,255,255,.02); border-radius: 999px; color: var(--faint); padding: 5px 8px; font-size: 9px; }
.article-section { border-top: 1px solid var(--line); padding: 22px 0; }
.article-section:first-of-type { border-top: 0; }
.article-text { margin-top: 10px; color: #c6d3e6; font-size: 13px; line-height: 1.72; white-space: pre-wrap; overflow-wrap: anywhere; }
.answer-section { margin: 7px -10px 0; padding: 19px 18px 22px; border: 1px solid rgba(100,217,173,.17); border-radius: 15px; background: linear-gradient(135deg, rgba(100,217,173,.055), rgba(121,167,255,.035)); }
.answer-head { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
.answer-head strong { display: block; font-size: 13px; margin-top: 5px; }
.answer-text { color: #d9e7e2; }
.copy-btn, .source-link { border: 1px solid var(--line-strong); border-radius: 10px; background: rgba(255,255,255,.035); padding: 8px 10px; color: #bdd0ef; font-size: 10px; cursor: pointer; text-decoration: none; white-space: nowrap; }
.copy-btn:hover, .source-link:hover { border-color: rgba(121,167,255,.45); color: #edf4ff; }
.compact-section { padding-bottom: 8px; }
.tag-list { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.tag-list span { border-radius: 999px; background: var(--accent-soft); color: #a9c5f8; padding: 5px 9px; font-size: 9px; }
.source-line { display: flex; justify-content: space-between; gap: 20px; align-items: center; margin-top: 10px; }
.source-line > div { min-width: 0; display: grid; gap: 4px; }
.source-line strong { font-size: 12px; }
.source-line span { color: var(--faint); font-size: 9px; overflow-wrap: anywhere; }
.article-foot { display: flex; justify-content: space-between; align-items: center; gap: 20px; min-height: 52px; padding: 10px 24px; border-top: 1px solid var(--line); background: rgba(7,14,26,.7); }
.article-foot > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }
.toast-host { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 100; pointer-events: none; }
.toast { max-width: 420px; padding: 11px 13px; border-radius: 11px; color: #dbe7fb; background: #14233b; border: 1px solid #2c4264; box-shadow: var(--shadow); font-size: 11px; animation: toast-in .18s ease-out; }
.toast.success { color: #b6eed8; border-color: rgba(100,217,173,.35); }
.toast.error { color: #ffc2c8; border-color: rgba(255,132,144,.35); }
@keyframes toast-in { from { opacity: 0; transform: translateY(7px); } }
@media (max-width: 840px) {
.topbar { padding: 0 18px; }
.brand-copy small, .mode-badge { display: none; }
.count-badge { max-width: 42vw; overflow: hidden; text-overflow: ellipsis; }
.hero { padding-left: 18px; padding-right: 18px; }
.hero-search { min-height: 58px; padding-left: 15px; }
.hero-search kbd { display: none; }
.search-box button { padding: 0 14px; }
.results-view { width: min(100% - 28px, 1240px); padding-top: 24px; }
.results-header { padding-right: 0; }
.results-layout { grid-template-columns: 1fr; }
.side-panel { display: none; }
.result-main { padding: 17px 6px 17px 17px; }
.article-head, .article-body { padding-left: 20px; padding-right: 20px; }
}
@media (max-width: 520px) {
.topbar { height: 64px; }
.brand-mark { width: 34px; height: 34px; }
.count-badge { display: none; }
.hero { min-height: calc(100vh - 64px); }
.hero h1 { font-size: 38px; }
.hero p { font-size: 14px; }
.hero-search { display: grid; grid-template-columns: 24px minmax(0,1fr); padding: 12px 14px; border-radius: 18px; }
.hero-search button { grid-column: 1 / -1; width: 100%; }
.top-search button { display: none; }
.results-summary { align-items: center; }
.result-card { grid-template-columns: 1fr; }
.result-arrow { display: none; }
.result-main h2 { font-size: 15px; }
.result-main p { font-size: 11px; }
.article-dialog { width: calc(100vw - 14px); max-height: calc(100vh - 14px); border-radius: 17px; }
.article-shell { max-height: calc(100vh - 16px); }
.article-head { padding: 20px 17px 16px; }
.article-body { padding: 18px 17px 25px; }
.answer-head, .source-line, .article-foot { align-items: flex-start; flex-direction: column; }
.article-foot { gap: 4px; }
}

468
cmd/server/web/app.js Normal file
View File

@@ -0,0 +1,468 @@
(() => {
'use strict';
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const state = {
page: 1,
pageSize: 60,
total: 0,
totalPages: 0,
items: [],
selected: new Set(),
currentKey: null,
currentDoc: null,
currentMeta: null,
dirty: false,
activeTab: 'form',
lastBulkPreviewSignature: '',
};
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
};
let searchTimer;
async function api(url, options = {}) {
const res = await fetch(url, options);
const contentType = res.headers.get('content-type') || '';
const body = contentType.includes('application/json') ? await res.json() : await res.text();
if (!res.ok) {
const msg = typeof body === 'object' && body?.error ? body.error : `${res.status} ${res.statusText}`;
throw new Error(msg);
}
return body;
}
function currentQuery(page = state.page) {
return {
q: els.searchInput.value.trim(),
auto_reply: els.autoReplyFilter.value,
language: els.languageFilter.value.trim(),
communication_style: els.styleFilter.value.trim(),
source: els.sourceFilter.value.trim(),
page,
page_size: state.pageSize,
};
}
function queryString(q) {
const p = new URLSearchParams();
Object.entries(q).forEach(([k, v]) => {
if (v !== '' && v !== null && v !== undefined && !(k === 'auto_reply' && v === 'any')) p.set(k, v);
});
return p.toString();
}
async function loadHealth() {
try {
const [h, config] = await Promise.all([api('/api/health'), api('/api/config')]);
if (config.title) {
els.brandTitle.textContent = config.title;
document.title = config.title;
}
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
els.healthPill.className = 'pill ok';
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
} catch (err) {
els.healthPill.textContent = 'Offline';
els.healthPill.className = 'pill';
toast(err.message, 'error');
}
}
async function loadList(resetPage = false) {
if (resetPage) state.page = 1;
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
try {
const data = await api(`/api/items?${queryString(currentQuery())}`);
state.page = data.page || 1;
state.total = data.total;
state.totalPages = data.total_pages;
state.items = data.items || [];
renderList();
} catch (err) {
els.resultList.innerHTML = `<div class="inline-error" style="margin:12px">${escapeHTML(err.message)}</div>`;
}
}
function renderList() {
els.resultList.innerHTML = '';
if (state.items.length === 0) {
els.resultList.innerHTML = '<div class="muted-text" style="padding:20px;text-align:center">Keine Treffer.</div>';
}
for (const item of state.items) {
const row = document.createElement('div');
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
row.dataset.key = item.key;
const checked = state.selected.has(item.key) ? 'checked' : '';
const auto = item.auto_reply === true;
row.innerHTML = `
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
<div>
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
<div class="result-meta">
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '')}</span>
<span>score ${item.min_score ?? ''}</span>
<span>${escapeHTML(item.language || '')}</span>
<span>${escapeHTML(item.source || '')}</span>
</div>
</div>`;
const cb = $('.result-check', row);
cb.addEventListener('click', (e) => {
e.stopPropagation();
toggleSelection(item.key, cb.checked);
});
row.addEventListener('click', () => openItem(item.key));
els.resultList.appendChild(row);
}
els.pageLabel.textContent = state.totalPages ? `Seite ${state.page} / ${state.totalPages}` : 'Seite 0 / 0';
els.totalLabel.textContent = `${state.total.toLocaleString('de-DE')} Treffer`;
els.prevPage.disabled = state.page <= 1;
els.nextPage.disabled = state.totalPages === 0 || state.page >= state.totalPages;
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
updateSelectionUI();
}
function toggleSelection(key, checked) {
if (checked) state.selected.add(key); else state.selected.delete(key);
renderSelectionOnly();
}
function renderSelectionOnly() {
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
}
function updateSelectionUI() { renderSelectionOnly(); }
async function openItem(key) {
if (key === state.currentKey) return;
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
try {
const data = await api(`/api/items/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document;
state.currentMeta = data.meta;
state.dirty = false;
showEditor();
fillForm();
setTab('form');
renderList();
} catch (err) {
toast(err.message, 'error');
}
}
function showEditor() {
els.emptyState.classList.add('hidden');
els.editor.classList.remove('hidden');
els.filePath.textContent = state.currentMeta?.rel_path || '';
setDirty(false);
}
function fillForm() {
$$('[data-field]').forEach(input => {
const name = input.dataset.field;
const val = state.currentDoc?.[name];
if (input.type === 'checkbox') input.checked = Boolean(val);
else input.value = val ?? '';
});
$$('[data-list-field]').forEach(input => {
const val = state.currentDoc?.[input.dataset.listField];
input.value = Array.isArray(val) ? val.join('\n') : '';
});
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
clearRawError();
}
function syncFormToDoc() {
if (!state.currentDoc) return;
$$('[data-field]').forEach(input => {
const name = input.dataset.field;
if (input.type === 'checkbox') state.currentDoc[name] = input.checked;
else if (input.type === 'number') {
if (input.value === '') delete state.currentDoc[name];
else state.currentDoc[name] = Number(input.value);
} else state.currentDoc[name] = input.value;
});
$$('[data-list-field]').forEach(input => {
state.currentDoc[input.dataset.listField] = lines(input.value);
});
}
function syncRawToDoc() {
try {
const parsed = JSON.parse(els.rawEditor.value);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('Die JSON-Wurzel muss ein Objekt sein.');
state.currentDoc = parsed;
clearRawError();
return true;
} catch (err) {
els.rawError.textContent = `JSON-Fehler: ${err.message}`;
els.rawError.classList.remove('hidden');
return false;
}
}
function clearRawError() {
els.rawError.textContent = '';
els.rawError.classList.add('hidden');
}
function setDirty(v = true) {
state.dirty = v;
els.dirtyBadge.classList.toggle('hidden', !v);
}
function setTab(tab) {
if (tab === state.activeTab) return;
if (state.activeTab === 'raw' && tab === 'form') {
if (!syncRawToDoc()) return;
fillForm();
} else if (state.activeTab === 'form' && tab === 'raw') {
syncFormToDoc();
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
}
state.activeTab = tab;
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
els.formTab.classList.toggle('active', tab === 'form');
els.rawTab.classList.toggle('active', tab === 'raw');
els.formatJsonBtn.classList.toggle('hidden', tab !== 'raw');
}
async function saveCurrent() {
if (!state.currentKey || !state.currentDoc) return;
if (state.activeTab === 'raw') {
if (!syncRawToDoc()) return;
} else syncFormToDoc();
els.saveBtn.disabled = true;
try {
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
});
state.currentMeta = result.meta;
setDirty(false);
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
await loadList(false);
} catch (err) {
toast(err.message, 'error');
} finally {
els.saveBtn.disabled = false;
}
}
function openBulk() {
if (state.selected.size === 0 && state.total === 0) return;
resetBulkPreview();
els.bulkAllMatching.checked = state.selected.size === 0;
updateBulkTargetText();
els.bulkDialog.showModal();
}
function updateBulkTargetText() {
const all = els.bulkAllMatching.checked;
els.bulkTargetText.textContent = all
? `${state.total.toLocaleString('de-DE')} aktuelle Treffer als Ziel`
: `${state.selected.size.toLocaleString('de-DE')} explizit ausgewählte Dateien als Ziel`;
els.allMatchingHint.textContent = `Aktueller Filter: ${state.total.toLocaleString('de-DE')} Treffer`;
}
function buildPatch() {
const patch = {};
const enabled = id => $(`[data-enable="${id}"]`)?.checked;
if (enabled('bulkAutoReply')) patch.set_auto_reply = $('#bulkAutoReply').value === 'true';
if (enabled('bulkMinScore')) patch.set_min_score = Number($('#bulkMinScore').value);
if (enabled('bulkLanguage')) patch.set_language = $('#bulkLanguage').value;
if (enabled('bulkStyle')) patch.set_communication_style = $('#bulkStyle').value;
if (enabled('bulkSource')) patch.set_source = $('#bulkSource').value;
if (enabled('bulkSourceUri')) patch.set_source_uri = $('#bulkSourceUri').value;
const addK = lines($('#addKeywords').value), rmK = lines($('#removeKeywords').value);
const addC = lines($('#addCategories').value), rmC = lines($('#removeCategories').value);
if (addK.length) patch.add_keywords = addK;
if (rmK.length) patch.remove_keywords = rmK;
if (addC.length) patch.add_categories = addC;
if (rmC.length) patch.remove_categories = rmC;
const find = $('#replaceFind').value;
if (find) {
patch.find_replace = {
fields: $$('input[name="replaceField"]:checked').map(x => x.value),
find,
replace: $('#replaceWith').value,
regex: $('#replaceRegex').checked,
case_sensitive: $('#replaceCase').checked,
};
}
return patch;
}
function buildBulkRequest(dryRun) {
const q = currentQuery(1);
q.page = 0; q.page_size = 0;
return {
keys: Array.from(state.selected),
all_matching: els.bulkAllMatching.checked,
query: q,
patch: buildPatch(),
dry_run: dryRun,
};
}
async function previewBulk() {
const req = buildBulkRequest(true);
if (!Object.keys(req.patch).length) {
toast('Bitte mindestens eine Änderung festlegen.', 'error');
return;
}
els.previewBulkBtn.disabled = true;
try {
const result = await api('/api/bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
});
state.lastBulkPreviewSignature = signatureFor(req);
renderBulkPreview(result);
els.applyBulkBtn.disabled = result.changed === 0;
} catch (err) {
els.bulkPreview.textContent = err.message;
els.bulkPreview.className = 'preview-box warn';
els.applyBulkBtn.disabled = true;
} finally {
els.previewBulkBtn.disabled = false;
}
}
function renderBulkPreview(result) {
els.bulkPreview.className = `preview-box ${result.changed > 0 ? 'ok' : 'warn'}`;
els.bulkPreview.innerHTML = `
<strong>Vorschau:</strong> ${result.changed.toLocaleString('de-DE')} von ${result.targeted.toLocaleString('de-DE')} Dateien würden geändert,
${result.skipped.toLocaleString('de-DE')} bleiben unverändert.
${result.sample?.length ? `<div class="preview-samples">${result.sample.map(x => `<code>${escapeHTML(x.id || x.rel_path)} · ${escapeHTML(x.title || '')}</code>`).join('')}</div>` : ''}`;
}
async function applyBulk() {
const req = buildBulkRequest(false);
const sig = signatureFor({...req, dry_run: true});
if (sig !== state.lastBulkPreviewSignature) {
toast('Die Massenänderung wurde seit der Vorschau verändert. Bitte erneut Vorschau ausführen.', 'error');
els.applyBulkBtn.disabled = true;
return;
}
if (!confirm('Massenänderung jetzt wirklich auf die Zieldateien anwenden?')) return;
els.applyBulkBtn.disabled = true;
try {
const result = await api('/api/bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
});
toast(`${result.changed.toLocaleString('de-DE')} Dateien geändert. Backup: ${shortPath(result.backup)}`, 'success');
els.bulkDialog.close();
state.selected.clear();
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
await Promise.all([loadList(true), loadHealth()]);
} catch (err) {
toast(err.message, 'error');
}
}
function resetBulkPreview() {
state.lastBulkPreviewSignature = '';
els.bulkPreview.className = 'preview-box hidden';
els.bulkPreview.textContent = '';
els.applyBulkBtn.disabled = true;
}
function signatureFor(obj) { return JSON.stringify(obj); }
function lines(s) { return s.split(/\r?\n/).map(x => x.trim()).filter(Boolean); }
function shortPath(p) { if (!p) return ''; const parts = p.split('/'); return parts.slice(-2).join('/'); }
function escapeHTML(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c])); }
function toast(message, type = '') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = message;
els.toastHost.appendChild(el);
setTimeout(() => el.remove(), 5000);
}
function debounceReload() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(true), 240);
}
// Filters and navigation.
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
els.autoReplyFilter.addEventListener('change', () => loadList(true));
els.prevPage.addEventListener('click', () => { if (state.page > 1) { state.page--; loadList(); } });
els.nextPage.addEventListener('click', () => { if (state.page < state.totalPages) { state.page++; loadList(); } });
els.selectPage.addEventListener('change', () => {
for (const item of state.items) {
if (els.selectPage.checked) state.selected.add(item.key); else state.selected.delete(item.key);
}
renderList();
});
// Editor.
$$('[data-field], [data-list-field]').forEach(el => el.addEventListener('input', () => setDirty(true)));
els.rawEditor.addEventListener('input', () => { setDirty(true); clearRawError(); });
$$('.tab').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab)));
els.saveBtn.addEventListener('click', saveCurrent);
els.formatJsonBtn.addEventListener('click', () => {
if (syncRawToDoc()) {
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
setDirty(true);
}
});
// Bulk modal.
els.bulkBtn.addEventListener('click', openBulk);
els.bulkAllMatching.addEventListener('change', () => { updateBulkTargetText(); resetBulkPreview(); });
$$('[data-enable]').forEach(toggle => toggle.addEventListener('change', () => {
const target = document.getElementById(toggle.dataset.enable);
if (target) target.disabled = !toggle.checked;
resetBulkPreview();
}));
$$('#bulkDialog input, #bulkDialog textarea, #bulkDialog select').forEach(el => {
if (el !== els.bulkAllMatching && !el.hasAttribute('data-enable')) el.addEventListener('input', resetBulkPreview);
});
els.previewBulkBtn.addEventListener('click', previewBulk);
els.applyBulkBtn.addEventListener('click', applyBulk);
els.reloadBtn.addEventListener('click', async () => {
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Dateien neu einlesen?')) return;
try {
const r = await api('/api/reload', {method: 'POST', headers: {'Content-Type':'application/json'}, body:'{}'});
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
toast(`${r.count.toLocaleString('de-DE')} Dateien neu eingelesen.`, 'success');
await Promise.all([loadList(true), loadHealth()]);
} catch (err) { toast(err.message, 'error'); }
});
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); }
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); els.searchInput.focus(); }
});
window.addEventListener('beforeunload', e => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } });
loadHealth();
loadList(true);
})();

251
cmd/server/web/index.html Normal file
View File

@@ -0,0 +1,251 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KB Mass Editor</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="brand">
<div class="brand-mark">KB</div>
<div>
<div id="brandTitle" class="brand-title">Knowledge Base Editor</div>
<div id="brandSubtitle" class="brand-subtitle">JSON · Massenbearbeitung · Docker</div>
</div>
</div>
<div class="top-actions">
<span id="healthPill" class="pill muted">Verbinde …</span>
<button id="reloadBtn" class="btn ghost" title="Dateien neu einlesen">↻ Neu einlesen</button>
<button id="bulkBtn" class="btn primary" disabled>✦ Massenbearbeitung</button>
</div>
</header>
<aside class="sidebar">
<section class="filters">
<label class="search-wrap">
<span></span>
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
<kbd>/</kbd>
</label>
<div class="filter-row">
<select id="autoReplyFilter" aria-label="Auto Reply Filter">
<option value="any">auto_reply: alle</option>
<option value="true">auto_reply: true</option>
<option value="false">auto_reply: false</option>
</select>
<input id="languageFilter" placeholder="Sprache, z. B. de-DE">
</div>
<div class="filter-row">
<input id="sourceFilter" placeholder="Quelle enthält …">
<input id="styleFilter" placeholder="Stil, z. B. formal">
</div>
</section>
<section class="list-tools">
<label class="check-label"><input id="selectPage" type="checkbox"> Seite auswählen</label>
<span id="selectionCount" class="muted-text">0 ausgewählt</span>
</section>
<div id="resultList" class="result-list" aria-live="polite"></div>
<footer class="pager">
<button id="prevPage" class="icon-btn" aria-label="Vorherige Seite"></button>
<div>
<strong id="pageLabel">Seite 1</strong>
<span id="totalLabel">0 Treffer</span>
</div>
<button id="nextPage" class="icon-btn" aria-label="Nächste Seite"></button>
</footer>
</aside>
<main class="main-pane">
<div id="emptyState" class="empty-state">
<div class="empty-icon">{ }</div>
<h1>JSON-Wissensbasis bearbeiten</h1>
<p>Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.</p>
<div class="empty-cards">
<div><strong>Sicher</strong><span>Automatische Backups vor jedem Schreibvorgang</span></div>
<div><strong>Schnell</strong><span>Indexierte Suche auch bei zehntausenden JSON-Dateien</span></div>
<div><strong>Flexibel</strong><span>Formularansicht und vollständiger Raw-JSON-Editor</span></div>
</div>
</div>
<section id="editor" class="editor hidden">
<div class="editor-head">
<div class="breadcrumb">
<span id="filePath"></span>
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
</div>
<div class="editor-actions">
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
</div>
</div>
<div class="tabs" role="tablist">
<button class="tab active" data-tab="form">Formular</button>
<button class="tab" data-tab="raw">Raw JSON</button>
</div>
<div id="formTab" class="tab-panel active">
<div class="form-grid">
<label class="field span-2">
<span>ID</span>
<input data-field="id" placeholder="KB-…">
</label>
<label class="field span-10">
<span>Titel</span>
<input data-field="title" placeholder="Titel des KB-Artikels">
</label>
<label class="field span-12">
<span>Erkennungstext / Problem</span>
<textarea data-field="text" rows="7" placeholder="Beschreibung, Fehlerkontext, Erkennung …"></textarea>
</label>
<label class="field span-12">
<span>Antwort / Lösung</span>
<textarea data-field="answer" rows="10" placeholder="Lösungsschritte …"></textarea>
</label>
<label class="field span-3 switch-field">
<span>Automatische Antwort</span>
<span class="switch-line"><input data-field="auto_reply" type="checkbox"><span>auto_reply</span></span>
</label>
<label class="field span-3">
<span>Min. Score</span>
<input data-field="min_score" type="number" min="0" max="1" step="0.01">
</label>
<label class="field span-3">
<span>Sprache</span>
<input data-field="language" placeholder="de-DE">
</label>
<label class="field span-3">
<span>Kommunikationsstil</span>
<input data-field="communication_style" placeholder="formal">
</label>
<label class="field span-4">
<span>Quelle</span>
<input data-field="source" placeholder="Microsoft Learn">
</label>
<label class="field span-8">
<span>Quell-URL</span>
<input data-field="source_uri" placeholder="https://…">
</label>
<label class="field span-6">
<span>Keywords <small>eine Zeile pro Wert</small></span>
<textarea data-list-field="keywords" rows="7" placeholder="Windows&#10;0x80070005&#10;Zugriff verweigert"></textarea>
</label>
<label class="field span-6">
<span>Kategorien <small>eine Zeile pro Wert</small></span>
<textarea data-list-field="categories" rows="7" placeholder="Windows&#10;Aktivierung"></textarea>
</label>
</div>
</div>
<div id="rawTab" class="tab-panel">
<div id="rawError" class="inline-error hidden"></div>
<textarea id="rawEditor" class="raw-editor" spellcheck="false"></textarea>
</div>
</section>
</main>
</div>
<dialog id="bulkDialog" class="modal">
<form method="dialog" class="modal-card" id="bulkForm">
<header class="modal-head">
<div>
<h2>Massenbearbeitung</h2>
<p id="bulkTargetText"></p>
</div>
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
</header>
<div class="modal-body">
<label class="target-choice">
<input id="bulkAllMatching" type="checkbox">
<span><strong>Alle aktuellen Treffer bearbeiten</strong><small id="allMatchingHint"></small></span>
</label>
<div class="bulk-grid">
<div class="bulk-section">
<h3>Felder setzen</h3>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkAutoReply">
<span>auto_reply</span>
<select id="bulkAutoReply" disabled><option value="true">true</option><option value="false">false</option></select>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkMinScore">
<span>min_score</span>
<input id="bulkMinScore" type="number" min="0" max="1" step="0.01" value="0.78" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkLanguage">
<span>language</span>
<input id="bulkLanguage" value="de-DE" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkStyle">
<span>communication_style</span>
<input id="bulkStyle" value="formal" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkSource">
<span>source</span>
<input id="bulkSource" placeholder="Microsoft Learn" disabled>
</label>
<label class="bulk-control">
<input type="checkbox" data-enable="bulkSourceUri">
<span>source_uri</span>
<input id="bulkSourceUri" placeholder="https://…" disabled>
</label>
</div>
<div class="bulk-section">
<h3>Listen ändern</h3>
<label class="field compact"><span>Keywords hinzufügen</span><textarea id="addKeywords" rows="3" placeholder="ein Wert pro Zeile"></textarea></label>
<label class="field compact"><span>Keywords entfernen</span><textarea id="removeKeywords" rows="3"></textarea></label>
<label class="field compact"><span>Kategorien hinzufügen</span><textarea id="addCategories" rows="3"></textarea></label>
<label class="field compact"><span>Kategorien entfernen</span><textarea id="removeCategories" rows="3"></textarea></label>
</div>
</div>
<div class="bulk-section replace-section">
<h3>Suchen & Ersetzen <small>optional</small></h3>
<div class="replace-grid">
<input id="replaceFind" placeholder="Suchen nach …">
<input id="replaceWith" placeholder="Ersetzen durch …">
</div>
<div class="replace-options">
<label><input type="checkbox" name="replaceField" value="title" checked> Titel</label>
<label><input type="checkbox" name="replaceField" value="text" checked> Text</label>
<label><input type="checkbox" name="replaceField" value="answer" checked> Antwort</label>
<label><input id="replaceRegex" type="checkbox"> Regex</label>
<label><input id="replaceCase" type="checkbox"> Groß-/Kleinschreibung</label>
</div>
</div>
<div id="bulkPreview" class="preview-box muted hidden"></div>
</div>
<footer class="modal-foot">
<span class="muted-text">Vor dem Anwenden wird automatisch ein Backup erstellt.</span>
<div>
<button value="cancel" class="btn ghost">Abbrechen</button>
<button id="previewBulkBtn" type="button" class="btn">Vorschau</button>
<button id="applyBulkBtn" type="button" class="btn danger" disabled>Änderungen anwenden</button>
</div>
</footer>
</form>
</dialog>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>
</html>

199
cmd/server/web/style.css Normal file
View File

@@ -0,0 +1,199 @@
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #11182b;
--panel-2: #151f35;
--panel-3: #1a2742;
--text: #eef3ff;
--muted: #93a0bb;
--border: #273654;
--accent: #7aa2ff;
--accent-2: #9d8cff;
--success: #47d7a7;
--danger: #ff6f7f;
--warning: #f2be61;
--shadow: 0 20px 60px rgba(0,0,0,.28);
--radius: 14px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
body { overflow: hidden; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
.hidden { display: none !important; }
.muted-text { color: var(--muted); font-size: 12px; }
.app-shell {
display: grid;
grid-template-columns: 410px minmax(0, 1fr);
grid-template-rows: 70px calc(100vh - 70px);
min-height: 100vh;
}
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: rgba(11,16,32,.94);
backdrop-filter: blur(14px);
z-index: 5;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 38px; height: 38px; display: grid; place-items: center;
border-radius: 11px; font-weight: 800; letter-spacing: -.04em;
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #07101f;
box-shadow: 0 8px 25px rgba(122,162,255,.28);
}
.brand-title { font-size: 15px; font-weight: 750; }
.brand-subtitle { font-size: 11px; color: var(--muted); margin-top: 2px; }
.top-actions { display: flex; align-items: center; gap: 9px; }
.btn, .icon-btn {
border: 1px solid var(--border); color: var(--text); background: var(--panel-2);
border-radius: 9px; padding: 9px 13px; transition: .16s ease;
}
.btn:hover, .icon-btn:hover { border-color: #3a4e76; transform: translateY(-1px); }
.btn:disabled, .icon-btn:disabled { opacity: .42; cursor: not-allowed; transform: none; }
.btn.primary { border-color: transparent; background: linear-gradient(135deg, #5c8eff, #8c72f2); }
.btn.success { border-color: rgba(71,215,167,.35); background: rgba(71,215,167,.13); color: #8cf0cd; }
.btn.danger { border-color: rgba(255,111,127,.35); background: rgba(255,111,127,.13); color: #ff9ca7; }
.btn.ghost { background: transparent; }
.shortcut { opacity: .5; font-size: 10px; margin-left: 5px; }
.icon-btn { width: 36px; height: 36px; padding: 0; font-size: 22px; display: grid; place-items: center; }
.pill, .badge {
display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 5px 9px;
font-size: 11px; border: 1px solid var(--border); background: rgba(255,255,255,.03);
}
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--success); }
.pill.muted { color: var(--muted); }
.badge.warn { color: var(--warning); border-color: rgba(242,190,97,.25); }
.sidebar {
grid-column: 1;
grid-row: 2;
min-height: 0;
display: grid;
grid-template-rows: auto auto 1fr auto;
border-right: 1px solid var(--border);
background: #0e1526;
}
.filters { padding: 14px; border-bottom: 1px solid var(--border); }
.search-wrap {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px;
background: var(--panel-2); border: 1px solid var(--border); border-radius: 11px; padding: 0 10px;
}
.search-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.08); }
.search-wrap input { border: 0; background: transparent; padding: 11px 0; outline: 0; color: var(--text); min-width: 0; }
kbd { color: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 5px; font-size: 10px; }
.filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
input, textarea, select {
width: 100%; color: var(--text); background: #0f1728; border: 1px solid var(--border); border-radius: 9px;
padding: 9px 10px; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.07); }
textarea { resize: vertical; line-height: 1.45; }
.list-tools { display: flex; justify-content: space-between; align-items: center; padding: 9px 14px; border-bottom: 1px solid var(--border); }
.check-label { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #c8d2e8; }
.check-label input, .target-choice input, .replace-options input, .bulk-control > input[type="checkbox"] { width: auto; accent-color: var(--accent); }
.result-list { overflow: auto; min-height: 0; }
.result-item {
display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 9px;
padding: 12px 13px; border-bottom: 1px solid rgba(39,54,84,.72); cursor: pointer; transition: background .12s;
}
.result-item:hover { background: rgba(122,162,255,.055); }
.result-item.active { background: rgba(122,162,255,.11); box-shadow: inset 3px 0 0 var(--accent); }
.result-check { margin-top: 4px; width: auto; accent-color: var(--accent); }
.result-title { font-size: 13px; line-height: 1.32; font-weight: 650; overflow-wrap: anywhere; }
.result-id { font-size: 10px; color: #9bb4e8; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-bottom: 4px; }
.result-meta { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 7px; color: var(--muted); font-size: 10px; }
.bool-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 3px; background: var(--danger); }
.bool-dot.true { background: var(--success); }
.pager { display: grid; grid-template-columns: 36px 1fr 36px; align-items: center; gap: 10px; padding: 11px 13px; border-top: 1px solid var(--border); }
.pager div { text-align: center; display: grid; gap: 2px; }
.pager strong { font-size: 12px; }
.pager span { font-size: 10px; color: var(--muted); }
.main-pane { grid-column: 2; grid-row: 2; overflow: auto; background: radial-gradient(circle at 70% 0%, rgba(103,85,190,.10), transparent 28%), var(--bg); }
.empty-state { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 40px; }
.empty-icon { font: 700 42px ui-monospace, monospace; color: var(--accent); opacity: .8; }
.empty-state h1 { margin: 12px 0 6px; font-size: 25px; }
.empty-state > p { color: var(--muted); max-width: 580px; }
.empty-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 760px; margin-top: 28px; }
.empty-cards div { text-align: left; background: rgba(17,24,43,.72); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
.empty-cards strong { display: block; font-size: 12px; margin-bottom: 5px; }
.empty-cards span { color: var(--muted); font-size: 11px; line-height: 1.4; }
.editor { min-height: 100%; }
.editor-head { position: sticky; top: 0; z-index: 4; display: flex; justify-content: space-between; align-items: center; padding: 12px 22px; border-bottom: 1px solid var(--border); background: rgba(11,16,32,.92); backdrop-filter: blur(14px); }
.breadcrumb { display: flex; align-items: center; gap: 9px; min-width: 0; }
#filePath { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55vw; }
.editor-actions { display: flex; gap: 8px; }
.tabs { display: flex; gap: 5px; padding: 14px 24px 0; }
.tab { border: 0; color: var(--muted); background: transparent; padding: 9px 12px; border-bottom: 2px solid transparent; }
.tab.active { color: var(--text); border-color: var(--accent); }
.tab-panel { display: none; padding: 18px 24px 50px; }
.tab-panel.active { display: block; }
.form-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; max-width: 1200px; margin: 0 auto; }
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
.span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-10 { grid-column: span 10; } .span-12 { grid-column: span 12; }
.field { display: grid; gap: 6px; min-width: 0; }
.field > span { font-size: 11px; color: #bac6dc; font-weight: 650; }
.field small { color: var(--muted); font-weight: 400; margin-left: 6px; }
.switch-field { align-content: end; }
.switch-line { min-height: 39px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: #0f1728; }
.switch-line input { width: auto; accent-color: var(--accent); }
.raw-editor { min-height: calc(100vh - 190px); resize: none; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; }
.inline-error { margin-bottom: 10px; padding: 10px 12px; border-radius: 9px; color: #ffb3bb; background: rgba(255,111,127,.09); border: 1px solid rgba(255,111,127,.25); font-size: 12px; }
.modal { width: min(1050px, calc(100vw - 40px)); max-height: calc(100vh - 40px); padding: 0; border: 1px solid var(--border); border-radius: 16px; color: var(--text); background: #0e1628; box-shadow: var(--shadow); }
.modal::backdrop { background: rgba(3,7,15,.72); backdrop-filter: blur(5px); }
.modal-card { display: grid; grid-template-rows: auto 1fr auto; max-height: calc(100vh - 42px); }
.modal-head, .modal-foot { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid var(--border); }
.modal-head h2 { margin: 0; font-size: 18px; }
.modal-head p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
.modal-body { overflow: auto; padding: 18px; }
.modal-foot { border-bottom: 0; border-top: 1px solid var(--border); gap: 10px; }
.modal-foot > div { display: flex; gap: 8px; }
.target-choice { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border-radius: 11px; background: rgba(122,162,255,.06); border: 1px solid rgba(122,162,255,.18); margin-bottom: 16px; }
.target-choice span { display: grid; gap: 3px; }
.target-choice strong { font-size: 12px; }
.target-choice small { color: var(--muted); }
.bulk-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
.bulk-section { border: 1px solid var(--border); border-radius: 12px; padding: 13px; background: rgba(255,255,255,.018); }
.bulk-section h3 { margin: 0 0 11px; font-size: 12px; }
.bulk-section h3 small { color: var(--muted); font-weight: 400; }
.bulk-control { display: grid; grid-template-columns: 20px 150px 1fr; gap: 8px; align-items: center; margin-top: 8px; font-size: 11px; }
.bulk-control input, .bulk-control select { padding: 7px 8px; }
.field.compact { margin-top: 9px; }
.field.compact textarea { min-height: 60px; padding: 7px 8px; font-size: 11px; }
.replace-section { margin-top: 15px; }
.replace-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
.replace-options { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; color: #c2cce1; font-size: 11px; }
.replace-options label { display: flex; align-items: center; gap: 5px; }
.preview-box { margin-top: 15px; border-radius: 11px; border: 1px solid var(--border); padding: 12px; font-size: 12px; }
.preview-box.ok { color: #a1ebd3; background: rgba(71,215,167,.06); border-color: rgba(71,215,167,.2); }
.preview-box.warn { color: #f6d99e; background: rgba(242,190,97,.06); border-color: rgba(242,190,97,.2); }
.preview-samples { margin-top: 8px; display: grid; gap: 4px; max-height: 150px; overflow: auto; }
.preview-samples code { color: #aec5f8; font-size: 10px; }
.toast-host { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; pointer-events: none; }
.toast { max-width: 420px; padding: 11px 13px; border-radius: 10px; background: #18243c; border: 1px solid #334869; box-shadow: var(--shadow); font-size: 12px; animation: toast-in .18s ease-out; }
.toast.error { border-color: rgba(255,111,127,.35); color: #ffc0c6; }
.toast.success { border-color: rgba(71,215,167,.35); color: #a1ebd3; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
@media (max-width: 950px) {
body { overflow: auto; }
.app-shell { grid-template-columns: 1fr; grid-template-rows: 70px minmax(420px, 48vh) auto; }
.topbar { grid-row: 1; }
.sidebar { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--border); }
.main-pane { grid-column: 1; grid-row: 3; min-height: 60vh; }
.empty-cards { grid-template-columns: 1fr; }
.span-2,.span-3,.span-4,.span-6,.span-8,.span-10 { grid-column: span 12; }
.bulk-grid { grid-template-columns: 1fr; }
.top-actions .pill { display: none; }
}

54
docker-compose.dual.yml Normal file
View File

@@ -0,0 +1,54 @@
# Beispiel: Dasselbe Image gleichzeitig als Editor und als Helpdesk-Suche.
# Start: docker compose -f docker-compose.dual.yml up --build -d
services:
kb-editor:
build: .
image: kb-helpdesk:local
restart: unless-stopped
ports:
- "${KB_EDITOR_PORT:-8080}:8080"
environment:
APP_MODE: editor
APP_TITLE: "${EDITOR_TITLE:-KB Administration}"
APP_SUBTITLE: "${EDITOR_SUBTITLE:-Wissensbasis verwalten}"
DATA_DIR: /data/knowledge
BACKUP_DIR: /data/backups
LISTEN_ADDR: :8080
BASIC_AUTH_USER: "${EDITOR_AUTH_USER:-}"
BASIC_AUTH_PASSWORD: "${EDITOR_AUTH_PASSWORD:-}"
volumes:
- "${KB_DATA_PATH:-./knowledge}:/data/knowledge:rw"
- "${KB_BACKUP_PATH:-./backups}:/data/backups:rw"
read_only: true
tmpfs:
- /tmp:size=32m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
kb-search:
image: kb-helpdesk:local
depends_on:
- kb-editor
restart: unless-stopped
ports:
- "${KB_SEARCH_PORT:-8081}:8080"
environment:
APP_MODE: google
APP_TITLE: "${SEARCH_TITLE:-IT Helpdesk Wissen}"
APP_SUBTITLE: "${SEARCH_SUBTITLE:-Interne Lösungsdatenbank}"
AUTO_RELOAD_INTERVAL: "${AUTO_RELOAD_INTERVAL:-60s}"
DATA_DIR: /data/knowledge
LISTEN_ADDR: :8080
BASIC_AUTH_USER: "${SEARCH_AUTH_USER:-}"
BASIC_AUTH_PASSWORD: "${SEARCH_AUTH_PASSWORD:-}"
volumes:
- "${KB_DATA_PATH:-./knowledge}:/data/knowledge:ro"
read_only: true
tmpfs:
- /tmp:size=32m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL

27
docker-compose.yml Normal file
View File

@@ -0,0 +1,27 @@
services:
kb-helpdesk:
build: .
container_name: kb-helpdesk
restart: unless-stopped
ports:
- "${KB_EDITOR_PORT:-8080}:8080"
environment:
APP_MODE: "${APP_MODE:-editor}"
APP_TITLE: "${APP_TITLE:-}"
APP_SUBTITLE: "${APP_SUBTITLE:-}"
AUTO_RELOAD_INTERVAL: "${AUTO_RELOAD_INTERVAL:-}"
DATA_DIR: /data/knowledge
BACKUP_DIR: /data/backups
LISTEN_ADDR: :8080
BASIC_AUTH_USER: "${BASIC_AUTH_USER:-}"
BASIC_AUTH_PASSWORD: "${BASIC_AUTH_PASSWORD:-}"
volumes:
- "${KB_DATA_PATH:-./knowledge}:/data/knowledge:${KB_DATA_MOUNT_MODE:-rw}"
- "${KB_BACKUP_PATH:-./backups}:/data/backups"
read_only: true
tmpfs:
- /tmp:size=32m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module kb-editor
go 1.26

1068
internal/store/store.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,163 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func writeDoc(t *testing.T, dir, name string, doc map[string]any) {
t.Helper()
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoadSearchSaveAndBulk(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "a.json", map[string]any{
"id": "KB-1", "title": "Fehler 0x80070005", "text": "Zugriff verweigert",
"answer": "Prüfen", "auto_reply": true, "min_score": 0.78,
"keywords": []string{"Windows", "0x80070005"}, "categories": []string{},
"language": "de-DE", "communication_style": "formal", "source": "Microsoft Learn",
"custom_field": "must survive",
})
writeDoc(t, dir, "b.json", map[string]any{
"id": "KB-2", "title": "Setup", "auto_reply": false, "keywords": []string{"Setup"},
})
t.Setenv("BACKUP_DIR", filepath.Join(dir, "backups"))
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
if s.Count() != 2 {
t.Fatalf("count=%d", s.Count())
}
got := s.List(Query{Q: "80070005", Page: 1, PageSize: 10})
if got.Total != 1 || got.Items[0].ID != "KB-1" {
t.Fatalf("unexpected list: %+v", got)
}
key := got.Items[0].Key
doc, _, err := s.Get(key)
if err != nil {
t.Fatal(err)
}
doc["answer"] = "Neue Lösung"
if _, _, err := s.Save(key, doc); err != nil {
t.Fatal(err)
}
reloaded, _, err := s.Get(key)
if err != nil {
t.Fatal(err)
}
if reloaded["custom_field"] != "must survive" {
t.Fatal("unknown field was lost")
}
if reloaded["answer"] != "Neue Lösung" {
t.Fatalf("answer=%v", reloaded["answer"])
}
yes := true
patch := BulkPatch{SetAutoReply: &yes, AddKeywords: []string{"Geprüft"}, FindReplace: &FindReplace{
Fields: []string{"title"}, Find: "setup", Replace: "Upgrade", CaseSensitive: false,
}}
all := s.MatchingKeys(Query{Page: 1, PageSize: 100})
preview, err := s.ApplyBulk(all, patch, true)
if err != nil {
t.Fatal(err)
}
if preview.Changed != 2 {
t.Fatalf("preview changed=%d", preview.Changed)
}
applied, err := s.ApplyBulk(all, patch, false)
if err != nil {
t.Fatal(err)
}
if applied.Changed != 2 || applied.Backup == "" {
t.Fatalf("applied=%+v", applied)
}
setup := s.List(Query{Q: "upgrade", Page: 1, PageSize: 10})
if setup.Total != 1 {
t.Fatalf("upgrade total=%d", setup.Total)
}
}
func TestExternalChangeIsRejected(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "a.json", map[string]any{"id": "KB-1", "title": "Original", "auto_reply": true})
t.Setenv("BACKUP_DIR", filepath.Join(dir, "..", "backups-external"))
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
item := s.List(Query{Page: 1, PageSize: 10}).Items[0]
doc, _, err := s.Get(item.Key)
if err != nil {
t.Fatal(err)
}
// Simulate an external writer after the editor indexed the file.
writeDoc(t, dir, "a.json", map[string]any{"id": "KB-1", "title": "Extern geändert", "auto_reply": true})
doc["title"] = "Editor geändert"
if _, _, err := s.Save(item.Key, doc); err == nil {
t.Fatal("expected external-change conflict")
}
}
func TestPreferredJSONFieldOrder(t *testing.T) {
payload, err := marshalDocument(map[string]any{
"language": "de-DE", "answer": "A", "id": "KB-1", "title": "T", "custom_z": 1, "custom_a": 2,
})
if err != nil {
t.Fatal(err)
}
s := string(payload)
wantOrder := []string{`"id"`, `"title"`, `"answer"`, `"language"`, `"custom_a"`, `"custom_z"`}
last := -1
for _, needle := range wantOrder {
idx := strings.Index(s, needle)
if idx <= last {
t.Fatalf("field %s out of order in %s", needle, s)
}
last = idx
}
}
func TestSearchRanksExactIdentifiersAndBuildsExcerpt(t *testing.T) {
dir := t.TempDir()
writeDoc(t, dir, "exact.json", map[string]any{
"id": "KB-0x80070005", "title": "Fehler 0x80070005", "text": "Zugriff verweigert. Prüfe die Berechtigungen.",
"keywords": []string{"0x80070005", "Access denied"},
})
writeDoc(t, dir, "answer.json", map[string]any{
"id": "KB-2", "title": "Allgemeine Reparatur", "answer": "Diese Anleitung erwähnt 0x80070005 nur als Beispiel.",
})
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
result := s.Search(Query{Q: "0x80070005", Page: 1, PageSize: 10})
if result.Total != 2 {
t.Fatalf("total=%d", result.Total)
}
if result.Items[0].ID != "KB-0x80070005" {
t.Fatalf("unexpected ranking: %+v", result.Items)
}
if result.Items[0].Score <= result.Items[1].Score {
t.Fatalf("expected first score to be higher: %+v", result.Items)
}
if !strings.Contains(strings.ToLower(result.Items[0].Excerpt), "zugriff") {
t.Fatalf("unexpected excerpt: %q", result.Items[0].Excerpt)
}
}

0
knowledge/.gitkeep Normal file
View File