Freigabe-Anpassung und Soft-Trash
All checks were successful
release-tag / release-image (push) Successful in 1m34s

This commit is contained in:
2026-07-29 11:08:03 +02:00
parent a33ff09c41
commit dc369dab65
14 changed files with 1233 additions and 122 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
@@ -12,6 +13,7 @@ import (
"strings"
"kb-editor/internal/aifallback"
"kb-editor/internal/staging"
"kb-editor/internal/store"
)
@@ -23,13 +25,15 @@ type appConfig struct {
AIFallbackEnabled bool `json:"ai_fallback_enabled"`
AIFallbackTimeoutSeconds int `json:"ai_fallback_timeout_seconds,omitempty"`
AIFallbackModel string `json:"ai_fallback_model,omitempty"`
StagingEnabled bool `json:"staging_enabled"`
}
type app struct {
store *store.Store
web fs.FS
config appConfig
ai *aifallback.Service
store *store.Store
web fs.FS
config appConfig
ai *aifallback.Service
staging *staging.Store
}
func newApp(s *store.Store, web fs.FS, configs ...appConfig) *app {
@@ -45,6 +49,12 @@ func (a *app) withAI(service *aifallback.Service) *app {
return a
}
func (a *app) withStaging(st *staging.Store) *app {
a.staging = st
a.config.StagingEnabled = st != nil
return a
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", a.handleHealth)
@@ -54,16 +64,25 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("GET /api/facets", a.handleFacets)
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback)
mux.HandleFunc("GET /api/staging", a.handleStagingList)
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
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)
mux.HandleFunc("PUT /api/staging/{key}", a.handleStagingPut)
mux.HandleFunc("DELETE /api/staging/{key}", a.handleStagingDelete)
mux.HandleFunc("POST /api/staging/{key}/promote", a.handleStagingPromote)
mux.HandleFunc("POST /api/staging/bulk", a.handleStagingBulk)
} else {
mux.HandleFunc("PUT /api/items/{key}", a.handleReadOnly)
mux.HandleFunc("POST /api/bulk", a.handleReadOnly)
mux.HandleFunc("POST /api/reload", a.handleReadOnly)
mux.HandleFunc("PUT /api/staging/{key}", a.handleReadOnly)
mux.HandleFunc("DELETE /api/staging/{key}", a.handleReadOnly)
mux.HandleFunc("POST /api/staging/{key}/promote", a.handleReadOnly)
mux.HandleFunc("POST /api/staging/bulk", a.handleReadOnly)
}
static := http.FileServer(http.FS(a.web))
@@ -90,6 +109,11 @@ func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
"mode": a.config.Mode,
"writable": a.config.Writable,
"ai_fallback_enabled": a.config.AIFallbackEnabled && a.ai != nil,
"staging_enabled": a.staging != nil,
}
if a.staging != nil {
payload["staging_count"] = a.staging.Count()
payload["staging_dir"] = a.staging.Dir()
}
if a.config.Writable {
payload["backup_dir"] = a.store.BackupDir()
@@ -187,12 +211,40 @@ func (a *app) handleAIFallback(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, result)
}
func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
if !a.config.AIFallbackEnabled || a.ai == nil {
writeError(w, http.StatusNotFound, "Staging-Viewer ist auf dieser Instanz deaktiviert")
func (a *app) handleStagingList(w http.ResponseWriter, r *http.Request) {
if !a.config.Writable {
writeError(w, http.StatusForbidden, "Die Staging-Liste ist nur im Editor-Modus verfügbar")
return
}
result, err := a.ai.GetStaging(r.PathValue("key"))
if a.staging == nil {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
v := r.URL.Query()
page, _ := strconv.Atoi(v.Get("page"))
pageSize, _ := strconv.Atoi(v.Get("page_size"))
result, err := a.staging.List(staging.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,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
if a.staging == nil || (!a.config.Writable && (!a.config.AIFallbackEnabled || a.ai == nil)) {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
result, err := a.staging.Get(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
return
@@ -204,6 +256,141 @@ func (a *app) handleStagingGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result)
}
type stagingBulkRequest struct {
Keys []string `json:"keys"`
Action string `json:"action"`
}
func (a *app) handleStagingPut(w http.ResponseWriter, r *http.Request) {
if a.staging == nil {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
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
}
result, err := a.staging.Update(r.PathValue("key"), doc)
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": result.Document, "meta": result.Meta})
}
func (a *app) handleStagingDelete(w http.ResponseWriter, r *http.Request) {
if a.staging == nil {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
trash, err := a.staging.Delete(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "trash": trash})
}
func (a *app) handleStagingPromote(w http.ResponseWriter, r *http.Request) {
if a.staging == nil {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
result, err := a.promoteStaging(r.PathValue("key"))
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Staging-Eintrag nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
func (a *app) handleStagingBulk(w http.ResponseWriter, r *http.Request) {
if a.staging == nil {
writeError(w, http.StatusNotFound, "Staging ist auf dieser Instanz nicht konfiguriert")
return
}
if !mustJSONContentType(w, r) {
return
}
var req stagingBulkRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage: "+err.Error())
return
}
if len(req.Keys) == 0 {
writeError(w, http.StatusBadRequest, "Keine Staging-Dateien ausgewählt")
return
}
if len(req.Keys) > 500 {
writeError(w, http.StatusBadRequest, "Maximal 500 Staging-Dateien pro Vorgang")
return
}
action := strings.ToLower(strings.TrimSpace(req.Action))
if action != "promote" && action != "delete" {
writeError(w, http.StatusBadRequest, "action muss promote oder delete sein")
return
}
type itemResult struct {
Key string `json:"key"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
items := make([]itemResult, 0, len(req.Keys))
succeeded := 0
for _, key := range req.Keys {
key = strings.TrimSpace(key)
var err error
if action == "promote" {
_, err = a.promoteStaging(key)
} else {
_, err = a.staging.Delete(key)
}
item := itemResult{Key: key, OK: err == nil}
if err != nil {
item.Error = err.Error()
} else {
succeeded++
}
items = append(items, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"action": action, "targeted": len(req.Keys), "succeeded": succeeded,
"failed": len(req.Keys) - succeeded, "items": items,
})
}
func (a *app) promoteStaging(key string) (map[string]any, error) {
staged, err := a.staging.Get(key)
if err != nil {
return nil, err
}
summary, err := a.store.ImportDocument(staged.Document, key)
if err != nil {
return nil, err
}
archive, err := a.staging.ArchiveApproved(key)
if err != nil {
return nil, fmt.Errorf("Produktivdatei wurde erstellt (%s), aber Staging konnte nicht als freigegeben archiviert werden: %w", summary.RelPath, err)
}
return map[string]any{"ok": true, "production": summary, "staging_key": key, "staging_archive": archive}, nil
}
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")
}

View File

@@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"net/http"
"net/http/httptest"
@@ -165,7 +166,7 @@ func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T)
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withAI(ai).routes()
h := newApp(s, web, appConfig{Mode: "google", Title: "Helpdesk", Writable: false, AIFallbackEnabled: true}).withStaging(st).withAI(ai).routes()
// Existing results must block the AI path before Ollama is called.
req := httptest.NewRequest(http.MethodPost, "/api/ai/fallback", bytes.NewBufferString(`{"query":"Bekannter Fehler"}`))
@@ -202,3 +203,133 @@ func TestAIFallbackOnlyRunsForZeroResultsAndReturnsStagingArticle(t *testing.T)
t.Fatalf("staging get status=%d body=%s", getRR.Code, getRR.Body.String())
}
}
func TestEditorCanReviewPromoteAndDeleteStaging(t *testing.T) {
knowledge := t.TempDir()
stagingDir := t.TempDir()
t.Setenv("BACKUP_DIR", filepath.Join(t.TempDir(), "backups"))
s, err := store.New(knowledge)
if err != nil {
t.Fatal(err)
}
st, err := staging.New(stagingDir)
if err != nil {
t.Fatal(err)
}
first, err := st.Save("unbekannt 0xAABBCCDD", "test-model", staging.Draft{
Title: "Zu prüfender Entwurf", Text: "Symptom", Answer: "Lösung", Keywords: []string{"0xAABBCCDD"},
}, false, 0.78)
if err != nil {
t.Fatal(err)
}
second, err := st.Save("anderer Entwurf", "test-model", staging.Draft{
Title: "Zu löschender Entwurf", Text: "Symptom", Answer: "Lösung",
}, false, 0.78)
if err != nil {
t.Fatal(err)
}
web, err := fs.Sub(webFS, "web")
if err != nil {
t.Fatal(err)
}
h := newApp(s, web, appConfig{Mode: "editor", Title: "Editor", Writable: true}).withStaging(st).routes()
listReq := httptest.NewRequest(http.MethodGet, "/api/staging?q=AABBCCDD&page=1&page_size=20", nil)
listRR := httptest.NewRecorder()
h.ServeHTTP(listRR, listReq)
if listRR.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", listRR.Code, listRR.Body.String())
}
var list staging.ListResult
if err := json.Unmarshal(listRR.Body.Bytes(), &list); err != nil {
t.Fatal(err)
}
if list.Total != 1 || list.Items[0].Key != first.Key {
t.Fatalf("unexpected staging list: %+v", list)
}
updated := first.Document
updated["title"] = "Geprüfter Entwurf"
updated["auto_reply"] = true
body, _ := json.Marshal(updated)
putReq := httptest.NewRequest(http.MethodPut, "/api/staging/"+first.Key, bytes.NewReader(body))
putReq.Header.Set("Content-Type", "application/json")
putRR := httptest.NewRecorder()
h.ServeHTTP(putRR, putReq)
if putRR.Code != http.StatusOK {
t.Fatalf("put status=%d body=%s", putRR.Code, putRR.Body.String())
}
promoteReq := httptest.NewRequest(http.MethodPost, "/api/staging/"+first.Key+"/promote", bytes.NewBufferString(`{}`))
promoteReq.Header.Set("Content-Type", "application/json")
promoteRR := httptest.NewRecorder()
h.ServeHTTP(promoteRR, promoteReq)
if promoteRR.Code != http.StatusCreated {
t.Fatalf("promote status=%d body=%s", promoteRR.Code, promoteRR.Body.String())
}
if s.Count() != 1 || st.Count() != 1 {
t.Fatalf("counts after promote: production=%d staging=%d", s.Count(), st.Count())
}
prod := s.List(store.Query{Page: 1, PageSize: 10})
if prod.Items[0].Title != "Geprüfter Entwurf" || prod.Items[0].AutoReply == nil || !*prod.Items[0].AutoReply {
t.Fatalf("promoted item not preserved: %+v", prod.Items[0])
}
if _, err := st.Get(first.Key); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("promoted staging file should be gone, err=%v", err)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/staging/"+second.Key, nil)
deleteRR := httptest.NewRecorder()
h.ServeHTTP(deleteRR, deleteReq)
if deleteRR.Code != http.StatusOK {
t.Fatalf("delete status=%d body=%s", deleteRR.Code, deleteRR.Body.String())
}
if st.Count() != 0 {
t.Fatalf("staging should be empty, count=%d", st.Count())
}
approved, err := filepath.Glob(filepath.Join(stagingDir, ".approved", "*.json"))
if err != nil || len(approved) != 1 {
t.Fatalf("expected one promoted draft in .approved, files=%v err=%v", approved, err)
}
trash, err := filepath.Glob(filepath.Join(stagingDir, ".trash", "*.json"))
if err != nil || len(trash) != 1 {
t.Fatalf("expected one deleted draft in .trash, files=%v err=%v", trash, err)
}
}
func TestEditorBulkStagingPromote(t *testing.T) {
knowledge := t.TempDir()
s, err := store.New(knowledge)
if err != nil {
t.Fatal(err)
}
st, err := staging.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
a, err := st.Save("a", "model", staging.Draft{Title: "A", Answer: "Lösung A"}, false, .78)
if err != nil {
t.Fatal(err)
}
b, err := st.Save("b", "model", staging.Draft{Title: "B", Answer: "Lösung B"}, false, .78)
if err != nil {
t.Fatal(err)
}
web, _ := fs.Sub(webFS, "web")
h := newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st).routes()
payload, _ := json.Marshal(map[string]any{"keys": []string{a.Key, b.Key}, "action": "promote"})
req := httptest.NewRequest(http.MethodPost, "/api/staging/bulk", bytes.NewReader(payload))
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 struct{ Succeeded, Failed int }
if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Succeeded != 2 || result.Failed != 0 || s.Count() != 2 || st.Count() != 0 {
t.Fatalf("unexpected bulk result=%+v prod=%d staging=%d", result, s.Count(), st.Count())
}
}

View File

@@ -39,7 +39,11 @@ func main() {
log.Fatalf("initialize store: %v", err)
}
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, s.DataDir())
stagingStore, err := stagingStoreFromEnv(s.DataDir())
if err != nil {
log.Fatal(err)
}
aiService, aiTimeout, err := aiServiceFromEnv(cfg.Mode, stagingStore)
if err != nil {
log.Fatal(err)
}
@@ -62,7 +66,7 @@ func main() {
log.Fatal(err)
}
app := newApp(s, sub, cfg).withAI(aiService)
app := newApp(s, sub, cfg).withStaging(stagingStore).withAI(aiService)
handler := requestLogger(optionalBasicAuth(app.routes()))
writeTimeout := 60 * time.Second
@@ -81,6 +85,7 @@ func main() {
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())
log.Printf("Staging directory: %s (%d JSON files)", stagingStore.Dir(), stagingStore.Count())
if reloadInterval > 0 {
log.Printf("Automatic index reload: %s", reloadInterval)
}
@@ -148,7 +153,26 @@ func startAutoReload(s *store.Store, interval time.Duration) {
}
}
func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration, error) {
func stagingStoreFromEnv(dataDir string) (*staging.Store, error) {
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
if stagingDir == "" {
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
}
stagingAbs, err := filepath.Abs(stagingDir)
if err != nil {
return nil, err
}
dataAbs, err := filepath.Abs(dataDir)
if err != nil {
return nil, err
}
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
return nil, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
}
return staging.New(stagingAbs)
}
func aiServiceFromEnv(mode string, st *staging.Store) (*aifallback.Service, time.Duration, error) {
enabled, err := envBool("AI_FALLBACK_ENABLED", false)
if err != nil {
return nil, 0, err
@@ -176,26 +200,8 @@ func aiServiceFromEnv(mode, dataDir string) (*aifallback.Service, time.Duration,
if err != nil || minScore < 0 || minScore > 1 {
return nil, 0, fmt.Errorf("OLLAMA_STAGING_MIN_SCORE must be between 0 and 1")
}
stagingDir := strings.TrimSpace(os.Getenv("STAGING_DIR"))
if stagingDir == "" {
stagingDir = filepath.Join(filepath.Dir(dataDir), "staging")
}
stagingAbs, err := filepath.Abs(stagingDir)
if err != nil {
return nil, 0, err
}
dataAbs, err := filepath.Abs(dataDir)
if err != nil {
return nil, 0, err
}
if pathContains(dataAbs, stagingAbs) || pathContains(stagingAbs, dataAbs) {
return nil, 0, fmt.Errorf("STAGING_DIR (%s) must be separate from DATA_DIR (%s)", stagingAbs, dataAbs)
}
st, err := staging.New(stagingAbs)
if err != nil {
return nil, 0, err
if st == nil {
return nil, 0, fmt.Errorf("staging store is required for AI fallback")
}
svc, err := aifallback.New(aifallback.Config{
BaseURL: envOr("OLLAMA_BASE_URL", "http://ollama:11434"),

View File

@@ -5,6 +5,7 @@
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const state = {
scope: 'production',
page: 1,
pageSize: 60,
total: 0,
@@ -21,15 +22,19 @@
const els = {
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
scopeProduction: $('#scopeProduction'), scopeStaging: $('#scopeStaging'), stagingCountBadge: $('#stagingCountBadge'),
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'),
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), stagingBadge: $('#stagingBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
deleteStagingBtn: $('#deleteStagingBtn'), promoteStagingBtn: $('#promoteStagingBtn'),
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
applyBulkBtn: $('#applyBulkBtn'), stagingBulkDialog: $('#stagingBulkDialog'), stagingBulkTargetText: $('#stagingBulkTargetText'),
stagingBulkResult: $('#stagingBulkResult'), bulkDeleteStagingBtn: $('#bulkDeleteStagingBtn'), bulkPromoteStagingBtn: $('#bulkPromoteStagingBtn'),
toastHost: $('#toastHost')
};
let searchTimer;
@@ -73,9 +78,11 @@
document.title = config.title;
}
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
const stagingCount = Number(h.staging_count || 0);
els.stagingCountBadge.textContent = stagingCount.toLocaleString('de-DE');
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} produktiv · ${stagingCount.toLocaleString('de-DE')} Staging`;
els.healthPill.className = 'pill ok';
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
els.healthPill.title = `Daten: ${h.data_dir}\nStaging: ${h.staging_dir || ''}\nBackups: ${h.backup_dir || ''}`;
} catch (err) {
els.healthPill.textContent = 'Offline';
els.healthPill.className = 'pill';
@@ -87,7 +94,8 @@
if (resetPage) state.page = 1;
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
try {
const data = await api(`/api/items?${queryString(currentQuery())}`);
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
const data = await api(`${endpoint}?${queryString(currentQuery())}`);
state.page = data.page || 1;
state.total = data.total;
state.totalPages = data.total_pages;
@@ -105,14 +113,14 @@
}
for (const item of state.items) {
const row = document.createElement('div');
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}${state.scope === 'staging' ? ' staging-item' : ''}`;
row.dataset.key = item.key;
const checked = state.selected.has(item.key) ? 'checked' : '';
const auto = item.auto_reply === true;
row.innerHTML = `
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
<div>
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
<div class="result-id">${state.scope === 'staging' ? '<span class="mini-staging">STAGING</span> ' : ''}${escapeHTML(item.id || item.rel_path)}</div>
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
<div class="result-meta">
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '')}</span>
@@ -145,7 +153,8 @@
function renderSelectionOnly() {
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
els.bulkBtn.disabled = state.scope === 'staging' ? state.selected.size === 0 : (state.selected.size === 0 && state.total === 0);
els.bulkBtn.textContent = state.scope === 'staging' ? '✦ Staging-Aktionen' : '✦ Massenbearbeitung';
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));
}
@@ -156,7 +165,8 @@
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)}`);
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
const data = await api(`${endpoint}/${encodeURIComponent(key)}`);
state.currentKey = key;
state.currentDoc = data.document;
state.currentMeta = data.meta;
@@ -174,6 +184,10 @@
els.emptyState.classList.add('hidden');
els.editor.classList.remove('hidden');
els.filePath.textContent = state.currentMeta?.rel_path || '';
const isStaging = state.scope === 'staging';
els.stagingBadge.classList.toggle('hidden', !isStaging);
els.promoteStagingBtn.classList.toggle('hidden', !isStaging);
els.deleteStagingBtn.classList.toggle('hidden', !isStaging);
setDirty(false);
}
@@ -248,28 +262,41 @@
}
async function saveCurrent() {
if (!state.currentKey || !state.currentDoc) return;
if (!state.currentKey || !state.currentDoc) return false;
if (state.activeTab === 'raw') {
if (!syncRawToDoc()) return;
if (!syncRawToDoc()) return false;
} else syncFormToDoc();
els.saveBtn.disabled = true;
try {
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
const result = await api(`${endpoint}/${encodeURIComponent(state.currentKey)}`, {
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
});
state.currentMeta = result.meta;
if (result.document) state.currentDoc = result.document;
setDirty(false);
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
if (state.scope === 'staging') toast('Staging-Entwurf gespeichert.', 'success');
else toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
await loadList(false);
return true;
} catch (err) {
toast(err.message, 'error');
return false;
} finally {
els.saveBtn.disabled = false;
}
}
function openBulk() {
if (state.scope === 'staging') {
if (state.selected.size === 0) return;
els.stagingBulkTargetText.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählte Staging-Entwürfe`;
els.stagingBulkResult.className = 'preview-box hidden';
els.stagingBulkResult.textContent = '';
els.stagingBulkDialog.showModal();
return;
}
if (state.selected.size === 0 && state.total === 0) return;
resetBulkPreview();
els.bulkAllMatching.checked = state.selected.size === 0;
@@ -383,6 +410,96 @@
}
}
async function promoteCurrentStaging() {
if (state.scope !== 'staging' || !state.currentKey) return;
if (state.dirty) {
if (!confirm('Der Entwurf enthält ungespeicherte Änderungen. Vor der Freigabe speichern?')) return;
if (!await saveCurrent()) return;
}
if (!confirm('Diesen Staging-Entwurf jetzt unverändert in die produktive Wissensbasis freigeben?')) return;
els.promoteStagingBtn.disabled = true;
try {
const result = await api(`/api/staging/${encodeURIComponent(state.currentKey)}/promote`, {
method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}'
});
const productionKey = result.production?.key;
toast(`Freigegeben: ${result.production?.rel_path || result.production?.id || 'Produktivartikel'}`, 'success');
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
await loadHealth();
await setScope('production');
if (productionKey) await openItem(productionKey);
} catch (err) {
toast(err.message, 'error');
} finally {
els.promoteStagingBtn.disabled = false;
}
}
async function deleteCurrentStaging() {
if (state.scope !== 'staging' || !state.currentKey) return;
if (!confirm('Diesen Staging-Entwurf löschen? Er wird zur Sicherheit nach staging/.trash verschoben.')) return;
els.deleteStagingBtn.disabled = true;
try {
await api(`/api/staging/${encodeURIComponent(state.currentKey)}`, {method: 'DELETE'});
toast('Staging-Entwurf gelöscht und in .trash archiviert.', 'success');
state.selected.delete(state.currentKey);
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');
} finally {
els.deleteStagingBtn.disabled = false;
}
}
async function stagingBulkAction(action) {
if (state.scope !== 'staging' || state.selected.size === 0) return;
const verb = action === 'promote' ? 'freigeben' : 'löschen';
if (!confirm(`${state.selected.size.toLocaleString('de-DE')} Staging-Entwürfe wirklich ${verb}?`)) return;
els.bulkPromoteStagingBtn.disabled = true;
els.bulkDeleteStagingBtn.disabled = true;
try {
const result = await api('/api/staging/bulk', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({keys: Array.from(state.selected), action})
});
els.stagingBulkResult.className = `preview-box ${result.failed ? 'warn' : 'ok'}`;
els.stagingBulkResult.innerHTML = `<strong>${result.succeeded.toLocaleString('de-DE')} erfolgreich</strong> · ${result.failed.toLocaleString('de-DE')} fehlgeschlagen` +
(result.failed ? `<div class="preview-samples">${result.items.filter(x => !x.ok).slice(0,20).map(x => `<code>${escapeHTML(x.key)} · ${escapeHTML(x.error)}</code>`).join('')}</div>` : '');
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()]);
if (!result.failed) setTimeout(() => els.stagingBulkDialog.close(), 650);
} catch (err) {
els.stagingBulkResult.className = 'preview-box warn';
els.stagingBulkResult.textContent = err.message;
} finally {
els.bulkPromoteStagingBtn.disabled = false;
els.bulkDeleteStagingBtn.disabled = false;
}
}
async function setScope(scope) {
if (scope !== 'production' && scope !== 'staging') return;
if (scope === state.scope) return;
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Bereich wechseln?')) return;
state.scope = scope;
state.page = 1;
state.selected.clear();
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
els.scopeProduction.classList.toggle('active', scope === 'production');
els.scopeStaging.classList.toggle('active', scope === 'staging');
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
els.emptyState.querySelector('h1').textContent = scope === 'staging' ? 'Staging-Entwürfe prüfen' : 'JSON-Wissensbasis bearbeiten';
els.emptyState.querySelector('p').textContent = scope === 'staging'
? 'KI-generierte Entwürfe prüfen, bearbeiten und anschließend gezielt freigeben oder verwerfen.'
: 'Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.';
renderSelectionOnly();
await loadList(true);
}
function resetBulkPreview() {
state.lastBulkPreviewSignature = '';
els.bulkPreview.className = 'preview-box hidden';
@@ -408,6 +525,13 @@
searchTimer = setTimeout(() => loadList(true), 240);
}
els.scopeProduction.addEventListener('click', () => setScope('production'));
els.scopeStaging.addEventListener('click', () => setScope('staging'));
els.promoteStagingBtn.addEventListener('click', promoteCurrentStaging);
els.deleteStagingBtn.addEventListener('click', deleteCurrentStaging);
els.bulkPromoteStagingBtn.addEventListener('click', () => stagingBulkAction('promote'));
els.bulkDeleteStagingBtn.addEventListener('click', () => stagingBulkAction('delete'));
// Filters and navigation.
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
els.autoReplyFilter.addEventListener('change', () => loadList(true));

