Conversion Key Inference: Config-Driven

This commit is contained in:
2026-04-10 19:11:08 +02:00
parent a1a98dd18e
commit 7f8506beed
2 changed files with 193 additions and 78 deletions
+135 -78
View File
@@ -181,10 +181,21 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode) return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode)
} }
if allowFormat { if allowFormat {
output := ""
if len(positional) == 2 {
output = positional[1]
}
ctx, err := resolveConvertContext(positional[0]) ctx, err := resolveConvertContext(positional[0])
if err != nil { if err != nil {
return opts, "", "", err 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) == "" { if strings.TrimSpace(opts.Namespace) == "" {
opts.Namespace = ctx.Template.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" { if opts.Type != "entries" && opts.Type != "override" {
return opts, "", "", fmt.Errorf("unsupported module type %q", opts.Type) return opts, "", "", fmt.Errorf("unsupported module type %q", opts.Type)
} }
output := "" if output == "" {
if len(positional) == 2 {
output = positional[1]
} else {
resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project) resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project)
if err != nil { if err != nil {
return opts, "", "", err return opts, "", "", err
@@ -219,6 +227,13 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
if err != nil { if err != nil {
return opts, "", "", err 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) == "" { if strings.TrimSpace(opts.Namespace) == "" {
opts.Namespace = ctx.Template.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) { 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)) out := make([]map[string]any, 0, len(rows))
used := map[string]struct{}{} used := map[string]struct{}{}
rowIDsByKey := map[string]int{}
for _, row := range rows { for _, row := range rows {
cloned := cloneRowMap(row) cloned := cloneRowMap(row)
if namespace == "" { if namespace == "" {
out = append(out, cloned) out = append(out, cloned)
continue continue
} }
key := convertedRowKey(cloned, namespace, profiles, opts.KeyFields) candidates := convertedRowKeyCandidates(cloned, opts.KeyFields)
if key == "" { if len(candidates) == 0 {
out = append(out, cloned) out = append(out, cloned)
continue 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" { 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"]) key = fmt.Sprintf("%s_%v", key, cloned["id"])
} }
used[key] = struct{}{} used[key] = struct{}{}
rowID, _ := asInt(cloned["id"])
rowIDsByKey[key] = rowID
cloned["key"] = key cloned["key"] = key
out = append(out, cloned) out = append(out, cloned)
} }
return out, nil 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 convertKeyWhitespace = regexp.MustCompile(`\s+`)
var convertKeyCleaner = regexp.MustCompile(`[^a-z0-9_]`) var convertKeyCleaner = regexp.MustCompile(`[^a-z0-9_]`)
func convertedRowKey(row map[string]any, namespace string, profiles []convertKeyProfile, preferred []string) string { func convertedRowKeyCandidates(row map[string]any, preferred []string) []string {
candidates := make([]convertKeyProfile, 0, len(preferred)+len(profiles)) fields := preferred
for _, field := range preferred { if len(fields) == 0 {
candidates = append(candidates, convertKeyProfile{Field: field, Mode: "literal"}) fields = []string{"LABEL", "Label", "Name"}
} }
candidates = append(candidates, profiles...) values := make([]string, 0, len(fields))
if len(candidates) == 0 { seen := map[string]struct{}{}
candidates = append(candidates, candidates := make([]string, 0, len(fields))
convertKeyProfile{Field: "LABEL", Mode: "literal"}, for _, field := range fields {
convertKeyProfile{Field: "Label", Mode: "literal"}, value, ok := row[field]
convertKeyProfile{Field: "Name", Mode: "literal"},
)
}
for _, candidate := range candidates {
value, ok := row[candidate.Field]
if !ok { if !ok {
continue continue
} }
@@ -483,9 +470,15 @@ func convertedRowKey(row map[string]any, namespace string, profiles []convertKey
if normalized == "" { if normalized == "" {
continue continue
} }
return namespace + ":" + normalized values = append(values, normalized)
joined := strings.Join(values, "_")
if _, ok := seen[joined]; ok {
continue
} }
return "" seen[joined] = struct{}{}
candidates = append(candidates, joined)
}
return candidates
} }
func normalizeConvertedKeyText(text string) string { func normalizeConvertedKeyText(text string) string {
@@ -537,6 +530,13 @@ func resolveConvertContext(input string) (convertContext, error) {
} }
func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateTable, 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") templatesDir := filepath.Join(p.TopDataSourceDir(), "templates")
inputAbs, err := filepath.Abs(input) inputAbs, err := filepath.Abs(input)
if err != nil { if err != nil {
@@ -551,23 +551,6 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT
return convertTemplateTable{}, nil 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) rel = filepath.ToSlash(rel)
base := filepath.Base(rel) base := filepath.Base(rel)
stem := strings.TrimSuffix(base, filepath.Ext(base)) stem := strings.TrimSuffix(base, filepath.Ext(base))
@@ -579,6 +562,80 @@ func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateT
return convertTemplateTable{}, nil 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) { func defaultModuleOutputPath(input string, opts convertOptions, p *project.Project) (string, error) {
if p == nil { if p == nil {
return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root") return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root")
+58
View File
@@ -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) { func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) {
root := testProjectRoot(t) root := testProjectRoot(t)
writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ writeFile(t, filepath.Join(root, "nwn-tool.json"), `{