From 22d7fa5e6f5d47c27589c9f04507a12ed28e8aaf Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Sat, 23 May 2026 17:51:27 +0200 Subject: [PATCH] Add managed namespace wiki reset deploy --- README.md | 7 +- internal/app/app.go | 6 +- internal/app/app_test.go | 10 +++ internal/topdata/wiki_deploy.go | 94 ++++++++++++++++---- internal/topdata/wiki_deploy_test.go | 128 +++++++++++++++++++++++++++ 5 files changed, 228 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index be3e522..2d8425b 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ sow-toolkit build-top-package [--force] sow-toolkit compare-topdata sow-toolkit convert-topdata <2da-to-json|2da-to-module|json-to-2da> ... sow-toolkit build-wiki [--force] -sow-toolkit deploy-wiki [--source-dir ] [--endpoint ] [--token ] [--version ] [--namespace ] [--category ] [--manifest ] [--stale-policy ] [--dry-run] [--create] [--force] +sow-toolkit deploy-wiki [--source-dir ] [--endpoint ] [--token ] [--version ] [--namespace ] [--category ] [--manifest ] [--stale-policy ] [--dry-run] [--create] [--force] [--reset-managed-namespaces] sow-toolkit build-changelog [--config ] [--output ] [--current-tag ] [--previous-tag ] [--api-base-url ] [--token ] ``` @@ -436,6 +436,11 @@ reported by default. managed content. `--stale-policy purge` permanently removes only stale generated topics already tracked in the wiki deploy manifest. Dry-run that policy before the live run; it never discovers manual wiki pages from NodeBB for purge. +`--reset-managed-namespaces` is a stronger destructive mode for slug or path +standard migrations: it permanently removes every topic currently listed by the +configured managed namespace categories, ignores the previous deploy manifest, +and recreates the current generated pages. It requires `--create` when local +generated pages are present. For newly created NodeBB topics, `topdata.wiki.title_prefix_min_length` controls when the deployer prefixes the namespace in the topic title. The diff --git a/internal/app/app.go b/internal/app/app.go index c9c6418..7b1a07f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1950,8 +1950,10 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO opts.Force = true case "--create": opts.AllowCreates = true + case "--reset-managed-namespaces": + opts.ResetManagedNamespaces = true case "-h", "--help": - return opts, fmt.Errorf("usage: %s [--source-dir ] [--endpoint ] [--token ] [--version ] [--namespace ...] [--category ...] [--manifest ] [--stale-policy ] [--dry-run] [--create] [--force]", commandName) + return opts, fmt.Errorf("usage: %s [--source-dir ] [--endpoint ] [--token ] [--version ] [--namespace ...] [--category ...] [--manifest ] [--stale-policy ] [--dry-run] [--create] [--force] [--reset-managed-namespaces]", commandName) default: if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil { if err != nil { @@ -2014,6 +2016,8 @@ func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiO opts.Force = true } else if arg == "--create" { opts.AllowCreates = true + } else if arg == "--reset-managed-namespaces" { + opts.ResetManagedNamespaces = true } else { return opts, fmt.Errorf("unknown %s argument %q", commandName, arg) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 02ed9dc..5a7fbd6 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -271,6 +271,16 @@ func TestParseDeployWikiHelpListsPurgeStalePolicy(t *testing.T) { } } +func TestParseDeployWikiResetManagedNamespacesFlag(t *testing.T) { + opts, err := parseDeployWikiArgs("deploy-wiki", []string{"--reset-managed-namespaces"}) + if err != nil { + t.Fatalf("parse deploy wiki reset flag: %v", err) + } + if !opts.ResetManagedNamespaces { + t.Fatalf("expected --reset-managed-namespaces to enable namespace reset") + } +} + func TestProjectConsoleSuppressesBuildModuleProgressInNormalMode(t *testing.T) { var stdout bytes.Buffer console := &projectConsole{ diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 31b0329..d73cc0c 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -33,21 +33,22 @@ const ( ) type DeployWikiOptions struct { - SourceDir string - Endpoint string - Token string - Version string - Namespaces []string - CategoryIDs map[string]int - ManifestPath string - DryRun bool - Force bool - AllowCreates bool - StalePolicy string - Username string - Password string - NotesDelimiter string - TitlePrefixMinLength int + SourceDir string + Endpoint string + Token string + Version string + Namespaces []string + CategoryIDs map[string]int + ManifestPath string + DryRun bool + Force bool + AllowCreates bool + ResetManagedNamespaces bool + StalePolicy string + Username string + Password string + NotesDelimiter string + TitlePrefixMinLength int } type deployResult struct { @@ -456,6 +457,23 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife remotePagesByCID := map[int][]nodeBBWikiPage{} var plans []wikiDeployPlan var result deployResult + if opts.ResetManagedNamespaces { + if !opts.AllowCreates && len(pageIDs) > 0 { + return nil, result, next, errors.New("wiki managed namespace reset requires --create so current generated pages can be recreated") + } + resetPlans, purged, err := planManagedNamespaceReset(opts, client) + if err != nil { + return nil, result, next, err + } + plans = append(plans, resetPlans...) + result.Purged += purged + manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}} + for _, cid := range opts.CategoryIDs { + if cid != 0 { + remotePagesByCID[cid] = []nodeBBWikiPage{} + } + } + } for _, pageID := range pageIDs { page := pages[pageID] @@ -615,6 +633,52 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife return plans, result, next, nil } +func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient) ([]wikiDeployPlan, int, error) { + namespaces := slices.Clone(opts.Namespaces) + if len(namespaces) == 0 { + for namespace := range opts.CategoryIDs { + namespaces = append(namespaces, namespace) + } + } + slices.Sort(namespaces) + + seenTIDs := map[int]struct{}{} + var plans []wikiDeployPlan + for _, namespace := range namespaces { + cid := opts.CategoryIDs[namespace] + if cid == 0 { + continue + } + remotePages, err := client.listNamespacePages(cid) + if err != nil { + return nil, 0, err + } + slices.SortFunc(remotePages, func(a, b nodeBBWikiPage) int { + return a.TID - b.TID + }) + for _, remotePage := range remotePages { + if remotePage.TID == 0 { + continue + } + if _, ok := seenTIDs[remotePage.TID]; ok { + continue + } + seenTIDs[remotePage.TID] = struct{}{} + title := strings.TrimSpace(remotePage.Title) + if title == "" { + title = strings.TrimSpace(remotePage.TitleLeaf) + } + pageID := fmt.Sprintf("%s:reset-topic-%d", namespace, remotePage.TID) + plans = append(plans, wikiDeployPlan{ + Page: wikiDeployPage{PageID: pageID, Title: title, Namespace: namespace}, + Entry: wikiDeployManifestPage{TID: remotePage.TID, CID: cid, Namespace: namespace, Title: title}, + Action: "purge", + }) + } + } + return plans, len(plans), nil +} + func findMappedRemotePage(entry wikiDeployManifestPage, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient) (nodeBBWikiPage, bool, error) { remotePages, ok := remotePagesByCID[entry.CID] if !ok { diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 469f61b..6e9ecc6 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -1575,6 +1575,134 @@ func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(t *tes } } +func TestDeployWikiResetManagedNamespacesPurgesRemotePagesBeforeCreatingFreshManifest(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +

Replacement

+

Generated replacement page

+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{ + Version: "nodebb-v1", + Pages: map[string]wikiDeployManifestPage{ + "skills:stale": {Hash: "old-hash", Title: "Replacement", Namespace: "skills", TID: 7, PID: 42, CID: 3}, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 7, "title": "Old Replacement", "titleLeaf": "Old Replacement", "slug": "7/replacement", "wikiPath": "/wiki/skills/replacement"}, + {"tid": 8, "title": "Manual Note", "titleLeaf": "Manual Note", "slug": "8/manual-note", "wikiPath": "/wiki/skills/manual-note"}, + }, + "hasMore": false, + }, + }) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7": + calls = append(calls, "purge:7") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/8": + calls = append(calls, "purge:8") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": + calls = append(calls, "create") + if strings.Join(calls, ",") != "purge:7,purge:8,create" { + t.Fatalf("expected namespace reset purges before create, got %#v", calls) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "tid": 11, + "mainPost": map[string]any{ + "pid": 99, + "tid": 11, + }, + }, + }) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + CategoryIDs: map[string]int{"skills": 3}, + AllowCreates: true, + ResetManagedNamespaces: true, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions reset-managed-namespaces failed: %v", err) + } + if result.Created != 1 || result.Purged != 2 || result.Stale != 0 { + t.Fatalf("expected one create and two namespace purges, got %#v", result) + } + if strings.Join(calls, ",") != "purge:7,purge:8,create" { + t.Fatalf("expected reset purges before create, got %#v", calls) + } + manifest := loadDeployManifest(manifestPath) + if _, ok := manifest.Pages["skills:stale"]; ok { + t.Fatalf("expected reset to remove previous manifest mapping") + } + entry := manifest.Pages["skills:replacement"] + if entry.TID != 11 || entry.PID != 99 || entry.CID != 3 { + t.Fatalf("expected fresh created mapping in manifest, got %#v", entry) + } +} + +func TestDeployWikiResetManagedNamespacesRequiresCreateForLocalPages(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +

Replacement

+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("reset without --create must fail before remote calls, got %s %s", r.Method, r.URL.String()) + })) + defer server.Close() + + _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + CategoryIDs: map[string]int{"skills": 3}, + ResetManagedNamespaces: true, + }, nil) + if err == nil || !strings.Contains(err.Error(), "requires --create") { + t.Fatalf("expected reset without create to fail clearly, got %v", err) + } +} + func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")