Files
sow-tools/internal/topdata/wiki_deploy.go
T
archvillainetteandClaude Opus 5 9e52a62e66
ci / ci (pull_request) Successful in 3m52s
fix(wiki): purge through the plugin page actions and count reset deletions
deploy-wiki purged pages with DELETE /api/v3/topics/{tid}. A NodeBB
running nodebb-plugin-westgate-wiki refuses that for topics in wiki
categories, because wiki revision history is plugin-owned, so every
purge failed with HTTP 400 and the deploy exited 1. Purge now goes
through the plugin's own page actions: tombstone the page, then
hard-purge it, since a page must be tombstoned before it can be purged.

Archiving is unaffected: it rewrites the page through the ordinary post
edit the plugin allows, rather than deleting anything.

Every fake NodeBB in the deploy tests now goes through one constructor
that refuses native topic mutation the way the plugin does. The old
fakes answered the core API, which is why this shipped green.

A namespace reset can reach pages NodeBB will not delete at all - the
wiki home topic above all - so those are skipped rather than aborting
the reset. NodeBB answers 403 for that and for a token without purge
privileges alike, and the response cannot tell them apart; what can is
scope, so a category where nothing at all could be deleted still fails
the run.

Under --reset-managed-namespaces the preview reported stale: 0 on a run
that would delete every topic in the managed categories, because the
reset purge never went through stale computation. The reset deletions
are now counted as stale, which is the number callers word their
destructive-policy warning around, and the summary names how many of
them the manifest has no record of writing - the deletions a re-seed
cannot undo.

Closes #99
Closes #100

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 17:44:09 +02:00

1961 lines
59 KiB
Go