View File

@@ -25,6 +25,10 @@
<aside class="sidebar">
<section class="filters">
<div class="scope-switch" role="group" aria-label="Datenbereich">
<button id="scopeProduction" type="button" class="scope-btn active" data-scope="production">Produktiv</button>
<button id="scopeStaging" type="button" class="scope-btn" data-scope="staging">Staging <span id="stagingCountBadge">0</span></button>
</div>
<label class="search-wrap">
<span></span>
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
@@ -77,9 +81,12 @@
<div class="editor-head">
<div class="breadcrumb">
<span id="filePath"></span>
<span id="stagingBadge" class="badge staging hidden">AI-STAGING · UNGEPRÜFT</span>
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
</div>
<div class="editor-actions">
<button id="deleteStagingBtn" class="btn danger hidden">Löschen</button>
<button id="promoteStagingBtn" class="btn primary hidden">✓ Freigeben → Produktiv</button>
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
</div>
@@ -245,6 +252,34 @@
</form>
</dialog>
<dialog id="stagingBulkDialog" class="modal staging-modal">
<form method="dialog" class="modal-card">
<header class="modal-head">
<div>
<h2>Staging-Aktionen</h2>
<p id="stagingBulkTargetText"></p>
</div>
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
</header>
<div class="modal-body">
<div class="staging-info-card">
<strong>Review-Workflow</strong>
<p>Freigeben kopiert den aktuellen JSON-Inhalt unverändert in die produktive Wissensbasis und archiviert den geprüften Originalentwurf anschließend unter <code>staging/.approved</code>. <code>auto_reply</code> bleibt genau wie im Entwurf gesetzt.</p>
</div>
<div id="stagingBulkResult" class="preview-box hidden"></div>
</div>
<footer class="modal-foot">
<span class="muted-text">Löschen verschiebt Entwürfe sicher nach <code>staging/.trash</code>.</span>
<div>
<button value="cancel" class="btn ghost">Abbrechen</button>
<button id="bulkDeleteStagingBtn" type="button" class="btn danger">Auswahl löschen</button>
<button id="bulkPromoteStagingBtn" type="button" class="btn primary">Auswahl freigeben</button>
</div>
</footer>
</form>
</dialog>
<div id="toastHost" class="toast-host" aria-live="polite"></div>
<script src="/app.js" defer></script>
</body>

