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)
|
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")
|
||||||
|
|||||||
@@ -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"), `{
|
||||||
|
|||||||
Reference in New Issue
Block a user