From 7f8506beedb90d04acb9676bb1e2d9536901233f Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 10 Apr 2026 19:11:08 +0200 Subject: [PATCH] Conversion Key Inference: Config-Driven --- internal/topdata/convert.go | 213 ++++++++++++++++++++----------- internal/topdata/convert_test.go | 58 +++++++++ 2 files changed, 193 insertions(+), 78 deletions(-) diff --git a/internal/topdata/convert.go b/internal/topdata/convert.go index 502a20c..bb2e01d 100644 --- a/internal/topdata/convert.go +++ b/internal/topdata/convert.go @@ -181,10 +181,21 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode) } if allowFormat { + output := "" + if len(positional) == 2 { + output = positional[1] + } ctx, err := resolveConvertContext(positional[0]) if err != nil { return opts, "", "", err } + if ctx.Project != nil { + if outputCtx, err := resolveConvertOutputContext(ctx.Project, output); err != nil { + return opts, "", "", err + } else if ctx.Template.Namespace == "" && len(ctx.Template.KeyFields) == 0 { + ctx.Template = outputCtx + } + } if strings.TrimSpace(opts.Namespace) == "" { opts.Namespace = ctx.Template.Namespace } @@ -203,10 +214,7 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, if opts.Type != "entries" && opts.Type != "override" { return opts, "", "", fmt.Errorf("unsupported module type %q", opts.Type) } - output := "" - if len(positional) == 2 { - output = positional[1] - } else { + if output == "" { resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project) if err != nil { return opts, "", "", err @@ -219,6 +227,13 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, if err != nil { return opts, "", "", err } + if ctx.Project != nil { + if outputCtx, err := resolveConvertOutputContext(ctx.Project, positional[1]); err != nil { + return opts, "", "", err + } else if ctx.Template.Namespace == "" && len(ctx.Template.KeyFields) == 0 { + ctx.Template = outputCtx + } + } if strings.TrimSpace(opts.Namespace) == "" { opts.Namespace = ctx.Template.Namespace } @@ -391,87 +406,59 @@ func toOverridesRows(rows []map[string]any) []map[string]any { } func assignConvertedRowKeys(rows []map[string]any, outputPath string, opts convertOptions) ([]map[string]any, error) { - namespace, profiles := inferConvertNamespace(outputPath, opts.Namespace) + namespace := strings.TrimSpace(opts.Namespace) out := make([]map[string]any, 0, len(rows)) used := map[string]struct{}{} + rowIDsByKey := map[string]int{} for _, row := range rows { cloned := cloneRowMap(row) if namespace == "" { out = append(out, cloned) continue } - key := convertedRowKey(cloned, namespace, profiles, opts.KeyFields) - if key == "" { + candidates := convertedRowKeyCandidates(cloned, opts.KeyFields) + if len(candidates) == 0 { out = append(out, cloned) continue } - if _, ok := used[key]; ok { + key := "" + for _, candidate := range candidates { + candidateKey := namespace + ":" + candidate + if _, ok := used[candidateKey]; ok { + continue + } + key = candidateKey + break + } + if key == "" { + key = namespace + ":" + candidates[0] if opts.CollisionMode == "error" { - return nil, fmt.Errorf("duplicate generated key %q", key) + return nil, fmt.Errorf("duplicate generated key %q for row %v (already used by row %d)", key, cloned["id"], rowIDsByKey[key]) } key = fmt.Sprintf("%s_%v", key, cloned["id"]) } used[key] = struct{}{} + rowID, _ := asInt(cloned["id"]) + rowIDsByKey[key] = rowID cloned["key"] = key out = append(out, cloned) } return out, nil } -type convertKeyProfile struct { - Field string - Mode string -} - -func inferConvertNamespace(outputPath, override string) (string, []convertKeyProfile) { - if strings.TrimSpace(override) != "" { - return strings.TrimSpace(override), nil - } - normalized := filepath.ToSlash(outputPath) - prefixes := []struct { - Prefix string - Namespace string - Profiles []convertKeyProfile - }{ - {"data/itemprops/costtables/index/", "itemprops:costtable_index", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/itemprops/paramtables/index/", "itemprops:paramtable_index", []convertKeyProfile{{"Name", "literal"}, {"Lable", "literal"}, {"TableResRef", "literal"}}}, - {"data/itemprops/defs/", "itempropdef", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/itemprops/sets/", "itemprops", []convertKeyProfile{{"StringRef", "literal"}, {"Label", "literal"}}}, - {"data/appearance/", "appearance", []convertKeyProfile{{"STRING_REF", "literal"}, {"LABEL", "literal"}}}, - {"data/baseitems/", "baseitems", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/feat/", "feat", []convertKeyProfile{{"FEAT", "literal"}, {"LABEL", "literal"}, {"Label", "literal"}}}, - {"data/spells/", "spells", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/skills/", "skills", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/portraits/", "portraits", []convertKeyProfile{{"BaseResRef", "literal"}, {"Label", "literal"}}}, - {"data/racialtypes/", "racialtypes", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - {"data/damagetypes/", "damagetypes", []convertKeyProfile{{"Name", "literal"}, {"Label", "literal"}}}, - } - for _, prefix := range prefixes { - if strings.Contains(normalized, prefix.Prefix) { - return prefix.Namespace, prefix.Profiles - } - } - return "", nil -} - var convertKeyWhitespace = regexp.MustCompile(`\s+`) var convertKeyCleaner = regexp.MustCompile(`[^a-z0-9_]`) -func convertedRowKey(row map[string]any, namespace string, profiles []convertKeyProfile, preferred []string) string { - candidates := make([]convertKeyProfile, 0, len(preferred)+len(profiles)) - for _, field := range preferred { - candidates = append(candidates, convertKeyProfile{Field: field, Mode: "literal"}) +func convertedRowKeyCandidates(row map[string]any, preferred []string) []string { + fields := preferred + if len(fields) == 0 { + fields = []string{"LABEL", "Label", "Name"} } - candidates = append(candidates, profiles...) - if len(candidates) == 0 { - candidates = append(candidates, - convertKeyProfile{Field: "LABEL", Mode: "literal"}, - convertKeyProfile{Field: "Label", Mode: "literal"}, - convertKeyProfile{Field: "Name", Mode: "literal"}, - ) - } - for _, candidate := range candidates { - value, ok := row[candidate.Field] + values := make([]string, 0, len(fields)) + seen := map[string]struct{}{} + candidates := make([]string, 0, len(fields)) + for _, field := range fields { + value, ok := row[field] if !ok { continue } @@ -483,9 +470,15 @@ func convertedRowKey(row map[string]any, namespace string, profiles []convertKey if normalized == "" { continue } - return namespace + ":" + normalized + values = append(values, normalized) + joined := strings.Join(values, "_") + if _, ok := seen[joined]; ok { + continue + } + seen[joined] = struct{}{} + candidates = append(candidates, joined) } - return "" + return candidates } func normalizeConvertedKeyText(text string) string { @@ -537,6 +530,13 @@ func resolveConvertContext(input string) (convertContext, error) { } func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateTable, error) { + config, err := loadConvertTemplateConfig(p) + if err != nil { + return convertTemplateTable{}, err + } + if len(config.Tables) == 0 { + return convertTemplateTable{}, nil + } templatesDir := filepath.Join(p.TopDataSourceDir(), "templates") inputAbs, err := filepath.Abs(input) if err != nil { @@ -551,23 +551,6 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT return convertTemplateTable{}, nil } - configPath := filepath.Join(templatesDir, "config.json") - raw, err := os.ReadFile(configPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return convertTemplateTable{}, nil - } - return convertTemplateTable{}, fmt.Errorf("read %s: %w", configPath, err) - } - - var config convertTemplateConfig - if err := json.Unmarshal(raw, &config); err != nil { - return convertTemplateTable{}, fmt.Errorf("parse %s: %w", configPath, err) - } - if len(config.Tables) == 0 { - return convertTemplateTable{}, nil - } - rel = filepath.ToSlash(rel) base := filepath.Base(rel) stem := strings.TrimSuffix(base, filepath.Ext(base)) @@ -579,6 +562,80 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT return convertTemplateTable{}, nil } +func loadConvertTemplateConfig(p *project.Project) (convertTemplateConfig, error) { + configPath := filepath.Join(p.TopDataSourceDir(), "templates", "config.json") + raw, err := os.ReadFile(configPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return convertTemplateConfig{}, nil + } + return convertTemplateConfig{}, fmt.Errorf("read %s: %w", configPath, err) + } + + var config convertTemplateConfig + if err := json.Unmarshal(raw, &config); err != nil { + return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err) + } + return config, nil +} + +func resolveConvertOutputContext(p *project.Project, output string) (convertTemplateTable, error) { + if p == nil || !p.HasTopData() || strings.TrimSpace(output) == "" { + return convertTemplateTable{}, nil + } + config, err := loadConvertTemplateConfig(p) + if err != nil { + return convertTemplateTable{}, err + } + if len(config.Tables) == 0 { + return convertTemplateTable{}, nil + } + dataDir := filepath.Join(p.TopDataSourceDir(), "data") + dataAbs, err := filepath.Abs(dataDir) + if err != nil { + return convertTemplateTable{}, err + } + outputAbs, err := filepath.Abs(output) + if err != nil { + return convertTemplateTable{}, err + } + rel, err := filepath.Rel(dataAbs, outputAbs) + if err != nil || rel == "." || strings.HasPrefix(rel, "..") { + return convertTemplateTable{}, nil + } + rel = filepath.ToSlash(rel) + + bestMatch := "" + bestTable := convertTemplateTable{} + for _, table := range config.Tables { + namespacePath := namespaceToPath(table.Namespace) + if namespacePath == "" { + continue + } + if rel != namespacePath && !strings.HasPrefix(rel, namespacePath+"/") { + continue + } + if len(namespacePath) <= len(bestMatch) { + continue + } + bestMatch = namespacePath + bestTable = table + } + return bestTable, nil +} + +func namespaceToPath(namespace string) string { + namespace = strings.TrimSpace(strings.ToLower(namespace)) + if namespace == "" { + return "" + } + namespace = strings.ReplaceAll(namespace, "\\", "/") + namespace = strings.ReplaceAll(namespace, ":", "/") + namespace = convertKeyWhitespace.ReplaceAllString(namespace, "_") + namespace = strings.Trim(namespace, "/") + return namespace +} + func defaultModuleOutputPath(input string, opts convertOptions, p *project.Project) (string, error) { if p == nil { return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root") diff --git a/internal/topdata/convert_test.go b/internal/topdata/convert_test.go index d85a864..9ce0a17 100644 --- a/internal/topdata/convert_test.go +++ b/internal/topdata/convert_test.go @@ -119,6 +119,64 @@ func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) { } } +func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing.T) { + root := testProjectRoot(t) + writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ + "module": {"name": "Test", "resref": "test"}, + "paths": {"source": "src", "assets": "assets", "build": "build"}, + "topdata": {"source": "topdata", "build": "build/topdata"} +}`+"\n") + mkdirAll(t, filepath.Join(root, "topdata", "templates")) + mkdirAll(t, filepath.Join(root, "topdata", "data", "soundset")) + writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ + "tables": { + "template_soundset": { + "namespace": "soundset", + "key_fields": ["RESREF", "LABEL"] + } + } +}`+"\n") + inputPath := filepath.Join(root, "scratch_soundset.2da") + writeFile(t, inputPath, "2DA V2.0\n\nLABEL RESREF STRREF GENDER TYPE\n0 \"Bandit Male\" nw_bandit 100 1 4\n1 \"Bandit Female\" nw_bandit 101 2 4\n") + + oldCwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(oldCwd) + }) + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir: %v", err) + } + + outputPath := filepath.Join(root, "topdata", "data", "soundset", "base.json") + if err := RunConvertCommand([]string{ + "2da-to-json", + "scratch_soundset.2da", + outputPath, + }, &bytes.Buffer{}); err != nil { + t.Fatalf("RunConvertCommand failed: %v", err) + } + + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + var payload struct { + Rows []map[string]any `json:"rows"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatalf("unmarshal output: %v", err) + } + if got := payload.Rows[0]["key"]; got != "soundset:nw_bandit" { + t.Fatalf("expected first soundset key, got %#v", got) + } + if got := payload.Rows[1]["key"]; got != "soundset:nw_bandit_bandit_female" { + t.Fatalf("expected second soundset key to use config disambiguation, got %#v", got) + } +} + func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{