package topdata
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"maps"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
"gopkg.in/yaml.v3"
)
const (
managedStartMarker = "<!-- sow-topdata-wiki:managed:start"
managedEndMarker = "<!-- sow-topdata-wiki:managed:end -->"
legacyMarkdownStartMarker = "[//]: # (sow-topdata-wiki:managed:start)"
legacyMarkdownEndMarker = "[//]: # (sow-topdata-wiki:managed:end)"
legacyManagedStartMarker = "<!-- sow-topdata-wiki:managed:start -->"
legacyManagedEndMarker = "<!-- sow-topdata-wiki:managed:end -->"
manualStartMarkerPrefix = "<!-- sow-topdata-wiki:manual:start"
manualEndMarkerPrefix = "<!-- sow-topdata-wiki:manual:end"
)
type DeployWikiOptions struct {
SourceDir string
PageIndexPath 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 {
LocalPages int
Created int
Updated int
Stale int
Archived int
Purged int
Skipped int
Drifted int
Renamed int
Manifest string
// ResetPurged counts the deletions queued by --reset-managed-namespaces.
// They are also included in Stale and Purged, because callers warn about
// destructive policies in terms of the stale count: sow-topdata's
// deploy-wiki wrapper prints "of the pages counted as 'stale' below, they
// will be DELETED from NodeBB" directly above this block.
ResetPurged int
// ResetUnrecognized counts the subset of ResetPurged that the deploy
// manifest has no record of writing. Those deletions are the ones a
// re-seed cannot undo.
ResetUnrecognized int
// ResetSkipped counts reset targets NodeBB refused to delete, such as the
// wiki home topic, which the plugin excludes from tombstone and purge.
ResetSkipped int
}
type wikiDeployPage struct {
PageID string
Title string
PublicPath string
Namespace string
Content string
Hash string
}
type wikiDeployManifest struct {
Version string `json:"version"`
Pages map[string]wikiDeployManifestPage `json:"pages"`
}
type wikiDeployManifestPage struct {
Hash string `json:"hash"`
LastSeenHash string `json:"last_seen_hash,omitempty"`
ArchivedHash string `json:"archived_hash,omitempty"`
Title string `json:"title,omitempty"`
TopicTitle string `json:"topic_title,omitempty"`
Namespace string `json:"namespace,omitempty"`
Stale bool `json:"stale,omitempty"`
SourceContentSynced bool `json:"source_content_synced,omitempty"`
TID int `json:"tid,omitempty"`
PID int `json:"pid,omitempty"`
CID int `json:"cid,omitempty"`
}
type wikiDeployPlan struct {
Page wikiDeployPage
Entry wikiDeployManifestPage
Action string
Content string
RemoteHash string
Title string
// Reset marks a purge queued by --reset-managed-namespaces rather than by
// stale computation over the manifest.
Reset bool
// Unrecognized marks a reset purge whose topic the manifest has no record
// of writing.
Unrecognized bool
}
type wikiNamespacesDocument struct {
Namespaces []wikiNamespaceDeclaration `json:"namespaces" yaml:"namespaces"`
}
type wikiNamespaceDeclaration struct {
ID string `json:"id" yaml:"id"`
Title string `json:"title" yaml:"title"`
CanonicalPath string `json:"canonical_path" yaml:"canonical_path"`
CategoryEnv string `json:"category_env" yaml:"category_env"`
PageTitle string `json:"page_title" yaml:"page_title"`
Tags []string `json:"tags" yaml:"tags"`
EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
}
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (DeployResult, error) {
if progress == nil {
progress = func(string) {}
}
sourceDirWasDefaulted := opts.SourceDir == ""
if sourceDirWasDefaulted {
opts.SourceDir = wikiOutputPagesDir(p)
}
if opts.PageIndexPath == "" {
if sourceDirWasDefaulted {
opts.PageIndexPath = p.TopDataWikiPageIndexPath()
} else {
opts.PageIndexPath = filepath.Join(filepath.Dir(opts.SourceDir), p.EffectiveConfig().TopData.Wiki.PageIndexFile)
}
}
if _, err := os.Stat(opts.SourceDir); err != nil {
return DeployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
}
if opts.Endpoint == "" {
return DeployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
}
if opts.Token == "" {
return DeployResult{}, errors.New("NODEBB_API_TOKEN is required")
}
if opts.Username != "" || opts.Password != "" || opts.NotesDelimiter != "" {
return DeployResult{}, errors.New("DokuWiki deployment options are no longer supported; use NodeBB endpoint, token, and category mappings")
}
if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" && opts.StalePolicy != "purge" {
return DeployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy)
}
if opts.TitlePrefixMinLength <= 0 {
opts.TitlePrefixMinLength = p.EffectiveConfig().TopData.Wiki.TitlePrefixMinLength
}
namespaces := opts.Namespaces
if len(namespaces) == 0 {
declarations, err := loadWikiNamespaceDeclarations(p)
if err != nil {
return DeployResult{}, err
}
for _, declaration := range declarations {
namespaces = append(namespaces, declaration.ID)
}
if len(namespaces) == 0 {
namespaces = p.EffectiveConfig().TopData.Wiki.ManagedNamespaces
}
envCategories, err := categoryIDsFromNamespaceEnv(declarations)
if err != nil {
return DeployResult{}, err
}
if len(envCategories) > 0 {
merged := envCategories
for namespace, cid := range opts.CategoryIDs {
merged[namespace] = cid
}
opts.CategoryIDs = merged
}
}
manifestPath := opts.ManifestPath
if manifestPath == "" {
manifestPath = filepath.Join(filepath.Dir(opts.SourceDir), p.EffectiveConfig().TopData.Wiki.DeployManifest)
}
progress(fmt.Sprintf("Collecting local wiki pages from %s", opts.SourceDir))
pages, err := collectLocalPages(opts.SourceDir, opts.PageIndexPath, namespaces)
if err != nil {
return DeployResult{}, err
}
progress(fmt.Sprintf("Loaded %d local wiki page(s)", len(pages)))
manifest := loadDeployManifest(manifestPath)
progress(fmt.Sprintf("Loaded wiki deploy manifest %s with %d page mapping(s)", manifestPath, len(manifest.Pages)))
client := newNodeBBClient(opts.Endpoint, opts.Token)
progress(fmt.Sprintf("Planning NodeBB wiki deploy for %d local page(s)", len(pages)))
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client, progress)
if err != nil {
return DeployResult{}, err
}
result.LocalPages = len(pages)
result.Manifest = manifestPath
progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, rename %d, skip %d, stale %d, archive %d, purge %d, drift %d", result.Created, result.Updated, result.Renamed, result.Skipped, result.Stale, result.Archived, result.Purged, result.Drifted))
if opts.DryRun {
return result, nil
}
if result.Drifted > 0 && !opts.Force {
return result, errors.New("remote managed wiki content drifted; rerun with --force to overwrite")
}
summary := p.EffectiveConfig().TopData.Wiki.DeployEditSummary
if opts.Version != "" {
summary = fmt.Sprintf("%s (%s)", summary, opts.Version)
}
orderedPlans := orderNodeBBDeployPlans(plans)
progress(fmt.Sprintf("Executing NodeBB wiki actions: total %d, create %d, update %d, rename %d, archive %d, purge %d", len(orderedPlans), result.Created, result.Updated, result.Renamed, result.Archived, result.Purged))
resetPurgedByCID := map[int]int{}
resetSkippedByCID := map[int]int{}
for i, plan := range orderedPlans {
if shouldReportNodeBBDeployActionProgress(i, len(orderedPlans)) {
progress(fmt.Sprintf("Executing NodeBB wiki action %d/%d: %s %s", i+1, len(orderedPlans), plan.Action, plan.Page.PageID))
}
switch plan.Action {
case "create":
created, err := client.createTopic(plan.Entry.CID, plan.Page.Title, plan.Content, summary)
if err != nil {
if isNodeBBWikiPageCollision(err) {
recovered, recoverErr := recoverCreateCollision(plan, nextManifest, client, summary)
if recoverErr != nil {
return result, fmt.Errorf("deploy wiki page %q: recover existing NodeBB page after create collision: %w", plan.Page.PageID, recoverErr)
}
if recovered {
result.Created--
result.Updated++
continue
}
}
return result, fmt.Errorf("deploy wiki page %q: create NodeBB topic %q in category %d: %w", plan.Page.PageID, plan.Page.Title, plan.Entry.CID, err)
}
entry := nextManifest.Pages[plan.Page.PageID]
entry.TID = created.TID
entry.PID = created.PID
nextManifest.Pages[plan.Page.PageID] = entry
case "update":
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
return result, fmt.Errorf("deploy wiki page %q: update NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
}
case "archive":
// Archiving rewrites the page rather than removing it, so it goes
// through the ordinary post edit the wiki plugin allows; only
// delete, restore, and purge are reserved to the page actions.
if err := client.updatePost(plan.Entry.TID, plan.Entry.PID, plan.Content, summary); err != nil {
return result, fmt.Errorf("deploy wiki page %q: archive NodeBB post %d: %w", plan.Page.PageID, plan.Entry.PID, err)
}
case "purge":
if err := client.purgeWikiPage(plan.Entry.TID); err != nil {
// A namespace reset sweeps every topic in the category, so it
// can reach pages NodeBB will not delete at all — the wiki home
// topic above all. Skip those rather than abandoning the reset.
if plan.Reset && isNodeBBWikiPageUndeletable(err) {
result.ResetSkipped++
result.ResetPurged--
result.Purged--
result.Stale--
if plan.Unrecognized {
result.ResetUnrecognized--
}
resetSkippedByCID[plan.Entry.CID]++
progress(fmt.Sprintf("NodeBB refused to delete wiki topic %d during managed namespace reset; skipping it", plan.Entry.TID))
continue
}
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
}
if plan.Reset {
resetPurgedByCID[plan.Entry.CID]++
}
case "rename":
if err := client.renameWikiPage(plan.Entry.TID, plan.Entry.CID, plan.Title); err != nil {
return result, fmt.Errorf("deploy wiki page %q: rename NodeBB topic %d to %q: %w", plan.Page.PageID, plan.Entry.TID, plan.Title, err)
}
}
}
// NodeBB answers 403 both for the pages it will never delete — the wiki
// home topic — and for a token without purge privileges, and the two are
// not distinguishable from the response. What tells them apart is scope: a
// category where nothing at all could be deleted is a privilege problem,
// not a home page. Reporting that as a completed reset would leave the
// manifest claiming a fresh start over pages that are all still there, so
// the next deploy would recreate every one of them as a duplicate.
for _, cid := range slices.Sorted(maps.Keys(resetSkippedByCID)) {
if resetPurgedByCID[cid] == 0 {
return result, fmt.Errorf("NodeBB refused every managed namespace reset deletion in category %d (%d topic(s)); check that the deploy token has wiki purge privileges there", cid, resetSkippedByCID[cid])
}
}
if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
return result, err
}
return result, nil
}
func shouldReportNodeBBDeployActionProgress(index, total int) bool {
if total <= 0 {
return false
}
position := index + 1
return position == 1 || position == total || position%100 == 0
}
func orderNodeBBDeployPlans(plans []wikiDeployPlan) []wikiDeployPlan {
if len(plans) <= 1 {
return plans
}
ordered := make([]wikiDeployPlan, 0, len(plans))
for _, plan := range plans {
if plan.Action == "purge" {
ordered = append(ordered, plan)
}
}
for _, plan := range plans {
if plan.Action != "purge" {
ordered = append(ordered, plan)
}
}
return ordered
}
func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclaration, error) {
cfg := p.EffectiveConfig().TopData.Wiki
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.NamespacesFile))
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read wiki namespaces: %w", err)
}
var doc wikiNamespacesDocument
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("parse wiki namespaces: %w", err)
}
seen := map[string]struct{}{}
for _, declaration := range doc.Namespaces {
id := strings.TrimSpace(declaration.ID)
if id == "" {
return nil, errors.New("topdata wiki namespace id is required")
}
if _, ok := seen[id]; ok {
return nil, fmt.Errorf("duplicate topdata wiki namespace id %q", id)
}
seen[id] = struct{}{}
if strings.TrimSpace(declaration.CategoryEnv) == "" {
return nil, fmt.Errorf("topdata wiki namespace %q category_env is required", id)
}
if err := validateWikiNamespaceDeclarationPath(declaration); err != nil {
return nil, err
}
switch strings.TrimSpace(declaration.EditPolicy) {
case "", "preserve_manual_sections", "generated_only":
default:
return nil, fmt.Errorf("topdata wiki namespace %q edit_policy %q is not supported", id, declaration.EditPolicy)
}
}
return doc.Namespaces, nil
}
func validateWikiNamespaceDeclarationPath(declaration wikiNamespaceDeclaration) error {
id := strings.TrimSpace(declaration.ID)
title := strings.TrimSpace(declaration.Title)
canonicalTitle := ""
if title != "" {
canonicalTitle = canonicalWikiSegment(title)
if canonicalTitle == "" {
return fmt.Errorf("topdata wiki namespace %q title %q does not produce a valid canonical segment", id, title)
}
}
path := strings.TrimSpace(declaration.CanonicalPath)
if path == "" {
if canonicalTitle != "" {
path = canonicalTitle
} else {
path = canonicalWikiSegment(namespaceTitle(id))
}
if path == "" {
return fmt.Errorf("topdata wiki namespace %q does not produce a valid canonical namespace path", id)
}
if isReservedCanonicalWikiFirstSegment(path) {
return fmt.Errorf("topdata wiki namespace %q title %q uses reserved first segment %q", id, title, path)
}
return nil
}
canonicalPath := canonicalWikiSlashPath(path)
if canonicalPath == "" {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q does not produce a valid canonical namespace path", id, path)
}
if canonicalPath != path {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q must already be canonical as %q", id, path, canonicalPath)
}
if isReservedCanonicalWikiFirstSegment(path) {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q uses reserved first segment", id, path)
}
return nil
}
func categoryIDsFromNamespaceEnv(declarations []wikiNamespaceDeclaration) (map[string]int, error) {
categories := map[string]int{}
for _, declaration := range declarations {
envName := strings.TrimSpace(declaration.CategoryEnv)
if envName == "" {
continue
}
raw := strings.TrimSpace(os.Getenv(envName))
if raw == "" {
continue
}
cid, err := strconv.Atoi(raw)
if err != nil || cid <= 0 {
return nil, fmt.Errorf("topdata wiki namespace %q env %s has invalid cid %q", declaration.ID, envName, raw)
}
categories[declaration.ID] = cid
}
return categories, nil
}
func collectLocalPages(sourceDir, pageIndexPath string, namespaces []string) (map[string]wikiDeployPage, error) {
namespaceSet := map[string]struct{}{}
for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{}
}
pageIndex, err := loadDeployPageIndex(sourceDir, pageIndexPath)
if err != nil {
return nil, err
}
pages := map[string]wikiDeployPage{}
for _, ns := range namespaces {
nsDir := filepath.Join(sourceDir, ns)
if !isDir(nsDir) {
continue
}
if err := filepath.WalkDir(nsDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
ext := filepath.Ext(path)
if d.IsDir() || (ext != ".md" && ext != ".html") {
return nil
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
pageID := strings.TrimSuffix(filepath.ToSlash(rel), ext)
pageID = strings.ReplaceAll(pageID, "/", ":")
namespace := strings.SplitN(pageID, ":", 2)[0]
if _, ok := namespaceSet[namespace]; !ok {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
content := string(raw)
if markerPageID := extractWikiPageID(content); markerPageID != "" {
pageID = markerPageID
namespace = strings.SplitN(pageID, ":", 2)[0]
if _, ok := namespaceSet[namespace]; !ok {
return nil
}
}
indexEntry := pageIndex[pageID]
publicPath := extractWikiPagePublicPath(content)
if publicPath == "" {
publicPath = indexEntry.PublicPath
}
title := strings.TrimSpace(indexEntry.Title)
if title == "" {
title = extractPageTitle(content, pageID)
}
pages[pageID] = wikiDeployPage{
PageID: pageID,
Title: title,
PublicPath: publicPath,
Namespace: namespace,
Content: content,
Hash: computeManagedHash(content),
}
return nil
}); err != nil {
return nil, err
}
}
return pages, nil
}
func loadDeployPageIndex(sourceDir, pageIndexPath string) (map[string]wikiPageIndexEntry, error) {
path := strings.TrimSpace(pageIndexPath)
if path == "" {
path = filepath.Join(filepath.Dir(sourceDir), "page-index.json")
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]wikiPageIndexEntry{}, nil
}
return nil, fmt.Errorf("read wiki page index: %w", err)
}
var index wikiPageIndex
if err := json.Unmarshal(raw, &index); err != nil {
return nil, fmt.Errorf("parse wiki page index: %w", err)
}
byPageID := map[string]wikiPageIndexEntry{}
for _, entry := range index.Pages {
if strings.TrimSpace(entry.PageID) != "" {
byPageID[entry.PageID] = entry
}
}
return byPageID, nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManifest, opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, DeployResult, wikiDeployManifest, error) {
if progress == nil {
progress = func(string) {}
}
pageIDs := sortedKeysFromMap(pages)
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
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, unrecognized, err := planManagedNamespaceReset(opts, manifest, client, progress)
if err != nil {
return nil, result, next, err
}
plans = append(plans, resetPlans...)
result.Purged += purged
// A namespace reset deletes remote pages the same way stale purge does,
// so it is counted as stale: that is the number callers word their
// destructive-policy warning around.
result.Stale += purged
result.ResetPurged = purged
result.ResetUnrecognized = unrecognized
manifest = wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
for _, cid := range opts.CategoryIDs {
if cid != 0 {
remotePagesByCID[cid] = []nodeBBWikiPage{}
}
}
}
for i, pageID := range pageIDs {
if i == 0 || (i+1)%100 == 0 || i+1 == len(pageIDs) {
progress(fmt.Sprintf("Planning NodeBB wiki page %d/%d: %s", i+1, len(pageIDs), pageID))
}
page := pages[pageID]
entry := manifest.Pages[pageID]
entry.Hash = page.Hash
entry.LastSeenHash = page.Hash
entry.Title = page.Title
entry.TopicTitle = nodeBBTopicTitle(page, opts.TitlePrefixMinLength)
entry.Namespace = page.Namespace
entry.Stale = false
if entry.CID == 0 {
entry.CID = opts.CategoryIDs[page.Namespace]
}
next.Pages[pageID] = entry
if entry.PID == 0 {
if entry.CID != 0 {
adopted, ok, err := adoptExistingNodeBBPage(page, entry.CID, remotePagesByCID, client, progress)
if err != nil {
return nil, result, next, err
}
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=<cid> to create it", pageID, page.Namespace)
}
if entry.CID == 0 {
return nil, result, next, fmt.Errorf("wiki page %q requires --category %s=<cid> for creation", pageID, page.Namespace)
}
result.Created++
page.Title = entry.TopicTitle
entry.SourceContentSynced = true
next.Pages[pageID] = entry
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "create", Content: page.Content})
continue
}
if entry.TID != 0 && entry.CID != 0 && shouldCheckRemoteTopicTitle(manifest.Pages[pageID], page.Title, entry.TopicTitle, opts.TitlePrefixMinLength) {
remoteTopic, ok, err := findMappedRemoteTopic(entry, client)
if err != nil {
return nil, result, next, err
}
if ok && titleNeedsRename(remoteTopic.Title, entry.TopicTitle) {
result.Renamed++
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "rename", Title: entry.TopicTitle})
}
}
needsSourceContentSync := !manifest.Pages[pageID].SourceContentSynced
if manifest.Pages[pageID].Hash == page.Hash && !needsSourceContentSync {
result.Skipped++
continue
}
remote, err := client.getPost(entry.PID)
if err != nil {
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, progress)
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=<cid> to create it", pageID, page.Namespace)
}
if entry.CID == 0 {
return nil, result, next, fmt.Errorf("wiki page %q requires --category %s=<cid> for creation", pageID, page.Namespace)
}
result.Created++
page.Title = entry.TopicTitle
entry.SourceContentSynced = true
next.Pages[pageID] = entry
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
next.Pages[pageID] = entry
}
remoteHash := computeManagedHash(remote.Content)
if manifest.Pages[pageID].Hash != "" && remoteHash != manifest.Pages[pageID].Hash {
result.Drifted++
if !opts.Force {
continue
}
}
merged := mergeManagedContent(remote.Content, page.Content)
if merged == remote.Content && !needsSourceContentSync {
result.Skipped++
continue
}
result.Updated++
entry.SourceContentSynced = true
next.Pages[pageID] = entry
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "update", Content: merged, RemoteHash: remoteHash})
}
for pageID := range manifest.Pages {
if _, ok := pages[pageID]; !ok {
entry := manifest.Pages[pageID]
entry.Stale = true
if entry.Namespace == "" {
entry.Namespace = strings.SplitN(pageID, ":", 2)[0]
}
if entry.Title == "" {
entry.Title = extractHTMLTitle("", pageID)
}
next.Pages[pageID] = entry
result.Stale++
policy := strings.TrimSpace(opts.StalePolicy)
if policy == "" {
policy = "report"
}
switch policy {
case "archive":
body := renderArchivedWikiPage(pageID, entry.Title)
entry.Hash = computeManagedHash(body)
entry.ArchivedHash = entry.Hash
entry.SourceContentSynced = true
next.Pages[pageID] = entry
result.Archived++
plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: entry.Title, Namespace: entry.Namespace, Content: body, Hash: entry.Hash},
Entry: entry,
Action: "archive",
Content: body,
})
case "purge":
if entry.TID == 0 {
return nil, result, next, fmt.Errorf("stale generated wiki page %q cannot be purged without tracked NodeBB topic id", pageID)
}
result.Purged++
delete(next.Pages, pageID)
plans = append(plans, wikiDeployPlan{
Page: wikiDeployPage{PageID: pageID, Title: entry.Title, Namespace: entry.Namespace},
Entry: entry,
Action: "purge",
})
}
}
}
return plans, result, next, nil
}
// planManagedNamespaceReset queues a purge for every topic in each managed
// category, not only the ones the manifest says we wrote. The second return is
// the plan count; the third is how many of those topics the manifest has no
// record of, which is the subset a re-seed cannot put back.
func planManagedNamespaceReset(opts DeployWikiOptions, manifest wikiDeployManifest, client *nodeBBClient, progress func(string)) ([]wikiDeployPlan, int, int, error) {
namespaces := slices.Clone(opts.Namespaces)
if len(namespaces) == 0 {
for namespace := range opts.CategoryIDs {
namespaces = append(namespaces, namespace)
}
}
slices.Sort(namespaces)
manifestTIDs := map[int]struct{}{}
for _, entry := range manifest.Pages {
if entry.TID != 0 {
manifestTIDs[entry.TID] = struct{}{}
}
}
seenTIDs := map[int]struct{}{}
unrecognized := 0
var plans []wikiDeployPlan
for _, namespace := range namespaces {
cid := opts.CategoryIDs[namespace]
if cid == 0 {
continue
}
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
remotePages, err := client.listNamespacePages(cid)
if err != nil {
return nil, 0, 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{}{}
_, known := manifestTIDs[remotePage.TID]
if !known {
unrecognized++
}
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",
Reset: true,
Unrecognized: !known,
})
}
}
return plans, len(plans), unrecognized, nil
}
func findMappedRemoteTopic(entry wikiDeployManifestPage, client *nodeBBClient) (nodeBBPost, bool, error) {
topic, err := client.getTopic(entry.TID)
if err != nil {
if isNodeBBMissingResource(err) {
return nodeBBPost{}, false, nil
}
return nodeBBPost{}, false, err
}
return topic, true, nil
}
func titleNeedsRename(current, desired string) bool {
current = strings.TrimSpace(current)
desired = strings.TrimSpace(desired)
return current != "" && desired != "" && current != desired
}
func shouldCheckRemoteTopicTitle(previous wikiDeployManifestPage, pageTitle, desiredTopicTitle string, minLength int) bool {
if strings.TrimSpace(previous.TopicTitle) != "" {
return strings.TrimSpace(previous.TopicTitle) != strings.TrimSpace(desiredTopicTitle)
}
titleLen := len([]rune(strings.TrimSpace(pageTitle)))
if titleLen == 0 {
return false
}
const legacyPrefixMinLength = 5
return titleLen < legacyPrefixMinLength || titleLen < minLength
}
func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, client *nodeBBClient, summary string) (bool, error) {
entry, ok, err := findExistingNodeBBPage(plan.Page, plan.Entry.CID, client)
if err != nil {
return false, err
}
if !ok {
// Search-based lookup failed. NodeBB confirmed the collision, so the page definitely
// exists — fall back to listing the category and matching by title.
entry, ok, err = findCollisionPageByTitle(plan.Page, plan.Entry.CID, client)
if err != nil || !ok {
return false, err
}
}
remote, err := client.getPost(entry.PID)
if err != nil {
return false, err
}
if entry.TID == 0 && remote.TID != 0 {
entry.TID = remote.TID
}
content := mergeManagedContent(remote.Content, plan.Content)
if err := client.updatePost(entry.TID, entry.PID, content, summary); err != nil {
return false, err
}
entry.Hash = plan.Entry.Hash
entry.LastSeenHash = plan.Entry.LastSeenHash
entry.Title = plan.Entry.Title
entry.TopicTitle = plan.Entry.TopicTitle
entry.Namespace = plan.Entry.Namespace
entry.Stale = false
entry.SourceContentSynced = true
manifest.Pages[plan.Page.PageID] = entry
return true, nil
}
func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient, progress func(string)) (wikiDeployManifestPage, bool, error) {
remotePages, ok := remotePagesByCID[cid]
if !ok {
var err error
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d", cid))
remotePages, err = client.listNamespacePages(cid)
if err != nil {
return wikiDeployManifestPage{}, false, err
}
remotePagesByCID[cid] = remotePages
}
remotePage, ok := matchExistingNodeBBPage(page, remotePages)
if !ok || remotePage.TID == 0 {
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
// For nested-path pages: nestedCanonicalAdoptionFallbackError only examined pages
// without WikiPath/CanonicalPath. Pages that have those fields set but whose stored
// path doesn't normalize to match the local PublicPath (e.g. different slug format)
// still need to be adopted. Try an unambiguous title match across all remote pages.
if isNestedCanonicalWikiPath(page.PublicPath) {
if remotePage, ok := matchByTitle(page, remotePages); ok && remotePage.TID != 0 {
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
}
return wikiDeployManifestPage{}, false, nil
}
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient) (wikiDeployManifestPage, bool, error) {
queries := uniqueNodeBBPageSearchQueries(
pageIDLeaf(page.PageID),
page.Title,
canonicalWikiSegment(page.Title),
nodeBBPathLeaf(page.PublicPath),
pageIDRemainder(page.PageID),
)
for _, query := range queries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
remotePages, err := client.searchNamespacePages(cid, query)
if err != nil {
return wikiDeployManifestPage{}, false, err
}
remotePage, ok := matchExistingNodeBBPage(page, remotePages)
if ok && remotePage.TID != 0 {
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
}
return wikiDeployManifestPage{}, false, nil
}
func findCollisionPageByTitle(page wikiDeployPage, cid int, client *nodeBBClient) (wikiDeployManifestPage, bool, error) {
remotePages, err := client.listNamespacePages(cid)
if err != nil {
return wikiDeployManifestPage{}, false, err
}
remotePage, ok := matchByTitle(page, remotePages)
if !ok || remotePage.TID == 0 {
return wikiDeployManifestPage{}, false, nil
}
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
func matchByTitle(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) {
desiredTitle := normalizeWikiTitle(page.Title)
if desiredTitle == "" {
return nodeBBWikiPage{}, false
}
var match *nodeBBWikiPage
for i, candidate := range remotePages {
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if normalizeWikiTitle(title) == desiredTitle {
if match != nil {
return nodeBBWikiPage{}, false // ambiguous — refuse to guess
}
cp := remotePages[i]
match = &cp
break
}
}
}
if match == nil {
return nodeBBWikiPage{}, false
}
return *match, true
}
func uniqueNodeBBPageSearchQueries(values ...string) []string {
queries := []string{}
seen := map[string]struct{}{}
for _, value := range values {
query := strings.TrimSpace(value)
if query == "" {
continue
}
key := strings.ToLower(query)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
queries = append(queries, query)
}
return queries
}
func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBWikiPage, client *nodeBBClient) (wikiDeployManifestPage, bool, error) {
topic, err := client.getTopic(remotePage.TID)
if err != nil {
return wikiDeployManifestPage{}, false, err
}
if topic.PID == 0 {
return wikiDeployManifestPage{}, false, fmt.Errorf("NodeBB topic %d did not include main post id for wiki page %q", remotePage.TID, page.PageID)
}
if topic.TID == 0 {
topic.TID = remotePage.TID
}
return wikiDeployManifestPage{TID: topic.TID, PID: topic.PID, CID: cid}, true, nil
}
func nestedCanonicalAdoptionFallbackError(page wikiDeployPage, remotePages []nodeBBWikiPage) error {
if !isNestedCanonicalWikiPath(page.PublicPath) {
return nil
}
targetLeaf := nodeBBPathLeaf(normalizeWikiPath(page.PublicPath))
desiredTitle := normalizeWikiTitle(page.Title)
for _, candidate := range remotePages {
if strings.TrimSpace(candidate.WikiPath) != "" || strings.TrimSpace(candidate.CanonicalPath) != "" {
continue
}
if targetLeaf != "" && !onlyHasRetiredGeneratedWikiPaths(page, candidate) {
for _, path := range []string{candidate.Slug} {
if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
}
}
}
if desiredTitle != "" {
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if normalizeWikiTitle(title) == desiredTitle {
return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
}
}
}
}
return nil
}
func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) {
targetPath := normalizeWikiPath(page.PublicPath)
requiresExactPath := isNestedCanonicalWikiPath(page.PublicPath)
if page.PublicPath != "" {
for _, candidate := range remotePages {
paths := []string{candidate.WikiPath, candidate.CanonicalPath}
if !requiresExactPath {
paths = append(paths, candidate.Slug)
}
for _, path := range paths {
if normalizeWikiPath(path) == targetPath {
return candidate, true
}
}
}
}
if requiresExactPath {
return nodeBBWikiPage{}, false
}
targetLeaf := nodeBBPathLeaf(targetPath)
if targetLeaf != "" {
matches := []nodeBBWikiPage{}
for _, candidate := range remotePages {
if strings.TrimSpace(candidate.WikiPath) != "" {
continue
}
for _, path := range []string{candidate.WikiPath, candidate.Slug} {
if isRetiredGeneratedWikiPath(page, path) {
continue
}
if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
matches = append(matches, candidate)
break
}
}
}
if len(matches) == 1 {
return matches[0], true
}
}
desiredTitles := map[string]struct{}{}
for _, title := range []string{page.Title} {
if normalized := normalizeWikiTitle(title); normalized != "" {
desiredTitles[normalized] = struct{}{}
}
}
matches := []nodeBBWikiPage{}
for _, candidate := range remotePages {
if targetPath != "" && strings.TrimSpace(candidate.WikiPath) != "" {
continue
}
if onlyHasRetiredGeneratedWikiPaths(page, candidate) {
continue
}
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if _, ok := desiredTitles[normalizeWikiTitle(title)]; ok {
matches = append(matches, candidate)
break
}
}
}
if len(matches) == 1 {
return matches[0], true
}
return nodeBBWikiPage{}, false
}
func onlyHasRetiredGeneratedWikiPaths(page wikiDeployPage, candidate nodeBBWikiPage) bool {
seenPath := false
for _, path := range []string{candidate.WikiPath, candidate.Slug} {
if strings.TrimSpace(path) == "" {
continue
}
seenPath = true
if !isRetiredGeneratedWikiPath(page, path) {
return false
}
}
return seenPath
}
func isRetiredGeneratedWikiPath(page wikiDeployPage, path string) bool {
retired := retiredGeneratedWikiPathLeaf(page.PageID)
if retired == "" {
return false
}
return strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == retired
}
func retiredGeneratedWikiPathLeaf(pageID string) string {
remainder := pageIDRemainder(pageID)
leaf := pageIDLeaf(pageID)
if remainder == "" || remainder == leaf {
return ""
}
return strings.ToLower(canonicalWikiSegment(strings.ReplaceAll(remainder, ":", "-")))
}
func pageIDRemainder(pageID string) string {
_, rest, ok := strings.Cut(pageID, ":")
if !ok {
return pageID
}
return rest
}
func pageIDLeaf(pageID string) string {
pageID = strings.TrimSpace(pageID)
if pageID == "" {
return ""
}
parts := strings.FieldsFunc(pageID, func(r rune) bool {
return r == ':' || r == '/'
})
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func nodeBBPathLeaf(value string) string {
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
return r == '/'
})
if len(parts) == 0 {
return ""
}
return parts[len(parts)-1]
}
func normalizeWikiTitle(value string) string {
return strings.Join(strings.Fields(strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "_", " "))), " ")
}
func slugifyWikiTitle(value string) string {
return slugifyWikiText(value, "")
}
func normalizeWikiPath(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "/wiki/")
value = strings.TrimPrefix(value, "wiki/")
parts := []string{}
for _, part := range strings.Split(value, "/") {
canonical := canonicalWikiSegment(part)
if canonical != "" {
parts = append(parts, strings.ToLower(canonical))
}
}
return strings.Join(parts, "/")
}
func loadDeployManifest(path string) wikiDeployManifest {
doc := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
raw, err := os.ReadFile(path)
if err != nil {
return doc
}
if err := json.Unmarshal(raw, &doc); err != nil {
return wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
}
if doc.Pages == nil {
doc.Pages = map[string]wikiDeployManifestPage{}
}
return doc
}
func saveDeployManifest(path string, manifest wikiDeployManifest) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(raw, '\n'), 0o644)
}
func computeManagedHash(content string) string {
managed, ok := extractManagedRegion(content)
if !ok {
managed = content
}
managed = normalizePreservedSectionBodies(managed)
h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
return fmt.Sprintf("%x", h[:])
}
func extractManagedRegion(content string) (string, bool) {
startMarker, _, start, end := managedRegionBounds(content)
if start < 0 || end < 0 || end < start {
return "", false
}
start += len(startMarker)
return strings.TrimSpace(content[start:end]), true
}
func mergeManagedContent(existing, generated string) string {
generatedRegion, ok := extractManagedRegion(generated)
if !ok {
return generated
}
startMarker, endMarker, start, end := managedRegionBounds(existing)
if start < 0 || end < 0 || end < start {
return generated
}
if strings.Contains(generated, manualStartMarkerPrefix) {
return mergeManualRegions(generated, existing)
}
end += len(endMarker)
generatedStart, generatedEnd, ok := extractManagedMarkers(generated)
if !ok {
generatedStart = startMarker
generatedEnd = endMarker
}
merged := existing[:start] + generatedStart + "\n" + generatedRegion + "\n" + generatedEnd + existing[end:]
return mergeManualRegions(merged, existing)
}
func managedRegionBounds(content string) (string, string, int, int) {
if start := strings.Index(content, managedStartMarker); start >= 0 {
startEnd := strings.Index(content[start:], "-->")
end := strings.Index(content, managedEndMarker)
if startEnd >= 0 && end >= 0 {
startMarker := content[start : start+startEnd+len("-->")]
return startMarker, managedEndMarker, start, end
}
}
for _, markers := range [][2]string{
{legacyMarkdownStartMarker, legacyMarkdownEndMarker},
{legacyManagedStartMarker, legacyManagedEndMarker},
} {
start := strings.Index(content, markers[0])
end := strings.Index(content, markers[1])
if start >= 0 && end >= 0 {
return markers[0], markers[1], start, end
}
}
return "", "", -1, -1
}
func extractManagedMarkers(content string) (string, string, bool) {
startMarker, endMarker, start, end := managedRegionBounds(content)
return startMarker, endMarker, start >= 0 && end >= 0
}
func mergeManualRegions(merged, existing string) string {
existingManual := extractManualRegions(existing)
if len(existingManual) == 0 {
return merged
}
return replaceManualRegions(merged, existingManual)
}
func extractManualRegions(content string) map[string]string {
regions := map[string]string{}
offset := 0
for {
startRel := strings.Index(content[offset:], manualStartMarkerPrefix)
if startRel < 0 {
break
}
start := offset + startRel
startEndRel := strings.Index(content[start:], "-->")
if startEndRel < 0 {
break
}
startMarker := content[start : start+startEndRel+len("-->")]
id := markerID(startMarker)
if id == "" {
offset = start + len(startMarker)
continue
}
endPrefix := manualEndMarkerPrefix + " id=\"" + id + "\""
end := strings.Index(content[start+len(startMarker):], endPrefix)
if end < 0 {
offset = start + len(startMarker)
continue
}
end += start + len(startMarker)
endEndRel := strings.Index(content[end:], "-->")
if endEndRel < 0 {
break
}
endMarker := content[end : end+endEndRel+len("-->")]
regions[id] = startMarker + strings.TrimRight(content[start+len(startMarker):end], "\n") + "\n" + endMarker
offset = end + len(endMarker)
}
return regions
}
func replaceManualRegions(content string, replacements map[string]string) string {
for id, replacement := range replacements {
start := strings.Index(content, manualStartMarkerPrefix+" id=\""+id+"\"")
if start < 0 {
continue
}
endPrefix := manualEndMarkerPrefix + " id=\"" + id + "\""
end := strings.Index(content[start:], endPrefix)
if end < 0 {
continue
}
end += start
endEndRel := strings.Index(content[end:], "-->")
if endEndRel < 0 {
continue
}
endEnd := end + endEndRel + len("-->")
content = content[:start] + replacement + content[endEnd:]
}
return content
}
func markerID(marker string) string {
const prefix = `id="`
start := strings.Index(marker, prefix)
if start < 0 {
return ""
}
start += len(prefix)
end := strings.Index(marker[start:], `"`)
if end < 0 {
return ""
}
return marker[start : start+end]
}
func renderArchivedWikiPage(pageID, title string) string {
if title == "" {
title = extractHTMLTitle("", pageID)
}
body := strings.Join([]string{
"<h1>" + html.EscapeString(title) + "</h1>",
"<p>This generated page is no longer present in the current Shadows Over Westgate topdata wiki source.</p>",
}, "\n")
hash := computeManagedHash(body)
return ensureTrailingNewline(strings.Join([]string{
"<!-- sow-topdata-wiki:page=" + pageID + " -->",
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
body,
"<!-- sow-topdata-wiki:managed:end -->",
}, "\n"))
}
func extractWikiPageID(content string) string {
const pageMarker = "sow-topdata-wiki:page="
start := strings.Index(content, pageMarker)
if start < 0 {
return ""
}
value := content[start+len(pageMarker):]
if end := strings.Index(value, "-->"); end >= 0 {
value = value[:end]
}
if fields := strings.Fields(value); len(fields) > 0 {
return strings.Trim(strings.TrimSpace(fields[0]), `"`)
}
return ""
}
func extractWikiPagePublicPath(content string) string {
const pageMarker = "sow-topdata-wiki:page="
const pathField = "public_path="
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, pathField)
if fieldStart < 0 {
return ""
}
value := marker[fieldStart+len(pathField):]
if fields := strings.Fields(value); len(fields) > 0 {
path := strings.Trim(strings.TrimSpace(fields[0]), `"`)
if path != "" && canonicalWikiSlashPath(path) == path {
return path
}
}
return ""
}
func extractPageTitle(content, fallback string) string {
if title := extractMarkdownTitle(content); title != "" {
return title
}
return extractHTMLTitle(content, fallback)
}
func extractMarkdownTitle(content string) string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "# ") {
continue
}
title := strings.TrimSpace(strings.TrimPrefix(trimmed, "# "))
if title != "" {
return title
}
}
return ""
}
func extractHTMLTitle(content, fallback string) string {
lower := strings.ToLower(content)
for _, tag := range []string{"h1", "h2"} {
open := "<" + tag + ">"
close := "</" + tag + ">"
start := strings.Index(lower, open)
end := strings.Index(lower, close)
if start >= 0 && end > start {
title := strings.TrimSpace(content[start+len(open) : end])
title = strings.ReplaceAll(title, "\n", " ")
if title != "" {
return html.UnescapeString(stripSimpleTags(title))
}
}
}
parts := strings.Split(fallback, ":")
return namespaceTitle(parts[len(parts)-1])
}
func nodeBBTopicTitle(page wikiDeployPage, minLength int) string {
if minLength <= 0 {
minLength = project.DefaultTopDataWikiTitlePrefixMinLength
}
title := strings.TrimSpace(page.Title)
if len([]rune(title)) >= minLength {
return title
}
namespace := strings.TrimSpace(namespaceTitle(page.Namespace))
if namespace == "" {
namespace = "Wiki"
}
if title == "" {
parts := strings.Split(page.PageID, ":")
title = namespaceTitle(parts[len(parts)-1])
}
return namespace + ": " + title
}
func stripSimpleTags(text string) string {
var out strings.Builder
inTag := false
for _, r := range text {
switch r {
case '<':
inTag = true
case '>':
inTag = false
default:
if !inTag {
out.WriteRune(r)
}
}
}
return out.String()
}
func sortedKeysFromMap[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
slices.Sort(keys)
return keys
}
type nodeBBClient struct {
endpoint string
token string
timeout time.Duration
}
type nodeBBHTTPError struct {
Method string
Path string
Status int
Body string
}
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
Title string
Content string
SourceContent string
}
type nodeBBEditLock struct {
Token string
}
type nodeBBWikiPage struct {
TID int
Title string
TitleLeaf string
Slug string
WikiPath string
CanonicalPath string
}
func newNodeBBClient(endpoint, token string) *nodeBBClient {
return &nodeBBClient{
endpoint: strings.TrimRight(endpoint, "/"),
token: token,
timeout: 30 * time.Second,
}
}
func (c *nodeBBClient) getPost(pid int) (nodeBBPost, error) {
var doc any
if err := c.request("GET", fmt.Sprintf("/api/v3/posts/%d", pid), nil, &doc); err != nil {
return nodeBBPost{}, err
}
return parseNodeBBPost(doc), nil
}
func (c *nodeBBClient) getTopic(tid int) (nodeBBPost, error) {
var doc any
if err := c.request("GET", fmt.Sprintf("/api/v3/topics/%d", tid), nil, &doc); err != nil {
return nodeBBPost{}, err
}
post := parseNodeBBPost(doc)
if post.TID == 0 {
post.TID = tid
}
return post, nil
}
func (c *nodeBBClient) listNamespacePages(cid int) ([]nodeBBWikiPage, error) {
return c.listNamespacePagesWithQuery(cid, "")
}
func (c *nodeBBClient) searchNamespacePages(cid int, query string) ([]nodeBBWikiPage, error) {
return c.listNamespacePagesWithQuery(cid, query)
}
func (c *nodeBBClient) listNamespacePagesWithQuery(cid int, query string) ([]nodeBBWikiPage, error) {
var out []nodeBBWikiPage
after := ""
seenCursors := map[string]struct{}{}
seenPages := map[string]struct{}{}
for {
path := fmt.Sprintf("/api/v3/plugins/westgate-wiki/namespace/%d/pages?limit=80", cid)
if query != "" {
path += "&q=" + url.QueryEscape(query)
}
if after != "" {
path += "&after=" + url.QueryEscape(after)
}
var doc any
if err := c.request("GET", path, nil, &doc); err != nil {
return nil, err
}
pages, next, hasMore := parseNodeBBWikiPageList(doc)
newPages := 0
for _, page := range pages {
identity := nodeBBWikiPageIdentity(page)
if identity == "" {
out = append(out, page)
newPages++
continue
}
if _, ok := seenPages[identity]; ok {
continue
}
seenPages[identity] = struct{}{}
out = append(out, page)
newPages++
}
if !hasMore || next == "" {
return out, nil
}
if after != "" && next == after {
if newPages == 0 {
return out, nil
}
return nil, fmt.Errorf("NodeBB wiki namespace %d returned repeated pagination cursor %q", cid, next)
}
if _, ok := seenCursors[next]; ok {
if newPages == 0 {
return out, nil
}
return nil, fmt.Errorf("NodeBB wiki namespace %d returned repeated pagination cursor %q", cid, next)
}
seenCursors[next] = struct{}{}
after = next
}
}
func nodeBBWikiPageIdentity(page nodeBBWikiPage) string {
if page.TID != 0 {
return fmt.Sprintf("tid:%d", page.TID)
}
for _, value := range []string{page.WikiPath, page.CanonicalPath, page.Slug, page.Title} {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return strings.ToLower(trimmed)
}
}
return ""
}
func (c *nodeBBClient) createTopic(cid int, title, content, summary string) (nodeBBPost, error) {
body := map[string]any{"cid": cid, "title": title, "content": content, "sourceContent": content}
if summary != "" {
body["_uid_note"] = summary
}
var doc any
if err := c.request("POST", "/api/v3/topics", body, &doc); err != nil {
return nodeBBPost{}, err
}
post := parseNodeBBPost(doc)
if post.TID == 0 || post.PID == 0 {
return nodeBBPost{}, fmt.Errorf("NodeBB topic create response did not include tid and pid")
}
return post, nil
}
func (c *nodeBBClient) updatePost(tid, pid int, content, summary string) error {
if tid == 0 {
post, err := c.getPost(pid)
if err != nil {
return err
}
tid = post.TID
}
lock, err := c.acquireEditLock(tid)
if err != nil {
return err
}
body := map[string]any{"content": content, "sourceContent": content}
if lock.Token != "" {
body["wikiEditLockToken"] = lock.Token
}
if summary != "" {
body["_uid_note"] = summary
}
return c.request("PUT", fmt.Sprintf("/api/v3/posts/%d", pid), body, nil)
}
func (c *nodeBBClient) renameWikiPage(tid, cid int, title string) error {
if tid == 0 || cid == 0 {
return fmt.Errorf("NodeBB wiki page rename requires topic id and category id")
}
body := map[string]any{"tid": tid, "cid": cid, "title": title}
return c.request("PUT", "/api/v3/plugins/westgate-wiki/page/move", body, nil)
}
// purgeWikiPage deletes a wiki topic through the wiki plugin's own page
// actions. The core DELETE /api/v3/topics/{tid} route is refused for topics in
// wiki categories, because revision history is plugin-owned, so a purge has to
// tombstone the page first and then hard-purge it.
func (c *nodeBBClient) purgeWikiPage(tid int) error {
if tid == 0 {
return fmt.Errorf("NodeBB wiki page purge requires topic id")
}
body := map[string]any{"tid": tid}
if err := c.request(http.MethodPut, "/api/v3/plugins/westgate-wiki/page/tombstone", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
if err := c.request(http.MethodDelete, "/api/v3/plugins/westgate-wiki/page/hard-purge", body, nil); err != nil {
if isNodeBBMissingResource(err) {
return nil
}
return err
}
return nil
}
// isNodeBBWikiPageUndeletable reports whether NodeBB refused to delete the page
// outright rather than failing transiently. The wiki home topic answers this
// way: the plugin excludes it from tombstone, restore, and purge alike.
func isNodeBBWikiPageUndeletable(err error) bool {
var httpErr nodeBBHTTPError
return errors.As(err, &httpErr) && httpErr.Status == http.StatusForbidden
}
func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
if tid == 0 {
return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki post update requires a topic id for edit locking")
}
body := map[string]any{"tid": tid}
var doc any
if err := c.request("PUT", "/api/v3/plugins/westgate-wiki/edit-lock", body, &doc); err != nil {
return nodeBBEditLock{}, err
}
lock := parseNodeBBEditLock(doc)
if lock.Token == "" {
return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki edit lock response for topic %d did not include token", tid)
}
return lock, nil
}
func (c *nodeBBClient) request(method, path string, payload any, out any) error {
var rawPayload []byte
if payload != nil {
var err error
rawPayload, err = json.Marshal(payload)
if err != nil {
return err
}
}
const maxAttempts = 3
backoff := []time.Duration{5 * time.Second, 15 * time.Second}
for attempt := 0; attempt < maxAttempts; attempt++ {
err := c.doRequest(method, path, rawPayload, out)
if err == nil {
return nil
}
if attempt == maxAttempts-1 || !isNodeBBRetryable(err) {
return err
}
time.Sleep(backoff[attempt])
}
return nil
}
func isNodeBBRetryable(err error) bool {
if err == nil {
return false
}
var httpErr nodeBBHTTPError
if errors.As(err, &httpErr) {
return httpErr.Status == http.StatusTooManyRequests ||
httpErr.Status == http.StatusBadGateway ||
httpErr.Status == http.StatusServiceUnavailable ||
httpErr.Status == http.StatusGatewayTimeout
}
// Retry on network/timeout errors (e.g. context deadline exceeded)
return true
}
func (c *nodeBBClient) doRequest(method, path string, rawPayload []byte, out any) error {
var body io.Reader
if rawPayload != nil {
body = bytes.NewReader(rawPayload)
}
req, err := http.NewRequest(method, c.endpoint+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if rawPayload != nil {
req.Header.Set("Content-Type", "application/json")
}
client := http.Client{Timeout: c.timeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return nodeBBHTTPError{Method: method, Path: path, Status: resp.StatusCode, Body: strings.TrimSpace(string(raw))}
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("parse NodeBB response: %w", err)
}
return nil
}
func parseNodeBBPost(doc any) nodeBBPost {
for {
m, ok := doc.(map[string]any)
if !ok {
return nodeBBPost{}
}
if response, ok := m["response"]; ok {
doc = response
continue
}
if postData, ok := m["postData"]; ok {
doc = postData
continue
}
if mainPost, ok := m["mainPost"]; ok {
post := parseNodeBBPost(mainPost)
if post.TID == 0 {
post.TID = intFromAny(m["tid"])
}
if post.PID == 0 {
post.PID = firstInt(m, "mainPid")
}
if post.Title == "" {
post.Title = firstString(m, "title", "titleRaw", "title_raw")
}
return post
}
sourceContent := firstString(m, "sourceContent", "source_content")
content := firstString(m, "content")
if sourceContent != "" {
content = sourceContent
}
return nodeBBPost{
PID: firstInt(m, "pid", "mainPid"),
TID: firstInt(m, "tid"),
Title: firstString(m, "title", "titleRaw", "title_raw"),
Content: content,
SourceContent: sourceContent,
}
}
}
func parseNodeBBEditLock(doc any) nodeBBEditLock {
for {
m, ok := doc.(map[string]any)
if !ok {
return nodeBBEditLock{}
}
if response, ok := m["response"]; ok {
doc = response
continue
}
return nodeBBEditLock{Token: firstString(m, "token")}
}
}
func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) {
for {
m, ok := doc.(map[string]any)
if !ok {
return nil, "", false
}
if response, ok := m["response"]; ok {
doc = response
continue
}
pagesRaw, _ := m["pages"].([]any)
pages := make([]nodeBBWikiPage, 0, len(pagesRaw))
for _, raw := range pagesRaw {
row, ok := raw.(map[string]any)
if !ok {
continue
}
pages = append(pages, nodeBBWikiPage{
TID: firstInt(row, "tid"),
Title: firstString(row, "title"),
TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
Slug: firstString(row, "slug"),
WikiPath: firstString(row, "wikiPath", "wiki_path"),
CanonicalPath: firstString(row, "canonicalPath", "canonical_path"),
})
}
return pages, firstString(m, "nextCursor", "next_cursor"), boolFromAny(m["hasMore"])
}
}
func isNodeBBWikiPageCollision(err error) bool {
var httpErr nodeBBHTTPError
if !errors.As(err, &httpErr) {
return false
}
if httpErr.Status != http.StatusBadRequest {
return false
}
body := strings.ToLower(httpErr.Body)
return strings.Contains(body, "wiki page with this url already exists") ||
strings.Contains(body, "page-collision") ||
strings.Contains(body, "namespace-page-collision")
}
func boolFromAny(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case string:
return typed == "true" || typed == "1"
default:
return false
}
}
func firstInt(m map[string]any, keys ...string) int {
for _, key := range keys {
if value := intFromAny(m[key]); value != 0 {
return value
}
}
return 0
}
func intFromAny(value any) int {
switch typed := value.(type) {
case int:
return typed
case float64:
return int(typed)
case json.Number:
i, _ := typed.Int64()
return int(i)
case string:
i, _ := strconv.Atoi(typed)
return i
default:
return 0
}
}
func firstString(m map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := m[key].(string); ok {
return value
}
}
return ""
}