package pipeline import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "slices" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) type ExtractResult struct { ModulePath string HAKPaths []string Written int Overwritten int Removed int Skipped int } func Extract(p *project.Project) (ExtractResult, error) { modulePath := filepath.Join(p.BuildDir(), p.Config.Module.ResRef+".mod") input, err := os.Open(modulePath) if err != nil { return ExtractResult{}, fmt.Errorf("open module archive: %w", err) } defer input.Close() archive, err := erf.Read(input) if err != nil { return ExtractResult{}, fmt.Errorf("read module archive: %w", err) } var result ExtractResult result.ModulePath = modulePath var failures []error desired := map[string]struct{}{} written, overwritten, skipped, errs := extractArchiveResources(p, archive, desired) result.Written += written result.Overwritten += overwritten result.Skipped += skipped failures = append(failures, errs...) hakPaths, err := filepath.Glob(filepath.Join(p.BuildDir(), "*.hak")) if err != nil { return result, fmt.Errorf("scan hak archives: %w", err) } slices.Sort(hakPaths) for _, hakPath := range hakPaths { input, err := os.Open(hakPath) if err != nil { failures = append(failures, fmt.Errorf("open hak archive %s: %w", hakPath, err)) continue } hakArchive, err := erf.Read(input) input.Close() if err != nil { failures = append(failures, fmt.Errorf("read hak archive %s: %w", hakPath, err)) continue } result.HAKPaths = append(result.HAKPaths, hakPath) written, overwritten, skipped, errs := extractArchiveResources(p, hakArchive, desired) result.Written += written result.Overwritten += overwritten result.Skipped += skipped failures = append(failures, errs...) } removed, errs := cleanupStaleFiles(p, desired) result.Removed = removed failures = append(failures, errs...) if len(failures) > 0 { return result, errors.Join(failures...) } return result, nil } func extractArchiveResources(p *project.Project, archive erf.Archive, desired map[string]struct{}) (int, int, int, []error) { var failures []error writtenCount := 0 overwrittenCount := 0 skippedCount := 0 for _, resource := range archive.Resources { target, data, err := extractedFile(p, resource) if err != nil { failures = append(failures, err) continue } desired[target] = struct{}{} state, err := writeManagedFile(target, data) if err != nil { failures = append(failures, err) continue } switch state { case writeNew: writtenCount++ case writeOverwritten: overwrittenCount++ case writeSkipped: skippedCount++ } } return writtenCount, overwrittenCount, skippedCount, failures } func extractedFile(p *project.Project, resource erf.Resource) (string, []byte, error) { extension, ok := erf.ExtensionForResourceType(resource.Type) if !ok { return "", nil, fmt.Errorf("unsupported resource type 0x%04X for %s", resource.Type, resource.Name) } resref := strings.ToLower(resource.Name) switch extension { case "nss": return filepath.Join(p.SourceDir(), "scripts", resref+".nss"), resource.Data, nil case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw", "are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl": document, err := gff.Read(bytes.NewReader(resource.Data)) if err != nil { return "", nil, fmt.Errorf("decode gff %s.%s: %w", resource.Name, extension, err) } formatted, err := json.MarshalIndent(document, "", " ") if err != nil { return "", nil, fmt.Errorf("marshal json %s.%s: %w", resource.Name, extension, err) } formatted = append(formatted, '\n') return filepath.Join(p.SourceDir(), sourceSubdir(extension), resref+"."+extension+".json"), formatted, nil default: return filepath.Join(p.AssetsDir(), extension, resref+"."+extension), resource.Data, nil } } type writeState int const ( writeSkipped writeState = iota writeNew writeOverwritten ) func writeManagedFile(path string, data []byte) (writeState, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return writeSkipped, fmt.Errorf("create parent directory for %s: %w", path, err) } existing, err := os.ReadFile(path) if err == nil { if bytes.Equal(existing, data) { return writeSkipped, nil } if err := os.WriteFile(path, data, 0o644); err != nil { return writeSkipped, fmt.Errorf("overwrite %s: %w", path, err) } return writeOverwritten, nil } if !errors.Is(err, os.ErrNotExist) { return writeSkipped, fmt.Errorf("check existing file %s: %w", path, err) } if err := os.WriteFile(path, data, 0o644); err != nil { return writeSkipped, fmt.Errorf("write %s: %w", path, err) } return writeNew, nil } func cleanupStaleFiles(p *project.Project, desired map[string]struct{}) (int, []error) { candidates := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles)) for _, rel := range p.Inventory.SourceFiles { candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) } for _, rel := range p.Inventory.ScriptFiles { candidates = append(candidates, filepath.Join(p.SourceDir(), filepath.FromSlash(rel))) } for _, rel := range p.Inventory.AssetFiles { candidates = append(candidates, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))) } removed := 0 var failures []error for _, path := range candidates { if _, keep := desired[path]; keep { continue } if err := os.Remove(path); err != nil { if errors.Is(err, os.ErrNotExist) { continue } failures = append(failures, fmt.Errorf("remove stale file %s: %w", path, err)) continue } removed++ cleanupEmptyParents(filepath.Dir(path), p.SourceDir(), p.AssetsDir()) } return removed, failures } func cleanupEmptyParents(dir string, roots ...string) { for { if dir == "." || dir == string(filepath.Separator) { return } stop := false for _, root := range roots { if dir == root { stop = true break } } if stop { return } if err := os.Remove(dir); err != nil { return } dir = filepath.Dir(dir) } } func sourceSubdir(extension string) string { switch strings.ToLower(extension) { case "are": return "areas" case "dlg": return "dialogs" case "fac": return "factions" case "gic": return "instance" case "git": return "instance" case "ifo": return "module" case "itp": return "palettes" case "jrl": return "journal" case "utc": return filepath.Join("blueprints", "creatures") case "utd": return filepath.Join("blueprints", "doors") case "ute": return filepath.Join("blueprints", "encounters") case "uti": return filepath.Join("blueprints", "items") case "utm": return filepath.Join("blueprints", "merchants") case "utp": return filepath.Join("blueprints", "placeables") case "uts": return filepath.Join("blueprints", "sounds") case "utt": return filepath.Join("blueprints", "triggers") case "utw": return filepath.Join("blueprints", "waypoints") default: return extension } }