From 9f60b6138cb9753cdbec62b63c6a6fb6dd04df02 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 4 Oct 2026 08:08:44 +0200 Subject: [PATCH] fix(14): WR-05 import every key of an object-shaped models repeater A repeater saved with non-sequential keys was read only at keys 0 to len-1, so a model at a higher key was dropped while the import reported success and refused to run again. Every key is now read, numeric keys in ascending order. --- console/import_settings.go | 33 ++++++++++++++++++++++++++++----- golem_admin_test.go | 16 ++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/console/import_settings.go b/console/import_settings.go index acb02ec..9d389cf 100644 --- a/console/import_settings.go +++ b/console/import_settings.go @@ -2,12 +2,15 @@ package console import ( + "cmp" "context" "encoding/json" "errors" "fmt" + "maps" "math" "os" + "slices" "strconv" "strings" @@ -150,11 +153,11 @@ func repeaterItems(raw []byte) ([]map[string]any, error) { case []any: list = t case map[string]any: - // A repeater saved with non-sequential keys decodes as an object. - for i := 0; i < len(t); i++ { - if item, ok := t[strconv.Itoa(i)]; ok { - list = append(list, item) - } + // A repeater saved with non-sequential keys decodes as an object + // ({"0":…,"2":…} after a middle item was deleted). Every key is + // read, numeric keys in ascending order, then any other key. + for _, k := range repeaterKeys(t) { + list = append(list, t[k]) } case nil: default: @@ -169,6 +172,26 @@ func repeaterItems(raw []byte) ([]map[string]any, error) { return out, nil } +// repeaterKeys orders an object repeater's keys: integers ascending by +// value first, then the other keys in byte order. +func repeaterKeys(t map[string]any) []string { + keys := slices.Collect(maps.Keys(t)) + slices.SortFunc(keys, func(a, b string) int { + ai, aerr := strconv.Atoi(a) + bi, berr := strconv.Atoi(b) + switch { + case aerr == nil && berr == nil: + return cmp.Compare(ai, bi) + case aerr == nil: + return -1 + case berr == nil: + return 1 + } + return strings.Compare(a, b) + }) + return keys +} + func modelFromItem(item map[string]any) models.AiModel { row := models.AiModel{ Name: str(item["name"]), diff --git a/golem_admin_test.go b/golem_admin_test.go index 808d921..7844466 100644 --- a/golem_admin_test.go +++ b/golem_admin_test.go @@ -151,6 +151,22 @@ func TestGolemImportCommand(t *testing.T) { if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { t.Fatal(err) } + // A repeater whose middle item was deleted keeps every remaining key, + // in numeric order (WR-05). + sparse := write("sparse.json", `{"models":{"10":{"name":"Ten","model":"m10","api_key":"k10"},"0":{"name":"Zero","model":"m0","api_key":"k0"},"2":{"name":"Two","model":"m2","api_key":"k2"}}}`) + if out, err := run(app, sparse); err != nil || !strings.Contains(out, "Imported 3 AI models.") { + t.Fatalf("sparse import = %q %v", out, err) + } + rows = nil + if err := db.Order("sort_order").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0].Name != "Zero" || rows[1].Name != "Two" || rows[2].Name != "Ten" { + t.Fatalf("sparse rows = %+v", rows) + } + if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil { + t.Fatal(err) + } for name, body := range map[string]string{"empty.json": `{"models":[]}`, "null.json": `{"models":null}`} { if out, err := run(app, write(name, body)); err != nil || !strings.Contains(out, "no AI models") { t.Errorf("%s = %q %v", name, out, err)