From d767ec0a25d9ee7b1365512e708cb97eb2f658b4 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 15 May 2026 08:05:42 +0200 Subject: [PATCH] Support for unique prefixes on 2da conversion --- internal/topdata/convert.go | 92 ++++++++++++++++++++++--- internal/topdata/convert_test.go | 113 +++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/internal/topdata/convert.go b/internal/topdata/convert.go index c3b15d2..ef47bad 100644 --- a/internal/topdata/convert.go +++ b/internal/topdata/convert.go @@ -17,12 +17,13 @@ import ( ) type convertOptions struct { - Namespace string - KeyFields []string - CollisionMode string - BaseDialog string - Type string - Name string + Namespace string + KeyFields []string + CollisionMode string + BaseDialog string + Type string + Name string + FilenamePrefixes map[string]string } func RunConvertCommand(args []string, stdout io.Writer) error { @@ -107,7 +108,8 @@ func printConvertUsage(stdout io.Writer) { _, _ = fmt.Fprintln(stdout, "") _, _ = fmt.Fprintln(stdout, "2da-to-module notes:") _, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.yaml declares it for the input table") - _, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: add__.json") + _, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: __.json") + _, _ = fmt.Fprintln(stdout, " - topdata/templates/config.yaml module_output.filename_prefixes can set entries/override prefixes") _, _ = fmt.Fprintln(stdout, " - flags accept both --flag value and --flag=value forms") _, _ = fmt.Fprintln(stdout, " - unkeyed rows now fail conversion instead of being skipped") } @@ -238,6 +240,9 @@ func parseConvertArgs(args []string, allowFormat bool, defaultCollision string) if len(opts.KeyFields) == 0 && len(ctx.Template.KeyFields) > 0 { opts.KeyFields = append(opts.KeyFields, ctx.Template.KeyFields...) } + if len(opts.FilenamePrefixes) == 0 && len(ctx.Config.ModuleOutput.FilenamePrefixes) > 0 { + opts.FilenamePrefixes = cloneStringMap(ctx.Config.ModuleOutput.FilenamePrefixes) + } if strings.TrimSpace(opts.Namespace) == "" { return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.yaml entry for the input table") } @@ -550,11 +555,17 @@ type convertTemplateTable struct { } type convertTemplateConfig struct { - Tables map[string]convertTemplateTable `json:"tables" yaml:"tables"` + ModuleOutput convertModuleOutputConfig `json:"module_output" yaml:"module_output"` + Tables map[string]convertTemplateTable `json:"tables" yaml:"tables"` +} + +type convertModuleOutputConfig struct { + FilenamePrefixes map[string]string `json:"filename_prefixes" yaml:"filename_prefixes"` } type convertContext struct { Project *project.Project + Config convertTemplateConfig Template convertTemplateTable } @@ -574,12 +585,17 @@ func resolveConvertContext(input string) (convertContext, error) { if !p.HasTopData() { return convertContext{Project: p}, nil } - table, err := loadTemplateTableConfig(p, input) + config, err := loadConvertTemplateConfig(p) + if err != nil { + return convertContext{}, err + } + table, err := templateTableConfig(config, p, input) if err != nil { return convertContext{}, err } return convertContext{ Project: p, + Config: config, Template: table, }, nil } @@ -589,6 +605,10 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT if err != nil { return convertTemplateTable{}, err } + return templateTableConfig(config, p, input) +} + +func templateTableConfig(config convertTemplateConfig, p *project.Project, input string) (convertTemplateTable, error) { if len(config.Tables) == 0 { return convertTemplateTable{}, nil } @@ -640,11 +660,28 @@ func loadConvertTemplateConfig(p *project.Project) (convertTemplateConfig, error return convertTemplateConfig{}, fmt.Errorf("parse %s: %w", configPath, err) } } + if err := validateConvertTemplateConfig(config); err != nil { + return convertTemplateConfig{}, fmt.Errorf("validate %s: %w", configPath, err) + } return config, nil } return convertTemplateConfig{}, nil } +func validateConvertTemplateConfig(config convertTemplateConfig) error { + for moduleType, prefix := range config.ModuleOutput.FilenamePrefixes { + switch moduleType { + case "entries", "override": + default: + return fmt.Errorf("unsupported module_output.filename_prefixes key %q; expected entries or override", moduleType) + } + if normalizeModuleFilenamePrefix(prefix) == "" { + return fmt.Errorf("module_output.filename_prefixes.%s must contain at least one filename-safe character", moduleType) + } + } + return nil +} + func resolveConvertOutputContext(p *project.Project, output string) (convertTemplateTable, error) { if p == nil || !p.HasTopData() || strings.TrimSpace(output) == "" { return convertTemplateTable{}, nil @@ -724,7 +761,8 @@ func defaultModuleFileName(input string, opts convertOptions, p *project.Project return "", fmt.Errorf("invalid --name %q", opts.Name) } namespace := normalizeModuleFilenameNamespace(opts.Namespace) - return fmt.Sprintf("add_%s_%s.json", namespace, name), nil + prefix := moduleFilenamePrefix(opts) + return fmt.Sprintf("%s_%s_%s.json", prefix, namespace, name), nil } if isTemplateInput(p, input) { return "", errors.New("2da-to-module requires --name when converting from topdata/templates without an explicit output path") @@ -732,6 +770,32 @@ func defaultModuleFileName(input string, opts convertOptions, p *project.Project return strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json", nil } +func moduleFilenamePrefix(opts convertOptions) string { + moduleType := strings.TrimSpace(opts.Type) + if moduleType == "entry" { + moduleType = "entries" + } + if moduleType == "overrides" { + moduleType = "override" + } + if configured := strings.TrimSpace(opts.FilenamePrefixes[moduleType]); configured != "" { + if prefix := normalizeModuleFilenamePrefix(configured); prefix != "" { + return prefix + } + } + return "add" +} + +func normalizeModuleFilenamePrefix(prefix string) string { + prefix = strings.TrimSpace(strings.ToLower(prefix)) + prefix = strings.ReplaceAll(prefix, "/", "_") + prefix = strings.ReplaceAll(prefix, "\\", "_") + prefix = convertKeyWhitespace.ReplaceAllString(prefix, "_") + prefix = convertKeyCleaner.ReplaceAllString(prefix, "_") + prefix = strings.Trim(prefix, "_") + return prefix +} + func normalizeModuleFilenameNamespace(namespace string) string { namespace = strings.TrimSpace(strings.ToLower(namespace)) namespace = strings.ReplaceAll(namespace, "/", "_") @@ -814,3 +878,11 @@ func write2DAFile(data parsed2DA, path string) error { } return write2DA(table, path, false) } + +func cloneStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for key, value := range in { + out[key] = value + } + return out +} diff --git a/internal/topdata/convert_test.go b/internal/topdata/convert_test.go index 73c8889..eb21a42 100644 --- a/internal/topdata/convert_test.go +++ b/internal/topdata/convert_test.go @@ -68,6 +68,71 @@ tables: } } +func TestRunConvertCommandInfersOverrideOutputPrefixFromTemplateConfig(t *testing.T) { + root := testProjectRoot(t) + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) + mkdirAll(t, filepath.Join(root, "topdata", "templates")) + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +module_output: + filename_prefixes: + entries: add + override: ovr +tables: + template_appearance: + namespace: appearance + key_fields: + - LABEL +`) + writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\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) + } + + var stdout bytes.Buffer + if err := RunConvertCommand([]string{ + "2da-to-module", + "--type", "override", + "--name", "ashzombies", + "topdata/templates/template_appearance.2da", + }, &stdout); err != nil { + t.Fatalf("RunConvertCommand failed: %v", err) + } + + outputPath := filepath.Join(root, "topdata", "data", "appearance", "modules", "ovr_appearance_ashzombies.json") + raw, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output: %v", err) + } + if !strings.Contains(string(raw), `"overrides"`) { + t.Fatalf("expected override payload, got:\n%s", string(raw)) + } + if _, err := os.Stat(filepath.Join(root, "topdata", "data", "appearance", "modules", "add_appearance_ashzombies.json")); !os.IsNotExist(err) { + t.Fatalf("expected add-prefixed output to be absent, stat err: %v", err) + } + if !strings.Contains(stdout.String(), "converted overrides: 1") { + t.Fatalf("unexpected stdout: %s", stdout.String()) + } +} + func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` @@ -470,6 +535,54 @@ topdata: } } +func TestRunConvertCommandRejectsUnknownTemplateFilenamePrefixType(t *testing.T) { + root := testProjectRoot(t) + writeFile(t, filepath.Join(root, "nwn-tool.yaml"), ` +module: + name: Test + resref: test +paths: + source: src + assets: assets + build: build +topdata: + source: topdata + build: build/topdata +`) + mkdirAll(t, filepath.Join(root, "topdata", "templates")) + writeFile(t, filepath.Join(root, "topdata", "templates", "config.yaml"), ` +module_output: + filename_prefixes: + additions: add +tables: + template_appearance: + namespace: appearance + key_fields: + - LABEL +`) + writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL\n960 Zombie_Ash_Hot\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) + } + + err = RunConvertCommand([]string{ + "2da-to-module", + "--name", "ashzombies", + "topdata/templates/template_appearance.2da", + }, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), `unsupported module_output.filename_prefixes key "additions"`) { + t.Fatalf("expected invalid filename prefix type error, got %v", err) + } +} + func TestConvert2DAToModuleFailsOnDuplicateGeneratedKeysByDefault(t *testing.T) { _, err := convert2DAToModule(parsed2DA{ Columns: []string{"LABEL"},