package app import ( "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings" "sync" "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/pipeline" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/topdata" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/validator" ) type command struct { name string description string run func(context) error } type context struct { stdout io.Writer stderr io.Writer cwd string args []string } type spinner struct { mu sync.Mutex on bool text string msg []string } var spin = &spinner{ msg: []string{"-", "\\", "|", "/"}, } func (s *spinner) start(text string) { s.mu.Lock() defer s.mu.Unlock() s.on = true s.text = text go s.run(text) } func (s *spinner) stop() { s.mu.Lock() defer s.mu.Unlock() s.on = false } func (s *spinner) run(text string) { i := 0 for { s.mu.Lock() if !s.on { s.mu.Unlock() fmt.Fprintf(os.Stderr, "\r") return } msg := s.msg[i%len(s.msg)] fmt.Fprintf(os.Stderr, "\r[%s] %s", msg, text) s.mu.Unlock() i++ time.Sleep(100 * time.Millisecond) } } var commands = []command{ { name: "build", description: "Build module and HAK outputs into build/, plus top package output when topdata is configured.", run: runBuild, }, { name: "build-module", description: "Build only the module archive from src/ into build/.", run: runBuildModule, }, { name: "build-haks", description: "Build only HAK archives and manifest from assets/ into build/.", run: runBuildHAKs, }, { name: "extract", description: "Extract a module archive back into the src/ tree.", run: runExtract, }, { name: "validate", description: "Validate project layout, config, and source inventory.", run: runValidate, }, { name: "compare", description: "Compare current source content against the built archives.", run: runCompare, }, { name: "apply-hak-manifest", description: "Apply a generated HAK manifest to src/module/module.ifo.json.", run: runApplyHAKManifest, }, { name: "validate-topdata", description: "Validate topdata source layout and canonical JSON compatibility.", run: runValidateTopData, }, { name: "build-topdata", description: "Build canonical topdata outputs into .cache/2da and .cache/wiki, then refresh build/sow_top.hak and build/sow_tlk.tlk.", run: runBuildTopData, }, { name: "build-top-package", description: "Build build/sow_top.hak and build/sow_tlk.tlk from cached native topdata output and authored topdata/assets.", run: runBuildTopPackage, }, { name: "compare-topdata", description: "Rebuild topdata natively and compare the current build output against that fresh native result.", run: runCompareTopData, }, { name: "convert-topdata", description: "Convert between 2DA and native topdata JSON/module formats.", run: runConvertTopData, }, { name: "build-wiki", description: "Build wiki pages from the current topdata state.", run: runBuildWiki, }, { name: "deploy-wiki", description: "Deploy generated wiki pages to NodeBB.", run: runDeployWiki, }, } func Run(args []string) (int, error) { ctx, err := newContext() if err != nil { return 1, err } if len(args) == 0 { printUsage(ctx.stdout) return 0, nil } switch args[0] { case "-h", "--help", "help": printUsage(ctx.stdout) return 0, nil default: for _, cmd := range commands { if cmd.name == args[0] { spin.start("running " + cmd.name) if err := cmd.run(ctx); err != nil { spin.stop() return 1, err } spin.stop() return 0, nil } } } return 1, fmt.Errorf("unknown command %q", args[0]) } func newContext() (context, error) { cwd, err := os.Getwd() if err != nil { return context{}, fmt.Errorf("resolve working directory: %w", err) } return context{ stdout: os.Stdout, stderr: os.Stderr, cwd: cwd, args: os.Args[1:], }, nil } func runBuild(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.Build(p) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources) if result.TopPackageHAK != "" { fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.TopPackageHAK) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.TopPackageTLK) } if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets) fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest) } return nil } func runBuildModule(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.BuildModuleWithProgress(p, func(message string) { fmt.Fprintf(ctx.stdout, "[build-module] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) fmt.Fprintf(ctx.stdout, "resources: %d\n", result.Resources) return nil } type buildHAKOptions struct { filteredHAKs []string filteredArchives []string sourceManifest string planOnly bool } func runBuildHAKs(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseBuildHAKArgs(ctx.args[1:]) if err != nil { return err } p, err = p.CloneWithHAKNames(opts.filteredHAKs) if err != nil { return err } progress := func(message string) { fmt.Fprintf(ctx.stdout, "[build-haks] %s\n", message) } var result pipeline.BuildResult if opts.planOnly { result, err = pipeline.PlanHAKsWithProgress(p, progress) } else { result, err = pipeline.BuildHAKsWithOptions(p, pipeline.BuildHAKOptions{ Progress: progress, ArchiveNames: opts.filteredArchives, SourceManifestPath: opts.sourceManifest, }) } if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) fmt.Fprintf(ctx.stdout, "hak assets: %d\n", result.HAKAssets) if result.Manifest != "" { fmt.Fprintf(ctx.stdout, "hak manifest: %s\n", result.Manifest) } if len(result.AutogenManifestPaths) > 0 { fmt.Fprintf(ctx.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths)) } return nil } func parseBuildHAKArgs(args []string) (buildHAKOptions, error) { opts := buildHAKOptions{} if len(args) == 0 { return opts, nil } for index := 0; index < len(args); index++ { arg := args[index] switch arg { case "-h", "--help": return opts, errors.New("usage: build-haks [--hak ...] [--archive ...] [--source-manifest ] [--plan-only]") case "--hak": index++ if index >= len(args) { return opts, errors.New("--hak requires a value") } opts.filteredHAKs = append(opts.filteredHAKs, args[index]) case "--archive": index++ if index >= len(args) { return opts, errors.New("--archive requires a value") } opts.filteredArchives = append(opts.filteredArchives, args[index]) case "--plan-only": opts.planOnly = true case "--source-manifest": index++ if index >= len(args) { return opts, errors.New("--source-manifest requires a value") } opts.sourceManifest = args[index] default: if value, ok := parseInlineFlagValue(arg, "--hak"); ok { opts.filteredHAKs = append(opts.filteredHAKs, value) continue } if value, ok := parseInlineFlagValue(arg, "--archive"); ok { opts.filteredArchives = append(opts.filteredArchives, value) continue } if value, ok := parseInlineFlagValue(arg, "--source-manifest"); ok { opts.sourceManifest = value continue } return opts, fmt.Errorf("unknown build-haks argument %q", arg) } } return opts, nil } func parseInlineFlagValue(arg, name string) (string, bool) { prefix := name + "=" if !strings.HasPrefix(arg, prefix) { return "", false } return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true } func runExtract(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } files := ctx.args[1:] result, err := pipeline.Extract(p, files...) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) } if len(result.DeletedArchivePaths) > 0 { fmt.Fprintf(ctx.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths)) } fmt.Fprintf(ctx.stdout, "written: %d\n", result.Written) fmt.Fprintf(ctx.stdout, "overwritten: %d\n", result.Overwritten) fmt.Fprintf(ctx.stdout, "removed: %d\n", result.Removed) fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped) return nil } func runValidate(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } validationReport := validator.ValidateProject(p) emitValidatorReport(ctx.stderr, validationReport) if validationReport.HasErrors() { return fmt.Errorf("validation failed with %d error(s)", validationReport.ErrorCount()) } inventoryReport := p.Inventory.Report() fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "root: %s\n", p.Root) fmt.Fprintf(ctx.stdout, "source files: %d\n", inventoryReport.SourceFiles) fmt.Fprintf(ctx.stdout, "script files: %d\n", inventoryReport.ScriptFiles) fmt.Fprintf(ctx.stdout, "asset files: %d\n", inventoryReport.AssetFiles) fmt.Fprintf(ctx.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", ")) if warnings := validationReport.WarningCount(); warnings > 0 { fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings) } fmt.Fprintf(ctx.stdout, "validation: ok\n") return nil } func emitValidatorReport(w io.Writer, report validator.Report) { lines := report.SummaryLines(5) emitSummaryLines(w, lines, 20, "validation diagnostics") if line := report.DecompiledSummaryLine(12); line != "" { fmt.Fprintln(w, line) } } func emitTopdataValidationReport(w io.Writer, report topdata.ValidationReport) { lines := report.SummaryLines(5) emitSummaryLines(w, lines, 20, "topdata validation diagnostics") } func emitSummaryLines(w io.Writer, lines []string, maxLines int, label string) { if maxLines <= 0 || len(lines) <= maxLines { for _, line := range lines { fmt.Fprintln(w, line) } return } for _, line := range lines[:maxLines] { fmt.Fprintln(w, line) } fmt.Fprintf(w, "info: %d additional %s omitted\n", len(lines)-maxLines, label) } func runCompare(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := pipeline.Compare(p) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "module: %s\n", result.ModulePath) if len(result.HAKPaths) > 0 { fmt.Fprintf(ctx.stdout, "hak archives: %d\n", len(result.HAKPaths)) } fmt.Fprintf(ctx.stdout, "checked resources: %d\n", result.Checked) fmt.Fprintf(ctx.stdout, "compare: ok\n") return nil } func runApplyHAKManifest(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } manifestPath := p.HAKManifestPath() if len(ctx.args) > 1 { manifestPath = ctx.args[1] if !filepath.IsAbs(manifestPath) { manifestPath = filepath.Join(ctx.cwd, manifestPath) } } result, err := pipeline.ApplyHAKManifest(p, manifestPath) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.ManifestPath) fmt.Fprintf(ctx.stdout, "module source: %s\n", result.ModuleSource) fmt.Fprintf(ctx.stdout, "hak entries: %d\n", result.HAKCount) return nil } func runValidateTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } report := topdata.ValidateProject(p) emitTopdataValidationReport(ctx.stderr, report) if report.HasErrors() { return fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount()) } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "topdata root: %s\n", p.TopDataSourceDir()) fmt.Fprintf(ctx.stdout, "topdata files: %d\n", report.Files) fmt.Fprintf(ctx.stdout, "data files: %d\n", report.DataFiles) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", report.TLKFiles) if warnings := report.WarningCount(); warnings > 0 { fmt.Fprintf(ctx.stdout, "warnings: %d\n", warnings) } fmt.Fprintf(ctx.stdout, "topdata validation: ok\n") return nil } func runBuildTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseBuildTopDataArgs("build-topdata", ctx.args[1:]) if err != nil { return err } result, err := topdata.BuildAndPackageWithOptions(p, opts, func(message string) { fmt.Fprintf(ctx.stdout, "[build-topdata] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir) fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir) fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath) fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources) fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles) if result.WikiOutputDir != "" { fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) } return nil } func runBuildTopPackage(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseBuildTopDataArgs("build-top-package", ctx.args[1:]) if err != nil { return err } result, err := topdata.BuildAndPackageWithOptions(p, opts, func(message string) { fmt.Fprintf(ctx.stdout, "[build-top-package] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "topdata 2da output: %s\n", result.Output2DADir) fmt.Fprintf(ctx.stdout, "topdata tlk output: %s\n", result.OutputTLKDir) fmt.Fprintf(ctx.stdout, "top package hak: %s\n", result.OutputHAKPath) fmt.Fprintf(ctx.stdout, "top package tlk: %s\n", result.OutputTLKPath) fmt.Fprintf(ctx.stdout, "2da files: %d\n", result.Files2DA) fmt.Fprintf(ctx.stdout, "tlk files: %d\n", result.FilesTLK) fmt.Fprintf(ctx.stdout, "top package resources: %d\n", result.HAKResources) fmt.Fprintf(ctx.stdout, "top package assets: %d\n", result.AssetFiles) if result.WikiOutputDir != "" { fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.WikiOutputDir) fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.WikiPages) fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.WikiStatus) } return nil } func parseBuildTopDataArgs(commandName string, args []string) (topdata.BuildAndPackageOptions, error) { opts := topdata.BuildAndPackageOptions{} for _, arg := range args { switch arg { case "--force": opts.Force = true case "--wiki": opts.BuildWiki = true case "-h", "--help": return opts, fmt.Errorf("usage: %s [--force] [--wiki]", commandName) default: return opts, fmt.Errorf("unknown %s argument %q", commandName, arg) } } return opts, nil } func runCompareTopData(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } result, err := topdata.Compare(p, func(message string) { fmt.Fprintf(ctx.stdout, "[compare-topdata] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "mode: %s\n", result.Mode) fmt.Fprintf(ctx.stdout, "checked 2da files: %d\n", result.Compared2DA) fmt.Fprintf(ctx.stdout, "checked tlk files: %d\n", result.ComparedTLK) fmt.Fprintf(ctx.stdout, "native-pass: %d\n", result.NativePass) fmt.Fprintf(ctx.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical) fmt.Fprintf(ctx.stdout, "native-mismatch: %d\n", result.NativeMismatch) fmt.Fprintf(ctx.stdout, "topdata compare: ok\n") return nil } func runConvertTopData(ctx context) error { if len(ctx.args) < 2 { return errors.New("usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...") } return topdata.RunConvertCommand(ctx.args[1:], ctx.stdout) } func runBuildWiki(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseBuildWikiArgs("build-wiki", ctx.args[1:]) if err != nil { return err } result, err := topdata.BuildWikiWithOptions(p, opts, func(message string) { fmt.Fprintf(ctx.stdout, "[build-wiki] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "wiki output: %s\n", result.OutputDir) fmt.Fprintf(ctx.stdout, "wiki pages: %d\n", result.PageCount) fmt.Fprintf(ctx.stdout, "wiki status: %s\n", result.Status) return nil } func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOptions, error) { opts := topdata.BuildWikiOptions{} for _, arg := range args { switch arg { case "--force": opts.Force = true case "-h", "--help": return opts, fmt.Errorf("usage: %s [--force]", commandName) default: return opts, fmt.Errorf("unknown %s argument %q", commandName, arg) } } return opts, nil } func runDeployWiki(ctx context) error { p, err := loadProject(ctx.cwd) if err != nil { return err } opts, err := parseDeployWikiArgs("deploy-wiki", ctx.args[1:]) if err != nil { return err } result, err := topdata.DeployWikiWithOptions(p, opts, func(message string) { fmt.Fprintf(ctx.stdout, "[deploy-wiki] %s\n", message) }) if err != nil { return err } fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name) fmt.Fprintf(ctx.stdout, "local pages: %d\n", result.LocalPages) fmt.Fprintf(ctx.stdout, "created: %d\n", result.Created) fmt.Fprintf(ctx.stdout, "updated: %d\n", result.Updated) fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped) fmt.Fprintf(ctx.stdout, "stale: %d\n", result.Stale) fmt.Fprintf(ctx.stdout, "drifted: %d\n", result.Drifted) fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.Manifest) return nil } func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiOptions, error) { opts := topdata.DeployWikiOptions{} for i := 0; i < len(args); i++ { arg := args[i] switch arg { case "--source-dir": i++ if i >= len(args) { return opts, fmt.Errorf("--source-dir requires a value") } opts.SourceDir = args[i] case "--endpoint": i++ if i >= len(args) { return opts, fmt.Errorf("--endpoint requires a value") } opts.Endpoint = args[i] case "--username": i++ if i >= len(args) { return opts, fmt.Errorf("--username requires a value") } opts.Username = args[i] case "--password": i++ if i >= len(args) { return opts, fmt.Errorf("--password requires a value") } opts.Password = args[i] case "--token": i++ if i >= len(args) { return opts, fmt.Errorf("--token requires a value") } opts.Token = args[i] case "--version": i++ if i >= len(args) { return opts, fmt.Errorf("--version requires a value") } opts.Version = args[i] case "--namespace": i++ if i >= len(args) { return opts, fmt.Errorf("--namespace requires a value") } opts.Namespaces = append(opts.Namespaces, args[i]) case "--category": i++ if i >= len(args) { return opts, fmt.Errorf("--category requires a namespace=cid value") } if opts.CategoryIDs == nil { opts.CategoryIDs = map[string]int{} } if err := parseWikiCategoryArg(args[i], opts.CategoryIDs); err != nil { return opts, err } case "--manifest": i++ if i >= len(args) { return opts, fmt.Errorf("--manifest requires a value") } opts.ManifestPath = args[i] case "--dry-run": opts.DryRun = true case "--force": opts.Force = true case "--create": opts.AllowCreates = true case "-h", "--help": return opts, fmt.Errorf("usage: %s [--source-dir ] [--endpoint ] [--token ] [--version ] [--namespace ...] [--category ...] [--manifest ] [--dry-run] [--create] [--force]", commandName) default: if strings.HasPrefix(arg, "--source-dir=") { opts.SourceDir = strings.TrimPrefix(arg, "--source-dir=") } else if strings.HasPrefix(arg, "--endpoint=") { opts.Endpoint = strings.TrimPrefix(arg, "--endpoint=") } else if strings.HasPrefix(arg, "--username=") { opts.Username = strings.TrimPrefix(arg, "--username=") } else if strings.HasPrefix(arg, "--password=") { opts.Password = strings.TrimPrefix(arg, "--password=") } else if strings.HasPrefix(arg, "--token=") { opts.Token = strings.TrimPrefix(arg, "--token=") } else if strings.HasPrefix(arg, "--version=") { opts.Version = strings.TrimPrefix(arg, "--version=") } else if strings.HasPrefix(arg, "--namespace=") { opts.Namespaces = append(opts.Namespaces, strings.TrimPrefix(arg, "--namespace=")) } else if strings.HasPrefix(arg, "--category=") { if opts.CategoryIDs == nil { opts.CategoryIDs = map[string]int{} } if err := parseWikiCategoryArg(strings.TrimPrefix(arg, "--category="), opts.CategoryIDs); err != nil { return opts, err } } else if strings.HasPrefix(arg, "--manifest=") { opts.ManifestPath = strings.TrimPrefix(arg, "--manifest=") } else if arg == "--dry-run" { opts.DryRun = true } else if arg == "--force" { opts.Force = true } else if arg == "--create" { opts.AllowCreates = true } else { return opts, fmt.Errorf("unknown %s argument %q", commandName, arg) } } } if opts.Endpoint == "" { opts.Endpoint = os.Getenv("NODEBB_API_ENDPOINT") } if opts.Token == "" { opts.Token = os.Getenv("NODEBB_API_TOKEN") } if opts.Version == "" { opts.Version = os.Getenv("GITHUB_REF_NAME") } if opts.CategoryIDs == nil { opts.CategoryIDs = map[string]int{} } if raw := os.Getenv("NODEBB_WIKI_CATEGORIES"); raw != "" { if err := parseWikiCategoryList(raw, opts.CategoryIDs); err != nil { return opts, err } } return opts, nil } func parseWikiCategoryList(raw string, target map[string]int) error { for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } if err := parseWikiCategoryArg(part, target); err != nil { return err } } return nil } func parseWikiCategoryArg(raw string, target map[string]int) error { namespace, rawCID, ok := strings.Cut(raw, "=") if !ok { return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw) } namespace = strings.TrimSpace(namespace) rawCID = strings.TrimSpace(rawCID) if namespace == "" || rawCID == "" { return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw) } cid, err := strconv.Atoi(rawCID) if err != nil || cid <= 0 { return fmt.Errorf("wiki category mapping %q has invalid cid", raw) } target[namespace] = cid return nil } func loadProject(cwd string) (*project.Project, error) { root, err := project.FindRoot(cwd) if err != nil { return nil, err } p, err := project.Load(root) if err != nil { return nil, err } var failures []error if err := p.ValidateLayout(); err != nil { failures = append(failures, err) } if err := p.Scan(); err != nil { failures = append(failures, err) } if len(failures) > 0 { return nil, errors.Join(failures...) } return p, nil } func printUsage(w io.Writer) { exe := filepath.Base(os.Args[0]) fmt.Fprintf(w, "%s \n\n", exe) fmt.Fprintln(w, "Commands:") for _, cmd := range commands { fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.description) } }