View File

@@ -197,3 +197,62 @@ textarea { resize: vertical; line-height: 1.45; }
.bulk-grid { grid-template-columns: 1fr; }
.top-actions .pill { display: none; }
}
/* Review workflow: production vs. AI staging */
.scope-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
padding: 4px;
margin-bottom: 10px;
border: 1px solid var(--border);
border-radius: 11px;
background: #0b1323;
}
.scope-btn {
border: 0;
border-radius: 8px;
padding: 8px 10px;
color: var(--muted);
background: transparent;
font-size: 11px;
font-weight: 700;
transition: .16s ease;
}
.scope-btn:hover { color: var(--text); background: rgba(255,255,255,.035); }
.scope-btn.active { color: var(--text); background: var(--panel-3); box-shadow: inset 0 0 0 1px rgba(122,162,255,.22); }
.scope-btn span {
display: inline-grid;
min-width: 20px;
place-items: center;
margin-left: 5px;
padding: 1px 5px;
border-radius: 999px;
font-size: 9px;
color: #f7d58f;
background: rgba(242,190,97,.12);
border: 1px solid rgba(242,190,97,.22);
}
.result-item.staging-item { background-image: linear-gradient(90deg, rgba(242,190,97,.035), transparent 45%); }
.result-item.staging-item.active { background: rgba(242,190,97,.075); box-shadow: inset 3px 0 0 var(--warning); }
.mini-staging {
display: inline-block;
padding: 1px 5px;
border-radius: 5px;
color: #f6d99e;
background: rgba(242,190,97,.10);
border: 1px solid rgba(242,190,97,.2);
font: 800 8px/1.4 ui-sans-serif, system-ui, sans-serif;
letter-spacing: .07em;
}
.badge.staging { color: #f6d99e; border-color: rgba(242,190,97,.28); background: rgba(242,190,97,.07); }
.staging-modal { width: min(720px, calc(100vw - 40px)); }
.staging-info-card {
border: 1px solid rgba(242,190,97,.22);
border-radius: 12px;
padding: 15px;
background: rgba(242,190,97,.055);
}
.staging-info-card strong { display: block; margin-bottom: 6px; color: #f7dca8; }
.staging-info-card p { margin: 0; color: #c7cede; font-size: 12px; line-height: 1.6; }
.staging-info-card code, .modal-foot code { color: #f6d99e; }