Files
sow-tools/internal/topdata/wiki_deploy.go
T

1712 lines
50 KiB
Go

package topdata
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"gitea.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
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
}
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"`
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
}
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) {}
}
if opts.SourceDir == "" {
opts.SourceDir = wikiOutputPagesDir(p)
}
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, 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))
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":
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.purgeTopic(plan.Entry.TID); err != nil {
return result, fmt.Errorf("deploy wiki page %q: purge NodeBB topic %d: %w", plan.Page.PageID, plan.Entry.TID, err)
}
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)
}
}
}
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 string, namespaces []string) (map[string]wikiDeployPage, error) {
namespaceSet := map[string]struct{}{}
for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{}
}
pageIndex, err := loadDeployPageIndex(sourceDir)
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 string) (map[string]wikiPageIndexEntry, error) {
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, err := planManagedNamespaceReset(opts, client, progress)
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 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
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})
}
}
if manifest.Pages[pageID].Hash == page.Hash {
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
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 {
result.Skipped++
continue
}
result.Updated++
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
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
}
func planManagedNamespaceReset(opts DeployWikiOptions, client *nodeBBClient, progress func(string)) ([]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
}
progress(fmt.Sprintf("Listing NodeBB wiki namespace category %d for managed reset", cid))
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 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 || !ok {
return ok, 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
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
}
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 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
}
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{}{}
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)
out = append(out, pages...)
if !hasMore || next == "" {
return out, nil
}
if after != "" && next == after {
return nil, fmt.Errorf("NodeBB wiki namespace %d returned repeated pagination cursor %q", cid, next)
}
if _, ok := seenCursors[next]; ok {
return nil, fmt.Errorf("NodeBB wiki namespace %d returned repeated pagination cursor %q", cid, next)
}
seenCursors[next] = struct{}{}
after = next
}
}
func (c *nodeBBClient) createTopic(cid int, title, content, summary string) (nodeBBPost, error) {
body := map[string]any{"cid": cid, "title": title, "content": 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}
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)
}
func (c *nodeBBClient) purgeTopic(tid int) error {
if tid == 0 {
return fmt.Errorf("NodeBB topic purge requires topic id")
}
err := c.request(http.MethodDelete, fmt.Sprintf("/api/v3/topics/%d", tid), nil, nil)
var httpErr nodeBBHTTPError
if errors.As(err, &httpErr) && (httpErr.Status == http.StatusNotFound || httpErr.Status == http.StatusGone) {
return nil
}
return err
}
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 body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(raw)
}
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 payload != 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
}
return nodeBBPost{
PID: firstInt(m, "pid", "mainPid"),
TID: firstInt(m, "tid"),
Title: firstString(m, "title", "titleRaw", "title_raw"),
Content: firstString(m, "content"),
}
}
}
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 ""
}