Freigabe-Anpassung und Soft-Trash
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
+104
View File
@@ -619,6 +619,110 @@ func (s *Store) Save(key string, doc map[string]any) (Summary, string, error) {
return summarize(newRec), backupBatch, nil
}
// ImportDocument creates a new production JSON file without overwriting an existing entry.
// It is used when a reviewed staging article is promoted into the productive knowledge base.
func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summary, error) {
if doc == nil {
return Summary{}, errors.New("JSON root must be an object")
}
id := strings.TrimSpace(str(doc["id"]))
if id == "" {
id = strings.TrimSpace(preferredBase)
doc = cloneMap(doc)
doc["id"] = id
}
base := safeFilenameBase(id)
if base == "" {
base = safeFilenameBase(preferredBase)
}
if base == "" {
return Summary{}, errors.New("cannot derive a safe production filename from document id")
}
s.mu.Lock()
defer s.mu.Unlock()
for _, rec := range s.records {
if strings.EqualFold(strings.TrimSpace(str(rec.Doc["id"])), id) {
return Summary{}, fmt.Errorf("knowledge entry with id %q already exists", id)
}
}
rel := base + ".json"
path := filepath.Join(s.dataDir, rel)
if _, err := os.Stat(path); err == nil {
return Summary{}, fmt.Errorf("production target already exists: %s", rel)
} else if !errors.Is(err, os.ErrNotExist) {
return Summary{}, err
}
payload, err := marshalDocument(doc)
if err != nil {
return Summary{}, err
}
tmp, err := os.CreateTemp(s.dataDir, ".kb-import-*.tmp")
if err != nil {
return Summary{}, err
}
tmpName := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}
if err := tmp.Chmod(0o644); err != nil {
cleanup()
return Summary{}, err
}
if _, err := tmp.Write(payload); err != nil {
cleanup()
return Summary{}, err
}
if err := tmp.Sync(); err != nil {
cleanup()
return Summary{}, err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpName)
return Summary{}, err
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return Summary{}, err
}
rec, err := s.readRecord(path)
if err != nil {
_ = os.Remove(path)
return Summary{}, err
}
s.records[rec.Key] = rec
s.order = append(s.order, rec.Key)
s.resortLocked()
return summarize(rec), nil
}
func safeFilenameBase(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var b strings.Builder
lastDash := false
for _, r := range value {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
if valid {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
out := strings.Trim(b.String(), ".-_ ")
if len(out) > 180 {
out = out[:180]
}
return out
}
func (s *Store) ApplyBulk(keys []string, patch BulkPatch, dryRun bool) (BulkResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
+25
View File
@@ -161,3 +161,28 @@ func TestSearchRanksExactIdentifiersAndBuildsExcerpt(t *testing.T) {
t.Fatalf("unexpected excerpt: %q", result.Items[0].Excerpt)
}
}
func TestImportDocumentCreatesNewFileAndRejectsDuplicateID(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
doc := map[string]any{
"id": "KB-AI-STAGING-TEST-001", "title": "Reviewed", "answer": "Lösung",
"auto_reply": false, "categories": []any{"AI-Staging"},
}
created, err := s.ImportDocument(doc, "fallback")
if err != nil {
t.Fatal(err)
}
if created.ID != "KB-AI-STAGING-TEST-001" || s.Count() != 1 {
t.Fatalf("unexpected created item: %+v count=%d", created, s.Count())
}
if _, err := os.Stat(filepath.Join(dir, "KB-AI-STAGING-TEST-001.json")); err != nil {
t.Fatalf("production file missing: %v", err)
}
if _, err := s.ImportDocument(doc, "fallback"); err == nil {
t.Fatal("expected duplicate ID to be rejected")
}
}