build-binaries / build-binaries (push) Successful in 2m27s
Closes #99. Closes #100. ## #99 — purge used the core topic API `deploy-wiki --stale-policy purge` deleted pages with `DELETE /api/v3/topics/{tid}`. On a NodeBB running `nodebb-plugin-westgate-wiki` that is refused for topics in wiki categories — 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: 1. `PUT /api/v3/plugins/westgate-wiki/page/tombstone` 2. `DELETE /api/v3/plugins/westgate-wiki/page/hard-purge` in that order, because a page must be tombstoned before it can be purged. A page that is already gone answers 404 on the tombstone and is treated as a completed purge, as before. **Archive was audited and needs no change.** It rewrites the page through `updatePost`, which is an ordinary post edit the plugin allows; only delete, restore, and purge are reserved to the page actions. **The wiki home topic.** A namespace reset enumerates every topic in the category, including the home page, which the plugin excludes from tombstone, restore, and purge alike. Those are now skipped instead of aborting the reset. NodeBB answers 403 for that and for a token without purge privileges alike, and the response body cannot tell the two apart — what can is scope. A category where nothing at all could be deleted is a privilege problem, so the run still fails there rather than writing a manifest that claims a fresh start over pages that are all still present. **The fakes.** Every fake NodeBB in `wiki_deploy_test.go` now goes through one constructor that refuses native topic mutation exactly the way the plugin does. The old fakes answered the core API, which is how a purge path that has never worked in production stayed green in CI. ## #100 — `stale: 0` above `purged: 1213` The reset purge never went through stale computation, so the preview reported zero deletions on a run that would delete every topic in the managed categories. Reset deletions are now counted in `stale`, which is the number callers word their destructive-policy warning around, and the summary gains a line naming the reset and how many of its targets the manifest has no record of writing: ``` stale: 1213 purged: 1213 namespace reset: 1213 (unrecognized: 13) unrecognized pages were not written by this deployer; recreating them is not possible ``` The unrecognized subset is the number worth surfacing, since those are the deletions a re-seed cannot undo. The `--reset-managed-namespaces` help text now says plainly that the flag deletes every page in the managed categories, not only the ones this deployer wrote. `DeployResult` is exported so the console reads named fields instead of eleven positional ints. ## Verification `go vet ./...` and `go test ./...` pass. New tests cover the plugin purge order, the already-missing page, the skipped undeletable topic, the per-category privilege failure, and the reset counts.Reviewed-on: #101 Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
1961 lines
59 KiB
Go
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 ""
|
|
}
|