package topdata import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type NormalizeResult struct { StatePath string ScannedFiles int UpdatedFiles int InlineValues int RemainingFiles int RemainingLegacyRefs int } func NormalizeProject(p *project.Project) (NormalizeResult, error) { if !p.HasTopData() { return NormalizeResult{}, fmt.Errorf("topdata is not configured for this project") } sourceDir := p.TopDataSourceDir() dataDir := filepath.Join(sourceDir, "data") migrationRoot := resolveMigrationInputRoot(sourceDir, p.TopDataReferenceBuilderDir()) legacyTLK, err := loadLegacyTLK(filepath.Join(sourceDir, "tlk")) if err != nil { return NormalizeResult{}, err } if migrationRoot != "" { migrationTLK, err := loadLegacyTLK(filepath.Join(migrationRoot, "tlk")) if err != nil { return NormalizeResult{}, err } legacyTLK = mergeLegacyTLK(migrationTLK, legacyTLK) } baseDialogImported, err := importLegacyBaseDialog(migrationRoot, sourceDir) if err != nil { return NormalizeResult{}, err } itempropsImported, err := importLegacyItempropsRegistry(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } masterfeatsImported, err := importLegacyMasterfeats(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } featImported, err := importLegacyFeat(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } creaturespeedImported, err := importLegacyCreaturespeed(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } armorImported, err := importLegacyArmor(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } baseitemsImported, err := importLegacyBaseitems(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } damagetypesImported, err := importLegacyDamagetypes(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } skyboxesImported, err := importLegacySkyboxes(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } vfxPersistentImported, err := importLegacyVFXPersistent(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } loadscreensImported, err := importLegacyLoadscreens(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } progfxImported, err := importLegacyProgfx(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } genericdoorsImported, err := importLegacyGenericdoors(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } cloakmodelImported, err := importLegacyCloakmodel(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } repadjustImported, err := importLegacyRepadjust(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } wingmodelImported, err := importLegacyWingmodel(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } tailmodelImported, err := importLegacyTailmodel(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } doortypesImported, err := importLegacyDoortypes(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } visualeffectsImported, err := importLegacyVisualeffects(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } appearanceImported, err := importLegacyAppearance(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } placeablesImported, err := importLegacyPlaceables(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } rulesetImported, err := importLegacyRuleset(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } skillsImported, err := importLegacySkills(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } spellsImported, err := importLegacySpells(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } racialtypesImported, err := importLegacyRacialtypes(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } classesImported, err := importLegacyClasses(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } portraitsImported, err := importLegacyPortraits(migrationRoot, dataDir, legacyTLK) if err != nil { return NormalizeResult{}, err } paths, err := collectDataJSONPaths(dataDir) if err != nil { return NormalizeResult{}, err } result := NormalizeResult{ StatePath: filepath.Join(sourceDir, tlkStateFile), ScannedFiles: len(paths), UpdatedFiles: baseDialogImported + itempropsImported + masterfeatsImported + featImported + creaturespeedImported + armorImported + baseitemsImported + damagetypesImported + skyboxesImported + vfxPersistentImported + loadscreensImported + progfxImported + genericdoorsImported + cloakmodelImported + repadjustImported + wingmodelImported + tailmodelImported + doortypesImported + visualeffectsImported + appearanceImported + placeablesImported + rulesetImported + skillsImported + spellsImported + racialtypesImported + classesImported + portraitsImported, } state, err := loadTLKState(result.StatePath) if err != nil { return result, err } if state.Entries == nil { state.Entries = map[string]tlkStateMapping{} } if legacyTLK != nil && state.Language == "" { state.Language = legacyTLK.Language } if legacyTLK != nil { for key, id := range legacyTLK.Lock { if _, ok := state.Entries[key]; !ok { state.Entries[key] = tlkStateMapping{ID: id} } } } if legacyTLK != nil { for _, path := range paths { updated, count, err := inlineLegacyTLKFile(path, legacyTLK) if err != nil { return result, err } if updated { result.UpdatedFiles++ result.InlineValues += count } } } if err := saveTLKState(result.StatePath, state); err != nil { return result, err } if err := removeLegacyTLKAuthoredInput(filepath.Join(sourceDir, "tlk")); err != nil { return result, err } remainingFiles, remainingRefs, err := countLegacyTLKRefs(paths) if err != nil { return result, err } result.RemainingFiles = remainingFiles result.RemainingLegacyRefs = remainingRefs return result, nil } func mergeLegacyTLK(base, override *legacyTLKData) *legacyTLKData { if base == nil && override == nil { return nil } merged := &legacyTLKData{ Language: "en", Entries: map[string]tlkEntryData{}, Lock: map[string]int{}, } if base != nil { if base.Language != "" { merged.Language = base.Language } for key, entry := range base.Entries { merged.Entries[key] = entry } for key, id := range base.Lock { merged.Lock[key] = id } } if override != nil { if override.Language != "" { merged.Language = override.Language } for key, entry := range override.Entries { merged.Entries[key] = entry } for key, id := range override.Lock { merged.Lock[key] = id } } return merged } func removeLegacyTLKAuthoredInput(tlkDir string) error { modulesDir := filepath.Join(tlkDir, "modules") if err := os.RemoveAll(modulesDir); err != nil { return fmt.Errorf("remove %s: %w", modulesDir, err) } lockPath := filepath.Join(tlkDir, "lock.json") if err := os.Remove(lockPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", lockPath, err) } gitkeepPath := filepath.Join(tlkDir, ".gitkeep") if err := os.Remove(gitkeepPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", gitkeepPath, err) } if err := os.Remove(tlkDir); err != nil && !os.IsNotExist(err) { if !strings.Contains(err.Error(), "directory not empty") { return fmt.Errorf("remove %s: %w", tlkDir, err) } } return nil } func resolveMigrationInputRoot(sourceDir, referenceBuilderDir string) string { if strings.TrimSpace(referenceBuilderDir) != "" { return referenceBuilderDir } snapshotRoot := filepath.Join(sourceDir, "migration_snapshot") if info, err := os.Stat(snapshotRoot); err == nil && info.IsDir() { return snapshotRoot } return "" } func inlineLegacyTLKFile(path string, legacy *legacyTLKData) (bool, int, error) { obj, err := loadJSONObject(path) if err != nil { return false, 0, err } updated, count, err := inlineLegacyTLKValue(obj, legacy) if err != nil { return false, 0, err } if !updated { return false, 0, nil } raw, err := json.MarshalIndent(obj, "", " ") if err != nil { return false, 0, err } raw = append(raw, '\n') if err := os.WriteFile(path, raw, 0o644); err != nil { return false, 0, err } return true, count, nil } func inlineLegacyTLKValue(value any, legacy *legacyTLKData) (bool, int, error) { switch typed := value.(type) { case map[string]any: if rawTLK, ok := typed["tlk"]; ok { key, ok := rawTLK.(string) if ok { entry, ok := legacy.Entries[key] if !ok { return false, 0, nil } payload := map[string]any{ "key": key, "text": entry.Text, } if entry.SoundResRef != "" { payload["sound_resref"] = entry.SoundResRef } if entry.SoundLength != 0 { payload["sound_length"] = entry.SoundLength } typed["tlk"] = payload return true, 1, nil } } updated := false total := 0 for key, child := range typed { childUpdated, childCount, err := inlineLegacyTLKValue(child, legacy) if err != nil { return false, 0, err } if childUpdated { typed[key] = child updated = true total += childCount } } return updated, total, nil case []any: updated := false total := 0 for index, child := range typed { childUpdated, childCount, err := inlineLegacyTLKValue(child, legacy) if err != nil { return false, 0, err } if childUpdated { typed[index] = child updated = true total += childCount } } return updated, total, nil default: return false, 0, nil } } func collectDataJSONPaths(dataDir string) ([]string, error) { paths := make([]string, 0) err := filepath.WalkDir(dataDir, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if strings.HasSuffix(strings.ToLower(d.Name()), ".json") && d.Name() != "lock.json" { paths = append(paths, path) } return nil }) if err != nil { return nil, err } return paths, nil } func countLegacyTLKRefs(paths []string) (int, int, error) { files := 0 refs := 0 for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { return 0, 0, err } count := countLegacyTLKRefsInValue(obj) if count == 0 { continue } files++ refs += count } return files, refs, nil } func countLegacyTLKRefsInValue(value any) int { switch typed := value.(type) { case map[string]any: total := 0 if rawTLK, ok := typed["tlk"]; ok { if _, ok := rawTLK.(string); ok { total++ } } for _, child := range typed { total += countLegacyTLKRefsInValue(child) } return total case []any: total := 0 for _, child := range typed { total += countLegacyTLKRefsInValue(child) } return total default: return 0 } }