diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..354ad2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +backups +knowledge +*.zip +kb-editor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9289ba6 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -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 }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..beeb9f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/backups/* +!/backups/.gitkeep +/knowledge/* +!/knowledge/.gitkeep +/kb-editor +.env +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0770670 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f79ec63 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index ec117d2..28a3085 100644 --- a/README.md +++ b/README.md @@ -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=` 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 +``` diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/server/app.go b/cmd/server/app.go new file mode 100644 index 0000000..d0b4c5a --- /dev/null +++ b/cmd/server/app.go @@ -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)}) +} diff --git a/cmd/server/app_test.go b/cmd/server/app_test.go new file mode 100644 index 0000000..373c73d --- /dev/null +++ b/cmd/server/app_test.go @@ -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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..191ea58 --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/cmd/server/viewer/app.js b/cmd/server/viewer/app.js new file mode 100644 index 0000000..2bffb7e --- /dev/null +++ b/cmd/server/viewer/app.js @@ -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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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, '$1'); + } + + 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 = `${escapeHTML(facet.name)}${facet.count.toLocaleString('de-DE')}`; + heroButton.addEventListener('click', () => submitSearch(facet.name)); + els.quickLinks.appendChild(heroButton); + + const sideButton = document.createElement('button'); + sideButton.type = 'button'; + sideButton.className = 'facet-button'; + sideButton.innerHTML = `${escapeHTML(facet.name)}${facet.count.toLocaleString('de-DE')}`; + 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 = ` + + `; + $('.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 => `${escapeHTML(value)}`).join(''); + + const tags = [...new Set([...(Array.isArray(doc.categories) ? doc.categories : []), ...(Array.isArray(doc.keywords) ? doc.keywords : [])])]; + els.articleTags.innerHTML = tags.map(tag => `${escapeHTML(tag)}`).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(); +})(); diff --git a/cmd/server/viewer/index.html b/cmd/server/viewer/index.html new file mode 100644 index 0000000..43ca0f4 --- /dev/null +++ b/cmd/server/viewer/index.html @@ -0,0 +1,142 @@ + + + + + + + Helpdesk Search + + + +
+ + H + + Helpdesk Search + Interne Wissenssuche für den Helpdesk + + +
+ Nur lesen + Wissensbasis lädt … +
+
+ +
+
+
+
+
+
INTERNES HELPDESK-WISSEN
+

Was möchtest du lösen?

+

Durchsuche Fehlercodes, Symptome, Produkte, Keywords und dokumentierte Lösungen in einer zentralen Wissensbasis.

+ + +
+
+ + +
+ + +
+
+
+
+

+
+ +
+ +
+
+ +
+
PROBLEM / ERKENNUNG
+
+
+ +
+
+
+
LÖSUNG / ANTWORT
+ Empfohlene Vorgehensweise +
+ +
+
+
+ +
+
EINORDNUNG
+
+
+ + +
+ +
+ + +
+
+
+ +
+ + + diff --git a/cmd/server/viewer/style.css b/cmd/server/viewer/style.css new file mode 100644 index 0000000..64438bd --- /dev/null +++ b/cmd/server/viewer/style.css @@ -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; } +} diff --git a/cmd/server/web/app.js b/cmd/server/web/app.js new file mode 100644 index 0000000..abdf6c4 --- /dev/null +++ b/cmd/server/web/app.js @@ -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 = '
Lade …
'; + 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 = `
${escapeHTML(err.message)}
`; + } + } + + function renderList() { + els.resultList.innerHTML = ''; + if (state.items.length === 0) { + els.resultList.innerHTML = '
Keine Treffer.
'; + } + 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 = ` + +
+
${escapeHTML(item.id || item.rel_path)}
+
${escapeHTML(item.title || '(ohne Titel)')}
+
+ auto ${String(item.auto_reply ?? '–')} + score ${item.min_score ?? '–'} + ${escapeHTML(item.language || '–')} + ${escapeHTML(item.source || '')} +
+
`; + 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 = ` + Vorschau: ${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 ? `
${result.sample.map(x => `${escapeHTML(x.id || x.rel_path)} · ${escapeHTML(x.title || '')}`).join('')}
` : ''}`; + } + + 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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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); +})(); diff --git a/cmd/server/web/index.html b/cmd/server/web/index.html new file mode 100644 index 0000000..c8f92e8 --- /dev/null +++ b/cmd/server/web/index.html @@ -0,0 +1,251 @@ + + + + + + KB Mass Editor + + + +
+
+
+
KB
+
+
Knowledge Base Editor
+
JSON · Massenbearbeitung · Docker
+
+
+
+ Verbinde … + + +
+
+ + + +
+
+
{ }
+

JSON-Wissensbasis bearbeiten

+

Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.

+
+
SicherAutomatische Backups vor jedem Schreibvorgang
+
SchnellIndexierte Suche auch bei zehntausenden JSON-Dateien
+
FlexibelFormularansicht und vollständiger Raw-JSON-Editor
+
+
+ + +
+
+ + + + + +
+ + + diff --git a/cmd/server/web/style.css b/cmd/server/web/style.css new file mode 100644 index 0000000..307fdfd --- /dev/null +++ b/cmd/server/web/style.css @@ -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; } +} diff --git a/docker-compose.dual.yml b/docker-compose.dual.yml new file mode 100644 index 0000000..caa58d7 --- /dev/null +++ b/docker-compose.dual.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..20f415b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5dda4e7 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module kb-editor + +go 1.26 diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..0c29393 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,1068 @@ +package store + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type Store struct { + mu sync.RWMutex + dataDir string + backupDir string + records map[string]*record + order []string +} + +type record struct { + Key string + RelPath string + Path string + Doc map[string]any + ModTime time.Time + Size int64 + Checksum string + Search string +} + +type Summary struct { + Key string `json:"key"` + ID string `json:"id"` + Title string `json:"title"` + AutoReply *bool `json:"auto_reply,omitempty"` + MinScore *float64 `json:"min_score,omitempty"` + Language string `json:"language"` + CommunicationStyle string `json:"communication_style"` + Source string `json:"source"` + Keywords []string `json:"keywords"` + Categories []string `json:"categories"` + RelPath string `json:"rel_path"` + ModifiedAt string `json:"modified_at"` + Size int64 `json:"size"` + Checksum string `json:"checksum"` +} + +type Query struct { + Q string `json:"q"` + AutoReply string `json:"auto_reply"` + Language string `json:"language"` + CommunicationStyle string `json:"communication_style"` + Source string `json:"source"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type ListResult struct { + Items []Summary `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +type SearchHit struct { + Summary + Excerpt string `json:"excerpt"` + Score int `json:"score"` +} + +type SearchResult struct { + Items []SearchHit `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` + Query string `json:"query"` +} + +type Facet struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type Facets struct { + Categories []Facet `json:"categories"` + Keywords []Facet `json:"keywords"` + Sources []Facet `json:"sources"` +} + +type FindReplace struct { + Fields []string `json:"fields"` + Find string `json:"find"` + Replace string `json:"replace"` + Regex bool `json:"regex"` + CaseSensitive bool `json:"case_sensitive"` +} + +type BulkPatch struct { + SetAutoReply *bool `json:"set_auto_reply,omitempty"` + SetMinScore *float64 `json:"set_min_score,omitempty"` + SetLanguage *string `json:"set_language,omitempty"` + SetCommunicationStyle *string `json:"set_communication_style,omitempty"` + SetSource *string `json:"set_source,omitempty"` + SetSourceURI *string `json:"set_source_uri,omitempty"` + AddKeywords []string `json:"add_keywords,omitempty"` + RemoveKeywords []string `json:"remove_keywords,omitempty"` + AddCategories []string `json:"add_categories,omitempty"` + RemoveCategories []string `json:"remove_categories,omitempty"` + FindReplace *FindReplace `json:"find_replace,omitempty"` +} + +type BulkResult struct { + Targeted int `json:"targeted"` + Changed int `json:"changed"` + Skipped int `json:"skipped"` + Keys []string `json:"keys"` + Sample []Summary `json:"sample"` + Backup string `json:"backup,omitempty"` + DryRun bool `json:"dry_run"` +} + +func New(dataDir string) (*Store, error) { + abs, err := filepath.Abs(dataDir) + if err != nil { + return nil, err + } + if err := os.MkdirAll(abs, 0o755); err != nil { + return nil, fmt.Errorf("create data directory: %w", err) + } + + backup := strings.TrimSpace(os.Getenv("BACKUP_DIR")) + if backup == "" { + backup = filepath.Join(filepath.Dir(abs), ".kb-editor-backups") + } + if !filepath.IsAbs(backup) { + backup, err = filepath.Abs(backup) + if err != nil { + return nil, err + } + } + + s := &Store{dataDir: abs, backupDir: backup, records: make(map[string]*record)} + if err := s.Reload(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) DataDir() string { return s.dataDir } +func (s *Store) BackupDir() string { return s.backupDir } + +func (s *Store) Count() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.records) +} + +func (s *Store) Reload() error { + records := make(map[string]*record) + order := make([]string, 0) + + err := filepath.WalkDir(s.dataDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + if samePath(path, s.backupDir) { + return filepath.SkipDir + } + return nil + } + if !strings.EqualFold(filepath.Ext(d.Name()), ".json") { + return nil + } + rec, err := s.readRecord(path) + if err != nil { + return fmt.Errorf("load %s: %w", path, err) + } + if _, exists := records[rec.Key]; exists { + return fmt.Errorf("duplicate internal key for %s", rec.RelPath) + } + records[rec.Key] = rec + order = append(order, rec.Key) + return nil + }) + if err != nil { + return err + } + + sort.Slice(order, func(i, j int) bool { + a, b := records[order[i]], records[order[j]] + at, bt := strings.ToLower(str(a.Doc["title"])), strings.ToLower(str(b.Doc["title"])) + if at == bt { + return a.RelPath < b.RelPath + } + return at < bt + }) + + s.mu.Lock() + s.records = records + s.order = order + s.mu.Unlock() + return nil +} + +func (s *Store) readRecord(path string) (*record, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc map[string]any + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + if doc == nil { + return nil, errors.New("JSON root must be an object") + } + rel, err := filepath.Rel(s.dataDir, path) + if err != nil { + return nil, err + } + rel = filepath.ToSlash(rel) + st, err := os.Stat(path) + if err != nil { + return nil, err + } + sum := sha256.Sum256(b) + rec := &record{ + Key: encodeKey(rel), + RelPath: rel, + Path: path, + Doc: doc, + ModTime: st.ModTime(), + Size: st.Size(), + Checksum: fmt.Sprintf("%x", sum[:8]), + } + rec.Search = buildSearch(doc, rel) + return rec, nil +} + +func encodeKey(rel string) string { + return base64.RawURLEncoding.EncodeToString([]byte(rel)) +} + +func (s *Store) Get(key string) (map[string]any, Summary, error) { + s.mu.RLock() + rec, ok := s.records[key] + if !ok { + s.mu.RUnlock() + return nil, Summary{}, os.ErrNotExist + } + doc := cloneMap(rec.Doc) + summary := summarize(rec) + s.mu.RUnlock() + return doc, summary, nil +} + +func (s *Store) List(q Query) ListResult { + if q.Page < 1 { + q.Page = 1 + } + if q.PageSize < 1 { + q.PageSize = 50 + } + if q.PageSize > 500 { + q.PageSize = 500 + } + + s.mu.RLock() + defer s.mu.RUnlock() + + matches := make([]string, 0) + for _, key := range s.order { + rec := s.records[key] + if match(rec, q) { + matches = append(matches, key) + } + } + + total := len(matches) + totalPages := 0 + if total > 0 { + totalPages = (total + q.PageSize - 1) / q.PageSize + } + if totalPages > 0 && q.Page > totalPages { + q.Page = totalPages + } + start := (q.Page - 1) * q.PageSize + if start < 0 { + start = 0 + } + if start > total { + start = total + } + end := start + q.PageSize + if end > total { + end = total + } + items := make([]Summary, 0, end-start) + for _, key := range matches[start:end] { + items = append(items, summarize(s.records[key])) + } + return ListResult{Items: items, Total: total, Page: q.Page, PageSize: q.PageSize, TotalPages: totalPages} +} + +func (s *Store) Search(q Query) SearchResult { + if q.Page < 1 { + q.Page = 1 + } + if q.PageSize < 1 { + q.PageSize = 20 + } + if q.PageSize > 100 { + q.PageSize = 100 + } + + s.mu.RLock() + defer s.mu.RUnlock() + + type ranked struct { + rec *record + score int + } + matches := make([]ranked, 0) + for _, key := range s.order { + rec := s.records[key] + if !match(rec, q) { + continue + } + matches = append(matches, ranked{rec: rec, score: relevanceScore(rec, q.Q)}) + } + + if strings.TrimSpace(q.Q) != "" { + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score > matches[j].score + } + ai := strings.ToLower(str(matches[i].rec.Doc["title"])) + aj := strings.ToLower(str(matches[j].rec.Doc["title"])) + if ai != aj { + return ai < aj + } + return matches[i].rec.RelPath < matches[j].rec.RelPath + }) + } + + total := len(matches) + totalPages := 0 + if total > 0 { + totalPages = (total + q.PageSize - 1) / q.PageSize + } + if totalPages > 0 && q.Page > totalPages { + q.Page = totalPages + } + start := (q.Page - 1) * q.PageSize + if start < 0 { + start = 0 + } + if start > total { + start = total + } + end := start + q.PageSize + if end > total { + end = total + } + + items := make([]SearchHit, 0, end-start) + for _, item := range matches[start:end] { + items = append(items, SearchHit{ + Summary: summarize(item.rec), + Excerpt: searchExcerpt(item.rec.Doc, q.Q), + Score: item.score, + }) + } + return SearchResult{ + Items: items, Total: total, Page: q.Page, PageSize: q.PageSize, + TotalPages: totalPages, Query: strings.TrimSpace(q.Q), + } +} + +func (s *Store) Facets(limit int) Facets { + if limit < 1 { + limit = 8 + } + if limit > 30 { + limit = 30 + } + s.mu.RLock() + defer s.mu.RUnlock() + + categories := make(map[string]int) + keywords := make(map[string]int) + sources := make(map[string]int) + ignoredKeywords := map[string]struct{}{ + "microsoft": {}, "windows": {}, "fehler": {}, "fehlercode": {}, "error": {}, "status": {}, + } + for _, rec := range s.records { + for _, category := range toStrings(rec.Doc["categories"]) { + category = strings.TrimSpace(category) + if category != "" { + categories[category]++ + } + } + for _, keyword := range toStrings(rec.Doc["keywords"]) { + keyword = strings.TrimSpace(keyword) + lower := strings.ToLower(keyword) + if keyword == "" || strings.HasPrefix(lower, "0x") { + continue + } + if _, ignored := ignoredKeywords[lower]; ignored { + continue + } + keywords[keyword]++ + } + source := strings.TrimSpace(str(rec.Doc["source"])) + if source != "" { + sources[source]++ + } + } + return Facets{ + Categories: topFacets(categories, limit), + Keywords: topFacets(keywords, limit), + Sources: topFacets(sources, limit), + } +} + +func topFacets(values map[string]int, limit int) []Facet { + out := make([]Facet, 0, len(values)) + for name, count := range values { + out = append(out, Facet{Name: name, Count: count}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + if len(out) > limit { + out = out[:limit] + } + return out +} + +func relevanceScore(rec *record, query string) int { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return 0 + } + terms := strings.Fields(query) + fields := []struct { + value string + weight int + }{ + {strings.ToLower(str(rec.Doc["id"])), 28}, + {strings.ToLower(str(rec.Doc["title"])), 24}, + {strings.ToLower(strings.Join(toStrings(rec.Doc["keywords"]), " ")), 18}, + {strings.ToLower(strings.Join(toStrings(rec.Doc["categories"]), " ")), 10}, + {strings.ToLower(str(rec.Doc["text"])), 7}, + {strings.ToLower(str(rec.Doc["answer"])), 5}, + {strings.ToLower(str(rec.Doc["source"])), 2}, + } + + score := 0 + for idx, field := range fields { + if field.value == query { + score += field.weight * 8 + } + if strings.Contains(field.value, query) { + score += field.weight * 3 + } + for _, term := range terms { + if field.value == term { + score += field.weight * 4 + } else if strings.Contains(field.value, term) { + score += field.weight + } + } + if idx == 1 && strings.HasPrefix(field.value, query) { + score += 40 + } + } + return score +} + +func searchExcerpt(doc map[string]any, query string) string { + candidates := []string{str(doc["text"]), str(doc["answer"])} + terms := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + for _, candidate := range candidates { + candidate = cleanExcerpt(candidate) + if candidate == "" { + continue + } + lower := strings.ToLower(candidate) + pos := -1 + for _, term := range terms { + if i := strings.Index(lower, term); i >= 0 && (pos < 0 || i < pos) { + pos = i + } + } + if pos < 0 { + return truncateRunes(candidate, 300) + } + runes := []rune(candidate) + prefixRunes := []rune(candidate[:pos]) + start := len(prefixRunes) - 90 + if start < 0 { + start = 0 + } + end := start + 320 + if end > len(runes) { + end = len(runes) + } + excerpt := strings.TrimSpace(string(runes[start:end])) + if start > 0 { + excerpt = "… " + excerpt + } + if end < len(runes) { + excerpt += " …" + } + return excerpt + } + return "" +} + +func cleanExcerpt(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return strings.TrimSpace(string(r[:max])) + " …" +} + +func (s *Store) MatchingKeys(q Query) []string { + s.mu.RLock() + defer s.mu.RUnlock() + keys := make([]string, 0) + for _, key := range s.order { + if match(s.records[key], q) { + keys = append(keys, key) + } + } + return keys +} + +func match(rec *record, q Query) bool { + if text := strings.TrimSpace(strings.ToLower(q.Q)); text != "" { + for _, term := range strings.Fields(text) { + if !strings.Contains(rec.Search, term) { + return false + } + } + } + if v := strings.TrimSpace(q.AutoReply); v != "" && v != "any" { + expected, err := strconv.ParseBool(v) + actual, ok := rec.Doc["auto_reply"].(bool) + if err != nil || !ok || actual != expected { + return false + } + } + if q.Language != "" && !strings.EqualFold(str(rec.Doc["language"]), q.Language) { + return false + } + if q.CommunicationStyle != "" && !strings.EqualFold(str(rec.Doc["communication_style"]), q.CommunicationStyle) { + return false + } + if q.Source != "" && !strings.Contains(strings.ToLower(str(rec.Doc["source"])), strings.ToLower(q.Source)) { + return false + } + return true +} + +func (s *Store) Save(key string, doc map[string]any) (Summary, string, error) { + if doc == nil { + return Summary{}, "", errors.New("JSON root must be an object") + } + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[key] + if !ok { + return Summary{}, "", os.ErrNotExist + } + if docsEqual(rec.Doc, doc) { + return summarize(rec), "", nil + } + if err := verifyUnchanged(rec); err != nil { + return Summary{}, "", err + } + backupBatch, err := s.newBackupBatch() + if err != nil { + return Summary{}, "", err + } + if err := s.backupRecord(rec, backupBatch); err != nil { + return Summary{}, "", err + } + newRec, err := s.writeRecord(rec, doc) + if err != nil { + return Summary{}, "", err + } + s.records[key] = newRec + s.resortLocked() + return summarize(newRec), backupBatch, nil +} + +func (s *Store) ApplyBulk(keys []string, patch BulkPatch, dryRun bool) (BulkResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + + keys = unique(keys) + result := BulkResult{Targeted: len(keys), DryRun: dryRun} + type pending struct { + key string + doc map[string]any + } + changes := make([]pending, 0) + for _, key := range keys { + rec, ok := s.records[key] + if !ok { + result.Skipped++ + continue + } + doc := cloneMap(rec.Doc) + changed, err := applyPatch(doc, patch) + if err != nil { + return result, fmt.Errorf("patch %s: %w", rec.RelPath, err) + } + if !changed { + result.Skipped++ + continue + } + changes = append(changes, pending{key: key, doc: doc}) + result.Changed++ + result.Keys = append(result.Keys, key) + if len(result.Sample) < 20 { + preview := *rec + preview.Doc = doc + preview.Search = buildSearch(doc, rec.RelPath) + result.Sample = append(result.Sample, summarize(&preview)) + } + } + if dryRun || len(changes) == 0 { + return result, nil + } + + for _, c := range changes { + if err := verifyUnchanged(s.records[c.key]); err != nil { + return result, err + } + } + + batch, err := s.newBackupBatch() + if err != nil { + return result, err + } + result.Backup = batch + + for _, c := range changes { + rec := s.records[c.key] + if err := s.backupRecord(rec, batch); err != nil { + return result, fmt.Errorf("backup %s: %w", rec.RelPath, err) + } + } + for _, c := range changes { + rec := s.records[c.key] + newRec, err := s.writeRecord(rec, c.doc) + if err != nil { + return result, fmt.Errorf("write %s: %w", rec.RelPath, err) + } + s.records[c.key] = newRec + } + s.resortLocked() + return result, nil +} + +func applyPatch(doc map[string]any, patch BulkPatch) (bool, error) { + before, _ := json.Marshal(doc) + if patch.SetAutoReply != nil { + doc["auto_reply"] = *patch.SetAutoReply + } + if patch.SetMinScore != nil { + doc["min_score"] = *patch.SetMinScore + } + if patch.SetLanguage != nil { + doc["language"] = *patch.SetLanguage + } + if patch.SetCommunicationStyle != nil { + doc["communication_style"] = *patch.SetCommunicationStyle + } + if patch.SetSource != nil { + doc["source"] = *patch.SetSource + } + if patch.SetSourceURI != nil { + doc["source_uri"] = *patch.SetSourceURI + } + if len(patch.AddKeywords) > 0 || len(patch.RemoveKeywords) > 0 { + doc["keywords"] = mutateStringList(toStrings(doc["keywords"]), patch.AddKeywords, patch.RemoveKeywords) + } + if len(patch.AddCategories) > 0 || len(patch.RemoveCategories) > 0 { + doc["categories"] = mutateStringList(toStrings(doc["categories"]), patch.AddCategories, patch.RemoveCategories) + } + if fr := patch.FindReplace; fr != nil && fr.Find != "" { + fields := fr.Fields + if len(fields) == 0 { + fields = []string{"title", "text", "answer"} + } + var re *regexp.Regexp + var err error + if fr.Regex { + pattern := fr.Find + if !fr.CaseSensitive { + pattern = "(?i)" + pattern + } + re, err = regexp.Compile(pattern) + if err != nil { + return false, fmt.Errorf("invalid regular expression: %w", err) + } + } + for _, field := range fields { + old, ok := doc[field].(string) + if !ok { + continue + } + var next string + if fr.Regex { + next = re.ReplaceAllString(old, fr.Replace) + } else if fr.CaseSensitive { + next = strings.ReplaceAll(old, fr.Find, fr.Replace) + } else { + next = replaceAllFold(old, fr.Find, fr.Replace) + } + doc[field] = next + } + } + after, _ := json.Marshal(doc) + return !bytes.Equal(before, after), nil +} + +func replaceAllFold(s, old, repl string) string { + if old == "" { + return s + } + lowerS := strings.ToLower(s) + lowerOld := strings.ToLower(old) + var b strings.Builder + pos := 0 + for { + idx := strings.Index(lowerS[pos:], lowerOld) + if idx < 0 { + b.WriteString(s[pos:]) + break + } + idx += pos + b.WriteString(s[pos:idx]) + b.WriteString(repl) + pos = idx + len(old) + } + return b.String() +} + +func mutateStringList(existing, add, remove []string) []string { + rm := make(map[string]struct{}) + for _, v := range remove { + rm[strings.ToLower(strings.TrimSpace(v))] = struct{}{} + } + seen := make(map[string]struct{}) + out := make([]string, 0, len(existing)+len(add)) + appendOne := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + k := strings.ToLower(v) + if _, bad := rm[k]; bad { + return + } + if _, ok := seen[k]; ok { + return + } + seen[k] = struct{}{} + out = append(out, v) + } + for _, v := range existing { + appendOne(v) + } + for _, v := range add { + appendOne(v) + } + return out +} + +func (s *Store) writeRecord(rec *record, doc map[string]any) (*record, error) { + payload, err := marshalDocument(doc) + if err != nil { + return nil, err + } + st, err := os.Stat(rec.Path) + if err != nil { + return nil, err + } + tmp, err := os.CreateTemp(filepath.Dir(rec.Path), ".kb-editor-*.tmp") + if err != nil { + return nil, err + } + tmpName := tmp.Name() + cleanup := func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + } + if err := tmp.Chmod(st.Mode().Perm()); err != nil { + cleanup() + return nil, err + } + if _, err := tmp.Write(payload); err != nil { + cleanup() + return nil, err + } + if err := tmp.Sync(); err != nil { + cleanup() + return nil, err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return nil, err + } + if err := os.Rename(tmpName, rec.Path); err != nil { + _ = os.Remove(tmpName) + return nil, err + } + return s.readRecord(rec.Path) +} + +func (s *Store) newBackupBatch() (string, error) { + stamp := time.Now().Format("20060102-150405.000000000") + dir := filepath.Join(s.backupDir, stamp) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create backup directory: %w", err) + } + return dir, nil +} + +func (s *Store) backupRecord(rec *record, batch string) error { + dst := filepath.Join(batch, filepath.FromSlash(rec.RelPath)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(rec.Path) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + ok := false + defer func() { + _ = out.Close() + if !ok { + _ = os.Remove(dst) + } + }() + if _, err := io.Copy(out, in); err != nil { + return err + } + if err := out.Sync(); err != nil { + return err + } + ok = true + return nil +} + +func (s *Store) resortLocked() { + sort.Slice(s.order, func(i, j int) bool { + a, b := s.records[s.order[i]], s.records[s.order[j]] + at, bt := strings.ToLower(str(a.Doc["title"])), strings.ToLower(str(b.Doc["title"])) + if at == bt { + return a.RelPath < b.RelPath + } + return at < bt + }) +} + +func summarize(rec *record) Summary { + var ar *bool + if v, ok := rec.Doc["auto_reply"].(bool); ok { + vv := v + ar = &vv + } + var ms *float64 + if v, ok := number(rec.Doc["min_score"]); ok { + vv := v + ms = &vv + } + return Summary{ + Key: rec.Key, + ID: str(rec.Doc["id"]), + Title: str(rec.Doc["title"]), + AutoReply: ar, + MinScore: ms, + Language: str(rec.Doc["language"]), + CommunicationStyle: str(rec.Doc["communication_style"]), + Source: str(rec.Doc["source"]), + Keywords: toStrings(rec.Doc["keywords"]), + Categories: toStrings(rec.Doc["categories"]), + RelPath: rec.RelPath, + ModifiedAt: rec.ModTime.Format(time.RFC3339), + Size: rec.Size, + Checksum: rec.Checksum, + } +} + +func buildSearch(doc map[string]any, rel string) string { + parts := []string{rel} + for _, key := range []string{"id", "title", "text", "answer", "source", "source_uri", "language", "communication_style"} { + parts = append(parts, str(doc[key])) + } + parts = append(parts, toStrings(doc["keywords"])...) + parts = append(parts, toStrings(doc["categories"])...) + return strings.ToLower(strings.Join(parts, "\n")) +} + +func cloneMap(in map[string]any) map[string]any { + b, _ := json.Marshal(in) + var out map[string]any + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() + _ = dec.Decode(&out) + return out +} + +func str(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + return fmt.Sprint(v) +} + +func number(v any) (float64, bool) { + switch x := v.(type) { + case float64: + return x, true + case float32: + return float64(x), true + case int: + return float64(x), true + case json.Number: + f, err := x.Float64() + return f, err == nil + default: + return 0, false + } +} + +func toStrings(v any) []string { + switch x := v.(type) { + case []string: + return append([]string(nil), x...) + case []any: + out := make([]string, 0, len(x)) + for _, item := range x { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + default: + return []string{} + } +} + +func marshalDocument(doc map[string]any) ([]byte, error) { + preferred := []string{"id", "title", "text", "answer", "auto_reply", "min_score", "categories", "keywords", "source", "source_uri", "language", "communication_style"} + seen := make(map[string]struct{}, len(doc)) + keys := make([]string, 0, len(doc)) + for _, key := range preferred { + if _, ok := doc[key]; ok { + keys = append(keys, key) + seen[key] = struct{}{} + } + } + extra := make([]string, 0) + for key := range doc { + if _, ok := seen[key]; !ok { + extra = append(extra, key) + } + } + sort.Strings(extra) + keys = append(keys, extra...) + + var out bytes.Buffer + out.WriteString("{\n") + for i, key := range keys { + kb, _ := json.Marshal(key) + vb, err := json.MarshalIndent(doc[key], "", " ") + if err != nil { + return nil, err + } + vb = bytes.ReplaceAll(vb, []byte("\n"), []byte("\n ")) + out.WriteString(" ") + out.Write(kb) + out.WriteString(": ") + out.Write(vb) + if i < len(keys)-1 { + out.WriteByte(',') + } + out.WriteByte('\n') + } + out.WriteString("}\n") + return out.Bytes(), nil +} + +func docsEqual(a, b map[string]any) bool { + ab, errA := json.Marshal(a) + bb, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(ab, bb) +} + +func verifyUnchanged(rec *record) error { + b, err := os.ReadFile(rec.Path) + if err != nil { + return fmt.Errorf("Datei vor dem Speichern erneut lesen: %w", err) + } + sum := sha256.Sum256(b) + current := fmt.Sprintf("%x", sum[:8]) + if current != rec.Checksum { + return fmt.Errorf("%s wurde außerhalb des Editors verändert; bitte zuerst neu einlesen", rec.RelPath) + } + return nil +} + +func samePath(a, b string) bool { + aa, errA := filepath.Abs(a) + bb, errB := filepath.Abs(b) + return errA == nil && errB == nil && filepath.Clean(aa) == filepath.Clean(bb) +} + +func unique(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..b85bb1d --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +} diff --git a/knowledge/.gitkeep b/knowledge/.gitkeep new file mode 100644 index 0000000..e69de29