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 AbilitySTR
+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "skills", "Athletics.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + manifestPath := filepath.Join(root, "deploy-manifest.json") + if err := saveDeployManifest(manifestPath, wikiDeployManifest{ + Version: "nodebb-v1", + Pages: map[string]wikiDeployManifestPage{ + "skills:athletics": { + Hash: computeManagedHash(generated), + Title: "Athletics", + TopicTitle: "Athletics", + Namespace: "skills", + TID: 7, + PID: 42, + CID: 3, + }, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + updateCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/42": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7, "content": generated}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock": + respondWikiEditLock(t, w, r, 7, "source-sync-lock") + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/42": + updateCalls++ + var req struct { + Content string `json:"content"` + SourceContent string `json:"sourceContent"` + WikiEditLockToken string `json:"wikiEditLockToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode update request: %v", err) + } + if req.WikiEditLockToken != "source-sync-lock" { + t.Fatalf("expected update to include wiki edit lock token, got %q", req.WikiEditLockToken) + } + if req.Content != generated || req.SourceContent != generated { + t.Fatalf("expected repair update to write content and sourceContent, got %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 42, "tid": 7}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions sourceContent repair failed: %v", err) + } + if result.Updated != 1 || result.Skipped != 0 || updateCalls != 1 { + t.Fatalf("expected one sourceContent repair update, result=%#v updateCalls=%d", result, updateCalls) + } + entry := loadDeployManifest(manifestPath).Pages["skills:athletics"] + if !entry.SourceContentSynced { + t.Fatalf("expected repair deploy to record sourceContent sync, got %#v", entry) + } } func TestMergeManagedContentPreservesUserTopAndBottomSections(t *testing.T) { @@ -897,7 +1004,7 @@ func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(t *testing. if err := saveDeployManifest(manifestPath, wikiDeployManifest{ Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ - "itemtypes:belt": {Hash: computeManagedHash(generated), Title: "Belt", Namespace: "itemtypes", TID: 7, PID: 42, CID: 5}, + "itemtypes:belt": {Hash: computeManagedHash(generated), Title: "Belt", Namespace: "itemtypes", SourceContentSynced: true, TID: 7, PID: 42, CID: 5}, }, }); err != nil { t.Fatalf("write deploy manifest: %v", err) @@ -975,13 +1082,14 @@ func TestDeployWikiRenamesManagedTopicWhenGeneratedTitleChanges(t *testing.T) { Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ "feat:weapon:focus:dwaxe": { - Hash: computeManagedHash(generated), - Title: "Weapon Focus (Dwaxe)", - TopicTitle: "Weapon Focus (Dwaxe)", - Namespace: "feat", - TID: 7, - PID: 42, - CID: 5, + Hash: computeManagedHash(generated), + Title: "Weapon Focus (Dwaxe)", + TopicTitle: "Weapon Focus (Dwaxe)", + Namespace: "feat", + SourceContentSynced: true, + TID: 7, + PID: 42, + CID: 5, }, }, }); err != nil { @@ -1067,13 +1175,14 @@ func TestDeployWikiDoesNotRenameHeadinglessPageToPageIDFallback(t *testing.T) { Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ "spells:acid_fog": { - Hash: computeManagedHash(generated), - Title: "Acid Fog", - TopicTitle: "Acid Fog", - Namespace: "spells", - TID: 7, - PID: 42, - CID: 5, + Hash: computeManagedHash(generated), + Title: "Acid Fog", + TopicTitle: "Acid Fog", + Namespace: "spells", + SourceContentSynced: true, + TID: 7, + PID: 42, + CID: 5, }, }, }); err != nil { @@ -1127,13 +1236,14 @@ func TestDeployWikiRenamesBrokenHeadinglessFallbackTitleBackToPageIndexTitle(t * Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ "spells:acid_fog": { - Hash: computeManagedHash(generated), - Title: "Acid_fog", - TopicTitle: "Acid_fog", - Namespace: "spells", - TID: 7, - PID: 42, - CID: 5, + Hash: computeManagedHash(generated), + Title: "Acid_fog", + TopicTitle: "Acid_fog", + Namespace: "spells", + SourceContentSynced: true, + TID: 7, + PID: 42, + CID: 5, }, }, }); err != nil { @@ -2148,12 +2258,13 @@ func TestDeployWikiPurgeDoesNotTargetCurrentGeneratedPages(t *testing.T) { Version: "nodebb-v1", Pages: map[string]wikiDeployManifestPage{ "skills:athletics": { - Hash: computeManagedHash(generated), - Title: "Athletics", - Namespace: "skills", - TID: 7, - PID: 42, - CID: 3, + Hash: computeManagedHash(generated), + Title: "Athletics", + Namespace: "skills", + SourceContentSynced: true, + TID: 7, + PID: 42, + CID: 3, }, }, }); err != nil {