Template usage

This commit is contained in:
2026-04-10 15:26:27 +02:00
parent 9300033e95
commit ae58c4ae71
7 changed files with 492 additions and 50 deletions
+181 -28
View File
@@ -21,6 +21,7 @@ type convertOptions struct {
CollisionMode string
BaseDialog string
Type string
Name string
}
func RunConvertCommand(args []string, stdout io.Writer) error {
@@ -49,7 +50,7 @@ func RunConvertCommand(args []string, stdout io.Writer) error {
return writeJSONFile(output, result)
case "2da-to-module":
if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") {
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
_, _ = fmt.Fprintln(stdout, "usage: convert-topdata 2da-to-module [--namespace <dataset>] [--name <module-name>] [--type entries|override] [flags] <input.2da> [output.json]")
return nil
}
opts, input, output, err := parseConvertArgs(args[1:], true)
@@ -68,17 +69,13 @@ func RunConvertCommand(args []string, stdout io.Writer) error {
_, _ = fmt.Fprintf(stdout, "converted overrides: %d\n", len(result["overrides"].([]map[string]any)))
return nil
}
result, skipped, err := convert2DAToModule(data, output, opts)
result, err := convert2DAToModule(data, output, opts)
if err != nil {
return err
}
if err := writeJSONFile(output, result); err != nil {
return err
}
if skipped > 0 {
_, _ = fmt.Fprintf(stdout, "converted entries: %d, skipped unkeyed: %d\n", len(result["entries"].(map[string]any)), skipped)
return nil
}
_, _ = fmt.Fprintf(stdout, "converted entries: %d\n", len(result["entries"].(map[string]any)))
return nil
case "json-to-2da":
@@ -106,6 +103,11 @@ func printConvertUsage(stdout io.Writer) {
_, _ = fmt.Fprintln(stdout, " 2da-to-json Convert a 2DA file into canonical JSON rows")
_, _ = fmt.Fprintln(stdout, " 2da-to-module Convert a 2DA file into module entries or override JSON")
_, _ = fmt.Fprintln(stdout, " json-to-2da Convert canonical JSON rows into a 2DA file")
_, _ = fmt.Fprintln(stdout, "")
_, _ = fmt.Fprintln(stdout, "2da-to-module notes:")
_, _ = fmt.Fprintln(stdout, " - --namespace is optional when topdata/templates/config.json declares it for the input table")
_, _ = fmt.Fprintln(stdout, " - --name controls inferred output naming: add_<namespace>_<name>.json")
_, _ = fmt.Fprintln(stdout, " - unkeyed rows now fail conversion instead of being skipped")
}
type parsed2DA struct {
@@ -115,7 +117,7 @@ type parsed2DA struct {
func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string, string, error) {
opts := convertOptions{
CollisionMode: "prompt",
CollisionMode: "error",
Type: "entries",
}
positional := make([]string, 0, 2)
@@ -134,6 +136,12 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
return opts, "", "", errors.New("--key-field requires a value")
}
opts.KeyFields = append(opts.KeyFields, args[index])
case "--name":
index++
if index >= len(args) {
return opts, "", "", errors.New("--name requires a value")
}
opts.Name = args[index]
case "--collision":
index++
if index >= len(args) {
@@ -163,22 +171,29 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
}
}
if allowFormat {
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace")
}
if len(positional) < 1 || len(positional) > 2 {
return opts, "", "", errors.New("usage: convert-topdata 2da-to-module --namespace <dataset> [--type entries|override] [flags] <input.2da> [output.json]")
return opts, "", "", errors.New("usage: convert-topdata 2da-to-module [--namespace <dataset>] [--name <module-name>] [--type entries|override] [flags] <input.2da> [output.json]")
}
} else if len(positional) != 2 {
return opts, "", "", errors.New("usage: convert-topdata 2da-to-json [flags] <input.2da> <output.json>")
}
if opts.CollisionMode == "prompt" {
opts.CollisionMode = "suffix"
}
if opts.CollisionMode != "suffix" && opts.CollisionMode != "error" {
return opts, "", "", fmt.Errorf("unsupported collision mode %q", opts.CollisionMode)
}
if allowFormat {
ctx, err := resolveConvertContext(positional[0])
if err != nil {
return opts, "", "", err
}
if strings.TrimSpace(opts.Namespace) == "" {
opts.Namespace = ctx.Template.Namespace
}
if len(opts.KeyFields) == 0 && len(ctx.Template.KeyFields) > 0 {
opts.KeyFields = append(opts.KeyFields, ctx.Template.KeyFields...)
}
if strings.TrimSpace(opts.Namespace) == "" {
return opts, "", "", errors.New("2da-to-module requires --namespace or a topdata/templates/config.json entry for the input table")
}
switch opts.Type {
case "entry":
opts.Type = "entries"
@@ -192,7 +207,7 @@ func parseConvertArgs(args []string, allowFormat bool) (convertOptions, string,
if len(positional) == 2 {
output = positional[1]
} else {
resolved, err := defaultModuleOutputPath(positional[0], opts)
resolved, err := defaultModuleOutputPath(positional[0], opts, ctx.Project)
if err != nil {
return opts, "", "", err
}
@@ -213,7 +228,7 @@ func positionalSafe(args []string) string {
func parse2DAFile(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path)
if err != nil {
return parsed2DA{}, err
return parsed2DA{}, fmt.Errorf("read %s: %w", path, err)
}
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
trimmed := make([]string, 0, len(lines))
@@ -224,10 +239,14 @@ func parse2DAFile(path string) (parsed2DA, error) {
}
}
if len(trimmed) < 2 || !strings.HasPrefix(trimmed[0], "2DA") {
return parsed2DA{}, errors.New("invalid 2DA file")
return parsed2DA{}, fmt.Errorf("%s: invalid 2DA file", path)
}
columns := split2DALine(trimmed[1])
if len(columns) == 0 {
return parsed2DA{}, fmt.Errorf("%s: missing 2DA columns", path)
}
rows := make([]map[string]any, 0, max(0, len(trimmed)-2))
seenIDs := map[int]struct{}{}
for _, line := range trimmed[2:] {
fields := split2DALine(line)
if len(fields) == 0 {
@@ -235,8 +254,15 @@ func parse2DAFile(path string) (parsed2DA, error) {
}
rowID, err := strconv.Atoi(fields[0])
if err != nil {
return parsed2DA{}, fmt.Errorf("invalid 2DA row id %q", fields[0])
return parsed2DA{}, fmt.Errorf("%s: invalid 2DA row id %q", path, fields[0])
}
if len(fields)-1 > len(columns) {
return parsed2DA{}, fmt.Errorf("%s: row %d has %d values but only %d columns are declared", path, rowID, len(fields)-1, len(columns))
}
if _, ok := seenIDs[rowID]; ok {
return parsed2DA{}, fmt.Errorf("%s: duplicate 2DA row id %d", path, rowID)
}
seenIDs[rowID] = struct{}{}
row := map[string]any{"id": rowID}
for index, column := range columns {
if index+1 < len(fields) {
@@ -302,17 +328,17 @@ func convert2DAToJSON(data parsed2DA, outputPath string, opts convertOptions) (m
return map[string]any{"columns": data.Columns, "rows": outRows}, nil
}
func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, int, error) {
func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions) (map[string]any, error) {
rows, err := assignConvertedRowKeys(data.Rows, outputPath, opts)
if err != nil {
return nil, 0, err
return nil, err
}
entries := map[string]any{}
skipped := 0
missing := []string{}
for _, row := range rows {
key, _ := row["key"].(string)
if key == "" {
skipped++
missing = append(missing, strconv.Itoa(row["id"].(int)))
continue
}
entry := map[string]any{}
@@ -325,7 +351,10 @@ func convert2DAToModule(data parsed2DA, outputPath string, opts convertOptions)
}
entries[key] = entry
}
return map[string]any{"entries": entries}, skipped, nil
if len(missing) > 0 {
return nil, fmt.Errorf("unable to generate keys for row ids %s; declare --key-field or configure key_fields in topdata/templates/config.json", strings.Join(missing, ", "))
}
return map[string]any{"entries": entries}, nil
}
func toOverridesRows(rows []map[string]any) []map[string]any {
@@ -457,27 +486,151 @@ func normalizeConvertedKeyText(text string) string {
return text
}
func defaultModuleOutputPath(input string, opts convertOptions) (string, error) {
type convertTemplateTable struct {
Namespace string `json:"namespace"`
KeyFields []string `json:"key_fields"`
}
type convertTemplateConfig struct {
Tables map[string]convertTemplateTable `json:"tables"`
}
type convertContext struct {
Project *project.Project
Template convertTemplateTable
}
func resolveConvertContext(input string) (convertContext, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
return convertContext{}, err
}
root, err := project.FindRoot(cwd)
if err != nil {
return "", errors.New("2da-to-module requires an explicit output path when not run inside a project root")
return convertContext{}, nil
}
p, err := project.Load(root)
if err != nil {
return "", err
return convertContext{}, err
}
if !p.HasTopData() {
return convertContext{Project: p}, nil
}
table, err := loadTemplateTableConfig(p, input)
if err != nil {
return convertContext{}, err
}
return convertContext{
Project: p,
Template: table,
}, nil
}
func loadTemplateTableConfig(p *project.Project, input string) (convertTemplateTable, error) {
templatesDir := filepath.Join(p.TopDataSourceDir(), "templates")
inputAbs, err := filepath.Abs(input)
if err != nil {
return convertTemplateTable{}, err
}
templatesAbs, err := filepath.Abs(templatesDir)
if err != nil {
return convertTemplateTable{}, err
}
rel, err := filepath.Rel(templatesAbs, inputAbs)
if err != nil || strings.HasPrefix(rel, "..") || rel == "." {
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))
for _, key := range []string{rel, base, stem} {
if table, ok := config.Tables[key]; ok {
return table, nil
}
}
return convertTemplateTable{}, nil
}
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")
}
if !p.HasTopData() {
return "", errors.New("2da-to-module requires topdata to be configured when inferring an output path")
}
filename := strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json"
filename, err := defaultModuleFileName(input, opts, p)
if err != nil {
return "", err
}
modulesDir := filepath.Join(p.TopDataSourceDir(), "data", filepath.FromSlash(opts.Namespace), "modules")
return filepath.Join(modulesDir, filename), nil
}
func defaultModuleFileName(input string, opts convertOptions, p *project.Project) (string, error) {
if strings.TrimSpace(opts.Name) != "" {
name := normalizeConvertedKeyText(opts.Name)
if name == "" {
return "", fmt.Errorf("invalid --name %q", opts.Name)
}
namespace := normalizeModuleFilenameNamespace(opts.Namespace)
return fmt.Sprintf("add_%s_%s.json", 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")
}
return strings.TrimSuffix(filepath.Base(input), filepath.Ext(input)) + ".json", nil
}
func normalizeModuleFilenameNamespace(namespace string) string {
namespace = strings.TrimSpace(strings.ToLower(namespace))
namespace = strings.ReplaceAll(namespace, "/", "_")
namespace = strings.ReplaceAll(namespace, "\\", "_")
namespace = convertKeyWhitespace.ReplaceAllString(namespace, "_")
namespace = convertKeyCleaner.ReplaceAllString(namespace, "_")
namespace = strings.Trim(namespace, "_")
if namespace == "" {
return "module"
}
return namespace
}
func isTemplateInput(p *project.Project, input string) bool {
if p == nil || !p.HasTopData() {
return false
}
templatesAbs, err := filepath.Abs(filepath.Join(p.TopDataSourceDir(), "templates"))
if err != nil {
return false
}
inputAbs, err := filepath.Abs(input)
if err != nil {
return false
}
rel, err := filepath.Rel(templatesAbs, inputAbs)
if err != nil {
return false
}
return rel != "." && !strings.HasPrefix(rel, "..")
}
func readCanonicalJSON(path string) (parsed2DA, error) {
raw, err := os.ReadFile(path)
if err != nil {