Wiki Deploy Code

This commit is contained in:
2026-04-30 19:51:50 +02:00
parent d893785c0c
commit f480815e5d
7 changed files with 801 additions and 407 deletions
+413 -346
View File
@@ -1,16 +1,18 @@
package topdata
import (
"crypto/sha1"
"encoding/base64"
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
@@ -20,61 +22,85 @@ import (
const (
defaultDeployManifestName = ".wiki_deploy_manifest.json"
defaultEditSummary = "Auto-generated from native builder"
defaultNotesDelimiter = "===== Notes ====="
managedStartMarker = "<!-- sow-topdata-wiki:managed:start -->"
managedEndMarker = "<!-- sow-topdata-wiki:managed:end -->"
)
var defaultManagedNamespaces = []string{"classes", "feat", "itemtypes", "races", "skills", "spells"}
var defaultManagedNamespaces = []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}
type DeployWikiOptions struct {
SourceDir string
Endpoint string
Username string
Password string
Token string
Version string
Namespaces []string
NotesDelimiter string
CategoryIDs map[string]int
ManifestPath string
DryRun bool
Force bool
AllowCreates bool
StalePolicy string
Username string
Password string
NotesDelimiter string
}
type deployResult struct {
LocalPages int
Created int
Updated int
Deleted int
Stale int
Skipped int
Drifted int
Manifest string
}
type deployPlan struct {
PageID string
Content string
OldContent string
type wikiDeployPage struct {
PageID string
Title string
Namespace string
Content string
Hash string
}
type manifestEntry struct {
type wikiDeployManifest struct {
Version string `json:"version"`
Pages map[string]wikiDeployManifestPage `json:"pages"`
}
type wikiDeployManifestPage struct {
Hash string `json:"hash"`
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
}
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
if progress == nil {
progress = func(string) {}
}
if opts.SourceDir == "" {
opts.SourceDir = wikiOutputPagesDir(p)
}
if _, err := os.Stat(opts.SourceDir); err != nil {
return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
}
if opts.Endpoint == "" {
return deployResult{}, errors.New("DOKUWIKI_RPC_ENDPOINT is required")
return deployResult{}, errors.New("NODEBB_API_ENDPOINT is required")
}
if opts.Username == "" {
return deployResult{}, errors.New("DOKUWIKI_RPC_USERNAME is required")
if opts.Token == "" {
return deployResult{}, errors.New("NODEBB_API_TOKEN is required")
}
if opts.Password == "" {
return deployResult{}, errors.New("DOKUWIKI_RPC_PASSWORD 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")
}
namespaces := opts.Namespaces
@@ -82,71 +108,66 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
namespaces = defaultManagedNamespaces
}
notesDelimiter := opts.NotesDelimiter
if notesDelimiter == "" {
notesDelimiter = defaultNotesDelimiter
}
manifestPath := opts.ManifestPath
if manifestPath == "" {
manifestPath = filepath.Join(filepath.Dir(opts.SourceDir), defaultDeployManifestName)
}
localPages, err := collectLocalPages(opts.SourceDir, namespaces)
pages, err := collectLocalPages(opts.SourceDir, namespaces)
if err != nil {
return deployResult{}, err
}
manifest := loadDeployManifest(manifestPath)
client := newDokuWikiClient(opts.Endpoint, opts.Username, opts.Password)
client := newNodeBBClient(opts.Endpoint, opts.Token)
deployments, stalePages, err := planDeploy(localPages, client, namespaces, notesDelimiter, manifest)
plans, result, nextManifest, err := planNodeBBDeploy(pages, manifest, opts, client)
if err != nil {
return deployResult{}, err
}
result.LocalPages = len(pages)
result.Manifest = manifestPath
progress(fmt.Sprintf("Deploying %d pages...", len(deployments)))
progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, skip %d, drift %d", result.Created, result.Updated, result.Skipped, 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")
}
editSummary := defaultEditSummary
summary := defaultEditSummary
if opts.Version != "" {
editSummary = fmt.Sprintf("%s (%s)", defaultEditSummary, opts.Version)
summary = fmt.Sprintf("%s (%s)", summary, opts.Version)
}
if !opts.DryRun {
for _, plan := range deployments {
if err := client.putPage(plan.PageID, plan.Content, editSummary); err != nil {
return deployResult{}, err
for _, plan := range plans {
switch plan.Action {
case "create":
created, err := client.createTopic(plan.Entry.CID, plan.Page.Title, plan.Content, summary)
if err != nil {
return result, 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.PID, plan.Content, summary); err != nil {
return result, err
}
}
for _, pageID := range stalePages {
if err := client.deletePage(pageID, editSummary); err != nil {
return deployResult{}, err
}
}
currentHashes := computePageHashes(localPages, notesDelimiter)
saveDeployManifest(manifestPath, currentHashes)
}
updated := len(deployments)
deleted := len(stalePages)
skipped := len(localPages) - updated
result := deployResult{
LocalPages: len(localPages),
Updated: updated,
Deleted: deleted,
Skipped: skipped,
Manifest: manifestPath,
if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
return result, err
}
progress(fmt.Sprintf("Updated: %d, Skipped: %d, Deleted: %d", updated, skipped, deleted))
return result, nil
}
func collectLocalPages(sourceDir string, namespaces []string) (map[string]string, error) {
pages := map[string]string{}
func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDeployPage, error) {
namespaceSet := map[string]struct{}{}
for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{}
}
pages := map[string]wikiDeployPage{}
for _, ns := range namespaces {
nsDir := filepath.Join(sourceDir, ns)
if !isDir(nsDir) {
@@ -156,24 +177,31 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]string
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".txt" {
if d.IsDir() || filepath.Ext(path) != ".html" {
return nil
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
pageID := strings.TrimSuffix(rel, ".txt")
pageID = strings.ReplaceAll(pageID, string(filepath.Separator), ":")
pageID = strings.ToLower(pageID)
content, err := os.ReadFile(path)
pageID := strings.TrimSuffix(filepath.ToSlash(rel), ".html")
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
}
pages[pageID] = string(content)
content := string(raw)
pages[pageID] = wikiDeployPage{
PageID: pageID,
Title: extractHTMLTitle(content, pageID),
Namespace: namespace,
Content: content,
Hash: computeManagedHash(content),
}
return nil
}); err != nil {
return nil, err
@@ -182,283 +210,322 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]string
return pages, nil
}
func computePageHash(content, notesDelimiter string) string {
generatedBody, _ := splitNotesSection(content, notesDelimiter)
h := sha1.New()
h.Write([]byte(generatedBody))
return fmt.Sprintf("%x", h.Sum(nil))
}
func computePageHashes(pages map[string]string, notesDelimiter string) map[string]string {
hashes := map[string]string{}
for pageID, content := range pages {
hashes[pageID] = computePageHash(content, notesDelimiter)
}
return hashes
}
func splitNotesSection(content, notesDelimiter string) (string, string) {
lines := strings.Split(content, notesDelimiter)
if len(lines) <= 1 {
return strings.TrimSpace(content), ""
}
body := strings.TrimSpace(strings.Join(lines[:len(lines)-1], notesDelimiter))
notes := strings.TrimSpace(strings.Join(lines[1:], notesDelimiter))
return body, notes
}
func loadDeployManifest(path string) map[string]string {
if _, err := os.Stat(path); err != nil {
return map[string]string{}
}
data, err := os.ReadFile(path)
if err != nil {
return map[string]string{}
}
var doc struct {
Pages map[string]string `json:"pages"`
}
if err := json.Unmarshal(data, &doc); err != nil {
return map[string]string{}
}
result := map[string]string{}
for k, v := range doc.Pages {
result[k] = v
}
return result
}
func saveDeployManifest(path string, hashes map[string]string) {
os.MkdirAll(filepath.Dir(path), 0755)
doc := struct {
Pages map[string]string `json:"pages"`
}{
Pages: hashes,
}
data, _ := json.MarshalIndent(doc, "", " ")
os.WriteFile(path, append(data, '\n'), 0644)
}
func planDeploy(
localPages map[string]string,
client *dokuWikiClient,
namespaces []string,
notesDelimiter string,
previousHashes map[string]string,
) ([]deployPlan, []string, error) {
var deployments []deployPlan
remotePages := map[string]string{}
for pageID, content := range localPages {
hash := computePageHash(content, notesDelimiter)
if previousHashes[pageID] == hash {
continue
}
oldContent, err := client.getPage(pageID)
if err != nil {
if !errors.Is(err, errPageNotFound) {
return nil, nil, err
}
oldContent = ""
}
remotePages[pageID] = oldContent
merged := mergePageContent(content, oldContent, notesDelimiter)
if merged != oldContent {
deployments = append(deployments, deployPlan{
PageID: pageID,
Content: merged,
OldContent: oldContent,
})
}
}
remoteIDs := map[string]bool{}
for pageID := range remotePages {
remoteIDs[pageID] = true
}
for _, ns := range namespaces {
pages, err := client.listNamespacePages(ns)
if err != nil {
continue
}
for _, pageID := range pages {
remoteIDs[pageID] = true
}
}
var stale []string
for pageID := range remoteIDs {
if _, exists := localPages[pageID]; !exists {
stale = append(stale, pageID)
}
}
slices.Sort(stale)
return deployments, stale, nil
}
func mergePageContent(generated, existing, notesDelimiter string) string {
_, existingNotes := splitNotesSection(existing, notesDelimiter)
generatedBody, _ := splitNotesSection(generated, notesDelimiter)
if existingNotes == "" {
return generated
}
return strings.TrimSpace(generatedBody) + "\n\n" + notesDelimiter + "\n\n" + existingNotes + "\n"
}
type dokuWikiClient struct {
endpoint string
username string
password string
timeout time.Duration
retryAttempts int
retryBackoff time.Duration
requestDelay time.Duration
}
var errPageNotFound = errors.New("page not found")
func newDokuWikiClient(endpoint, username, password string) *dokuWikiClient {
endpoint = strings.TrimRight(endpoint, "/")
if strings.HasSuffix(endpoint, "/doku.php") {
endpoint = strings.TrimSuffix(endpoint, "/doku.php") + "/lib/exe/jsonrpc.php"
}
return &dokuWikiClient{
endpoint: endpoint,
username: username,
password: password,
timeout: 30 * time.Second,
retryAttempts: 3,
retryBackoff: 500 * time.Millisecond,
requestDelay: 50 * time.Millisecond,
}
}
func (c *dokuWikiClient) getPage(pageID string) (string, error) {
result, err := c.rpc("wiki.getPage", []any{pageID})
if err != nil {
return "", err
}
if result == nil {
return "", errPageNotFound
}
s, ok := result.(string)
if !ok {
return "", nil
}
return s, nil
}
func (c *dokuWikiClient) putPage(pageID, content, summary string) error {
_, err := c.rpc("wiki.putPage", []any{pageID, content, map[string]any{"sum": summary}})
return err
}
func (c *dokuWikiClient) deletePage(pageID, summary string) error {
_, err := c.rpc("wiki.putPage", []any{pageID, "", map[string]any{"sum": summary, "minor": false}})
return err
}
func (c *dokuWikiClient) listNamespacePages(namespace string) ([]string, error) {
result, err := c.rpc("dokuwiki.getPagelist", []any{namespace, map[string]any{}})
if err != nil {
return nil, err
}
items, ok := result.([]any)
if !ok {
return nil, nil
}
var pages []string
for _, item := range items {
switch v := item.(type) {
case string:
pages = append(pages, v)
case map[string]any:
if id, ok := v["id"].(string); ok {
pages = append(pages, id)
}
}
}
return pages, nil
}
func (c *dokuWikiClient) rpc(method string, params []any) (any, error) {
var last error
for attempt := 1; attempt <= c.retryAttempts; attempt++ {
result, err := c.rpcOnce(method, params)
if err == nil {
return result, nil
}
last = err
if attempt < c.retryAttempts {
time.Sleep(c.retryBackoff * time.Duration(attempt))
}
}
return nil, fmt.Errorf("%s failed after %d attempts: %w", method, c.retryAttempts, last)
}
func (c *dokuWikiClient) rpcOnce(method string, params []any) (any, error) {
if c.requestDelay > 0 {
time.Sleep(c.requestDelay)
}
payload := map[string]any{
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
token := base64.StdEncoding.EncodeToString([]byte(c.username + ":" + c.password))
req, err := http.NewRequest("POST", c.endpoint, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Basic "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]any
if err := json.Unmarshal(data, &result); err != nil {
preview := strings.TrimSpace(string(data))
if len(preview) > 200 {
preview = preview[:200] + "..."
}
return nil, fmt.Errorf("non-JSON response: %s", preview)
}
if errMsg, ok := result["error"].(any); ok && errMsg != nil {
return nil, fmt.Errorf("RPC error: %v", errMsg)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return result["result"], 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) ([]wikiDeployPlan, deployResult, wikiDeployManifest, error) {
pageIDs := sortedKeysFromMap(pages)
next := wikiDeployManifest{Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{}}
var plans []wikiDeployPlan
var result deployResult
for _, pageID := range pageIDs {
page := pages[pageID]
entry := manifest.Pages[pageID]
entry.Hash = page.Hash
if entry.CID == 0 {
entry.CID = opts.CategoryIDs[page.Namespace]
}
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++
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "create", Content: page.Content})
continue
}
if manifest.Pages[pageID].Hash == page.Hash {
result.Skipped++
continue
}
remote, err := client.getPost(entry.PID)
if err != nil {
return nil, result, next, err
}
remoteHash := computeManagedHash(remote.Content)
if manifest.Pages[pageID].Hash != "" && remoteHash != manifest.Pages[pageID].Hash {
result.Drifted++
if !opts.Force {
continue
}
}
merged := mergeManagedContent(remote.Content, page.Content)
if merged == remote.Content {
result.Skipped++
continue
}
result.Updated++
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "update", Content: merged, RemoteHash: remoteHash})
}
for pageID := range manifest.Pages {
if _, ok := pages[pageID]; !ok {
result.Stale++
}
}
return plans, result, next, nil
}
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
}
h := sha256.Sum256([]byte(strings.TrimSpace(managed)))
return fmt.Sprintf("%x", h[:])
}
func extractManagedRegion(content string) (string, bool) {
start := strings.Index(content, managedStartMarker)
end := strings.Index(content, managedEndMarker)
if start < 0 || end < 0 || end < start {
return "", false
}
start += len(managedStartMarker)
return strings.TrimSpace(content[start:end]), true
}
func mergeManagedContent(existing, generated string) string {
generatedRegion, ok := extractManagedRegion(generated)
if !ok {
return generated
}
start := strings.Index(existing, managedStartMarker)
end := strings.Index(existing, managedEndMarker)
if start < 0 || end < 0 || end < start {
return generated
}
end += len(managedEndMarker)
return existing[:start+len(managedStartMarker)] + "\n" + generatedRegion + "\n" + existing[end:]
}
func extractHTMLTitle(content, fallback string) string {
lower := strings.ToLower(content)
start := strings.Index(lower, "<h1>")
end := strings.Index(lower, "</h1>")
if start >= 0 && end > start {
title := strings.TrimSpace(content[start+len("<h1>") : end])
title = strings.ReplaceAll(title, "\n", " ")
if title != "" {
return html.UnescapeString(stripSimpleTags(title))
}
}
parts := strings.Split(fallback, ":")
return namespaceTitle(parts[len(parts)-1])
}
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 nodeBBPost struct {
PID int
TID int
Content 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) createTopic(cid int, title, content, summary string) (nodeBBPost, error) {
body := map[string]any{"cid": cid, "title": title, "content": content}
if summary != "" {
body["_uid_note"] = summary
}
var doc any
if err := c.request("POST", "/api/v3/topics", body, &doc); err != nil {
return nodeBBPost{}, err
}
post := parseNodeBBPost(doc)
if post.TID == 0 || post.PID == 0 {
return nodeBBPost{}, fmt.Errorf("NodeBB topic create response did not include tid and pid")
}
return post, nil
}
func (c *nodeBBClient) updatePost(pid int, content, summary string) error {
body := map[string]any{"content": content}
if summary != "" {
body["_uid_note"] = summary
}
return c.request("PUT", fmt.Sprintf("/api/v3/posts/%d", pid), body, nil)
}
func (c *nodeBBClient) request(method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, c.endpoint+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
client := http.Client{Timeout: c.timeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return fmt.Errorf("NodeBB %s %s failed: HTTP %d: %s", method, path, resp.StatusCode, 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"])
}
return post
}
return nodeBBPost{
PID: firstInt(m, "pid", "mainPid"),
TID: firstInt(m, "tid"),
Content: firstString(m, "content"),
}
}
}
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 ""
}