1862 lines
55 KiB
Go
1862 lines
55 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
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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))
|
|
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, 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, 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
|
|
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
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
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 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 ""
|
|
}
|