This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user