Conversion Key Inference: Config-Driven
This commit is contained in:
+135
-78
@@ -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
|
||||
}
|
||||
return ""
|
||||
seen[joined] = struct{}{}
|
||||
candidates = append(candidates, joined)
|
||||
}
|
||||
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")
|
||||
|
||||
@@ -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"), `{
|
||||
|
||||
Reference in New Issue
Block a user