diff --git a/README.md b/README.md index 7407d05..0b6261a 100644 --- a/README.md +++ b/README.md @@ -434,8 +434,11 @@ re-queries the namespace by page leaf, adopts the matching topic, and updates it instead. Pass `--debug` when deploying large wiki changes to emit planning and live mutation progress. Updates and stale-page archives acquire a Westgate Wiki edit lock before saving, then send the returned -`wikiEditLockToken` with the NodeBB post edit request. Stale generated pages are -reported by default. +`wikiEditLockToken` with the NodeBB post edit request. Creates, updates, and +archives write the same managed HTML to both NodeBB `content` and +`sourceContent`; the deploy manifest records `source_content_synced` so older +manifests receive a one-time body repair even when the generated hash is +unchanged. Stale generated pages are reported by default. `--stale-policy archive` rewrites stale generated pages in place as archived managed content. `--stale-policy purge` permanently removes only stale generated topics already tracked in the wiki deploy manifest. Dry-run that policy before diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index b686c4a..57dec19 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -79,16 +79,17 @@ type wikiDeployManifest struct { } type wikiDeployManifestPage struct { - Hash string `json:"hash"` - LastSeenHash string `json:"last_seen_hash,omitempty"` - ArchivedHash string `json:"archived_hash,omitempty"` - Title string `json:"title,omitempty"` - TopicTitle string `json:"topic_title,omitempty"` - Namespace string `json:"namespace,omitempty"` - Stale bool `json:"stale,omitempty"` - TID int `json:"tid,omitempty"` - PID int `json:"pid,omitempty"` - CID int `json:"cid,omitempty"` + 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 { @@ -541,6 +542,8 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife } 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 } @@ -556,7 +559,8 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife } } - if manifest.Pages[pageID].Hash == page.Hash { + needsSourceContentSync := !manifest.Pages[pageID].SourceContentSynced + if manifest.Pages[pageID].Hash == page.Hash && !needsSourceContentSync { result.Skipped++ continue } @@ -588,6 +592,8 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife } 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 } @@ -608,11 +614,13 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife } } merged := mergeManagedContent(remote.Content, page.Content) - if merged == remote.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 { @@ -636,6 +644,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife 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{ @@ -759,6 +768,7 @@ func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, cl 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 } @@ -1369,10 +1379,11 @@ func isNodeBBMissingResource(err error) bool { } type nodeBBPost struct { - PID int - TID int - Title string - Content string + PID int + TID int + Title string + Content string + SourceContent string } type nodeBBEditLock struct { @@ -1457,7 +1468,7 @@ func (c *nodeBBClient) listNamespacePagesWithQuery(cid int, query string) ([]nod } func (c *nodeBBClient) createTopic(cid int, title, content, summary string) (nodeBBPost, error) { - body := map[string]any{"cid": cid, "title": title, "content": content} + body := map[string]any{"cid": cid, "title": title, "content": content, "sourceContent": content} if summary != "" { body["_uid_note"] = summary } @@ -1484,7 +1495,7 @@ func (c *nodeBBClient) updatePost(tid, pid int, content, summary string) error { if err != nil { return err } - body := map[string]any{"content": content} + body := map[string]any{"content": content, "sourceContent": content} if lock.Token != "" { body["wikiEditLockToken"] = lock.Token } @@ -1597,11 +1608,17 @@ func parseNodeBBPost(doc any) nodeBBPost { } 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: firstString(m, "content"), + PID: firstInt(m, "pid", "mainPid"), + TID: firstInt(m, "tid"), + Title: firstString(m, "title", "titleRaw", "title_raw"), + Content: content, + SourceContent: sourceContent, } } } diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index f9a3850..1ee579b 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -166,7 +166,7 @@ func TestDeployWikiReportsLiveExecutionProgress(t *testing.T) { if err := saveDeployManifest(manifestPath, wikiDeployManifest{ Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ - "feat:renamed": {Hash: computeManagedHash(renamePage), Title: "Old Renamed", TopicTitle: "Old Renamed", Namespace: "feat", TID: 7, PID: 70, CID: 5}, + "feat:renamed": {Hash: computeManagedHash(renamePage), Title: "Old Renamed", TopicTitle: "Old Renamed", Namespace: "feat", SourceContentSynced: true, TID: 7, PID: 70, CID: 5}, "feat:updated": {Hash: computeManagedHash(oldUpdate), Title: "Updated", TopicTitle: "Updated", Namespace: "feat", TID: 8, PID: 80, CID: 5}, }, }); err != nil { @@ -676,9 +676,10 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { } createCalls++ var req struct { - CID int `json:"cid"` - Title string `json:"title"` - Content string `json:"content"` + CID int `json:"cid"` + Title string `json:"title"` + Content string `json:"content"` + SourceContent string `json:"sourceContent"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode create request: %v", err) @@ -686,6 +687,9 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { if req.CID != 5 || req.Title != "Athletics" || req.Content != generated { t.Fatalf("unexpected create request: %#v", req) } + if req.SourceContent != generated { + t.Fatalf("expected create request sourceContent to match generated HTML, got %q", req.SourceContent) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "response": map[string]any{ @@ -719,6 +723,109 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { if entry.TID != 11 || entry.PID != 42 || entry.CID != 5 || entry.Hash != computeManagedHash(generated) { t.Fatalf("unexpected manifest entry: %#v", entry) } + if !entry.SourceContentSynced { + t.Fatalf("expected created manifest entry to record sourceContent sync") + } +} + +func TestParseNodeBBPostPrefersSourceContentForWikiHTML(t *testing.T) { + post := parseNodeBBPost(map[string]any{ + "response": map[string]any{ + "pid": 42, + "tid": 7, + "content": "<p>Rendered as text</p>", + "sourceContent": "
Stored source HTML
", + }, + }) + if post.Content != "Stored source HTML
" { + t.Fatalf("expected sourceContent to be canonical deploy content, got %#v", post) + } + if post.SourceContent != "Stored source HTML
" { + t.Fatalf("expected sourceContent field to be preserved, got %#v", post) + } +} + +func TestDeployWikiRepairsManifestedPageMissingSourceContentSync(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "skills"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +Ability: Strength.
+| Key Ability | STR |
|---|