diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index b714c49..31b0329 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -195,7 +195,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress if opts.Version != "" { summary = fmt.Sprintf("%s (%s)", summary, opts.Version) } - for _, plan := range plans { + for _, plan := range orderNodeBBDeployPlans(plans) { switch plan.Action { case "create": created, err := client.createTopic(plan.Entry.CID, plan.Page.Title, plan.Content, summary) @@ -241,6 +241,24 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress return result, nil } +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)) @@ -687,7 +705,13 @@ func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[ } func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient) (wikiDeployManifestPage, bool, error) { - queries := []string{pageIDLeaf(page.PageID), page.Title, pageIDRemainder(page.PageID)} + 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 == "" { @@ -708,6 +732,24 @@ func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient) return wikiDeployManifestPage{}, false, nil } +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 { diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index ab207fd..469f61b 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -1197,6 +1197,110 @@ func TestDeployWikiCreateCollisionAdoptsExistingNodeBBPage(t *testing.T) { } } +func TestDeployWikiCreateCollisionSearchesCanonicalTitleSegment(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "itemtypes"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := ` + +
Generated wand page
+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "itemtypes", "Wand_not_enchanted.html"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + old := ` + +Old wand page
+ +` + + createCalls := 0 + var updated string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": + createCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": map[string]any{ + "code": "bad-request", + "message": "A wiki page with this URL already exists in this namespace. Rename the page before publishing.", + }, + "response": map[string]any{}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "blank_magicwand": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "Wand (not enchanted)": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/58/pages" && r.URL.Query().Get("q") == "Wand_not_enchanted": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 77, "title": "Wand (not enchanted)", "titleLeaf": "Wand (not enchanted)", "slug": "77/wand-not-enchanted", "wikiPath": "/wiki/Itemtypes/Wand_not_enchanted"}, + }, + "hasMore": false, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/topics/77": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 77, "mainPid": 88}}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/posts/88": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77, "content": old}}) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/edit-lock": + respondWikiEditLock(t, w, r, 77, "wand-collision-lock") + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/posts/88": + var req struct { + Content string `json:"content"` + WikiEditLockToken string `json:"wikiEditLockToken"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode update request: %v", err) + } + if req.WikiEditLockToken != "wand-collision-lock" { + t.Fatalf("expected collision recovery update to include edit lock token, got %q", req.WikiEditLockToken) + } + updated = req.Content + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pid": 88, "tid": 77}}) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.String()) + } + })) + defer server.Close() + + manifestPath := filepath.Join(root, "deploy-manifest.json") + result, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: manifestPath, + CategoryIDs: map[string]int{"itemtypes": 58}, + AllowCreates: true, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions collision recovery failed: %v", err) + } + if result.Created != 0 || result.Updated != 1 || createCalls != 1 { + t.Fatalf("expected collision recovery to update existing page, result=%#v createCalls=%d", result, createCalls) + } + if !containsAll(updated, "Generated wand page
") { + t.Fatalf("expected collision recovery to update adopted page:\n%s", updated) + } +} + func TestDeployWikiReportsAndArchivesStalePages(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") @@ -1395,6 +1499,82 @@ func TestDeployWikiPurgesTrackedStaleGeneratedTopic(t *testing.T) { } } +func TestDeployWikiPurgesTrackedStaleTopicsBeforeCreatingReplacementPages(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 := ` + +Generated replacement page
+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "skills", "replacement.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:retired": {Hash: "old-hash", Title: "Replacement", Namespace: "skills", TID: 7, PID: 42, CID: 3}, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/3/pages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v3/topics/7": + calls = append(calls, "purge") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"status": map[string]any{"code": "ok"}}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v3/topics": + calls = append(calls, "create") + if len(calls) != 2 || calls[0] != "purge" { + t.Fatalf("expected stale purge before create, got calls %#v", calls) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "tid": 11, + "mainPost": map[string]any{ + "pid": 99, + "tid": 11, + }, + }, + }) + 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, + CategoryIDs: map[string]int{"skills": 3}, + AllowCreates: true, + StalePolicy: "purge", + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions purge-and-create failed: %v", err) + } + if result.Created != 1 || result.Purged != 1 { + t.Fatalf("expected one create and one purge, got %#v", result) + } + if strings.Join(calls, ",") != "purge,create" { + t.Fatalf("expected purge before create, got %#v", calls) + } +} + func TestDeployWikiPurgeTreatsAlreadyMissingTrackedTopicAsSuccess(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")