diff --git a/README.md b/README.md index 191b174..e58fa6d 100644 --- a/README.md +++ b/README.md @@ -407,13 +407,16 @@ sow-toolkit deploy-wiki --dry-run --namespace spells --category spells=42 When a deploy manifest is missing but matching wiki pages already exist in NodeBB, `deploy-wiki` checks the Westgate Wiki namespace directory API and -adopts those topic/post mappings before planning updates. `--create` is still -required for genuinely new pages that have no existing remote topic. If NodeBB -rejects a create because the clean wiki URL already exists, `deploy-wiki` -re-queries the namespace by page leaf, adopts the matching topic, and updates it -instead. Updates and stale-page archives acquire a Westgate Wiki edit lock -before saving, then send the returned `wikiEditLockToken` with the NodeBB post -edit request. Stale generated pages are reported by default. +adopts those topic/post mappings before planning updates. It does the same when +a cached manifest mapping points to a post NodeBB no longer has. Generated +public slugs are preferred during adoption before title-based compatibility +matching. `--create` is still required for genuinely new pages that have no +existing remote topic. If NodeBB rejects a create because the clean wiki URL +already exists, `deploy-wiki` re-queries the namespace by page leaf, adopts the +matching topic, and updates it instead. Updates and stale-page archives acquire +a Westgate Wiki edit lock before saving, then send the returned +`wikiEditLockToken` with the NodeBB post edit request. Stale generated pages are +reported by default. `--stale-policy archive` rewrites stale generated pages in place as archived managed content. `--stale-policy purge` permanently removes only stale generated topics already tracked in the wiki deploy manifest. Dry-run that policy before diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index ba78495..8fde49f 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -64,11 +64,12 @@ type deployResult struct { } type wikiDeployPage struct { - PageID string - Title string - Namespace string - Content string - Hash string + PageID string + Title string + PublicSlug string + Namespace string + Content string + Hash string } type wikiDeployManifest struct { @@ -330,11 +331,12 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe } content := string(raw) pages[pageID] = wikiDeployPage{ - PageID: pageID, - Title: extractPageTitle(content, pageID), - Namespace: namespace, - Content: content, - Hash: computeManagedHash(content), + PageID: pageID, + Title: extractPageTitle(content, pageID), + PublicSlug: extractWikiPagePublicSlug(content), + Namespace: namespace, + Content: content, + Hash: computeManagedHash(content), } return nil }); err != nil { @@ -415,7 +417,39 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife } remote, err := client.getPost(entry.PID) if err != nil { - return nil, result, next, err + if !isNodeBBMissingResource(err) { + return nil, result, next, err + } + entry.TID = 0 + entry.PID = 0 + if entry.CID != 0 { + adopted, ok, adoptErr := adoptExistingNodeBBPage(page, entry.CID, remotePagesByCID, client) + if adoptErr != nil { + return nil, result, next, adoptErr + } + if ok { + entry.TID = adopted.TID + entry.PID = adopted.PID + entry.CID = adopted.CID + } + } + next.Pages[pageID] = entry + if entry.PID == 0 { + if !opts.AllowCreates { + return nil, result, next, fmt.Errorf("wiki page %q has no manifest post mapping; pass --create and --category %s= to create it", pageID, page.Namespace) + } + if entry.CID == 0 { + return nil, result, next, fmt.Errorf("wiki page %q requires --category %s= for creation", pageID, page.Namespace) + } + result.Created++ + page.Title = entry.TopicTitle + plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "create", Content: page.Content}) + continue + } + remote, err = client.getPost(entry.PID) + if err != nil { + return nil, result, next, err + } } if entry.TID == 0 && remote.TID != 0 { entry.TID = remote.TID @@ -602,6 +636,15 @@ func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBW } func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) { + if page.PublicSlug != "" { + for _, candidate := range remotePages { + for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} { + if slugifyWikiTitle(slug) == page.PublicSlug { + return candidate, true + } + } + } + } desiredTitles := map[string]struct{}{} for _, title := range []string{page.Title, pageIDLeaf(page.PageID)} { if normalized := normalizeWikiTitle(title); normalized != "" { @@ -880,6 +923,32 @@ func renderArchivedWikiPage(pageID, title string) string { }, "\n")) } +func extractWikiPagePublicSlug(content string) string { + const pageMarker = "sow-topdata-wiki:page=" + const slugField = "wiki_slug=" + start := strings.Index(content, pageMarker) + if start < 0 { + return "" + } + end := strings.Index(content[start:], "-->") + if end < 0 { + return "" + } + marker := content[start : start+end] + fieldStart := strings.Index(marker, slugField) + if fieldStart < 0 { + return "" + } + value := marker[fieldStart+len(slugField):] + if fields := strings.Fields(value); len(fields) > 0 { + slug := strings.TrimSpace(fields[0]) + if slug != "" && slug == slugifyWikiTitle(slug) { + return slug + } + } + return "" +} + func extractPageTitle(content, fallback string) string { if title := extractMarkdownTitle(content); title != "" { return title @@ -979,6 +1048,11 @@ func (e nodeBBHTTPError) Error() string { return fmt.Sprintf("NodeBB %s %s failed: HTTP %d: %s", e.Method, e.Path, e.Status, e.Body) } +func isNodeBBMissingResource(err error) bool { + var httpErr nodeBBHTTPError + return errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) +} + type nodeBBPost struct { PID int TID int diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 0e39045..bcfeef1 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -76,6 +76,98 @@ func TestDeployWikiDryRunDoesNotWriteRemoteOrManifest(t *testing.T) { } } +func TestDeployWikiDryRunReadoptsMissingMappedPost(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "classes"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +

Acolyte

+

Generated acolyte page

+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "classes", "acolyte.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + old := ` + +

Acolyte

+

Old acolyte page

+ +` + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{ + Version: "nodebb-v1", + Pages: map[string]wikiDeployManifestPage{ + "classes:acolyte": {Hash: computeManagedHash(old), TID: 7, PID: 42, CID: 9}, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": map[string]any{"code": "not-found", "message": "Post does not exist"}, + "response": map[string]any{}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/9/pages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 77, "title": "Acolyte", "titleLeaf": "Acolyte", "slug": "77/acolyte", "wikiPath": "/wiki/classes/acolyte"}, + }, + "hasMore": false, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}}) + 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, + DryRun: true, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions dry-run re-adoption failed: %v", err) + } + if result.Updated != 1 { + t.Fatalf("expected one planned update after re-adoption, got %#v", result) + } +} + +func TestMatchExistingNodeBBPagePrefersGeneratedPublicSlug(t *testing.T) { + page := wikiDeployPage{ + PageID: "spells:pdk:fear", + Title: "Fear", + PublicSlug: "pdk-fear", + } + matched, ok := matchExistingNodeBBPage(page, []nodeBBWikiPage{ + {TID: 31, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/fear"}, + {TID: 32, Title: "Fear", TitleLeaf: "Fear", WikiPath: "/wiki/spells/pdk-fear"}, + }) + if !ok || matched.TID != 32 { + t.Fatalf("expected generated public slug to select tid 32, got %#v ok=%t", matched, ok) + } +} + func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")