diff --git a/README.md b/README.md index c258363..b138474 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,7 @@ topdata: managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta] deploy_manifest: .wiki_deploy_manifest.json deploy_edit_summary: Auto-generated from native builder + title_prefix_min_length: 3 autogen: cache: @@ -332,6 +333,13 @@ instead. Updates and stale-page archives acquire a Westgate Wiki edit lock before saving, then send the returned `wikiEditLockToken` with the NodeBB post edit request. +For newly created NodeBB topics, `topdata.wiki.title_prefix_min_length` +controls when the deployer prefixes the namespace in the topic title. The +default is `3`, so one- and two-character page titles are created as +`Namespace: Title`, while titles with three or more characters keep the page +title alone. Existing bot-managed pages with older prefixed titles are renamed +on later deploy passes when their current title no longer matches this policy. + `build-changelog` renders a release changelog from tagged pushes. Pull-request style commits keep their PR links and authors from the Gitea API; direct pushes are rendered as commit links using the commit author. It defaults to diff --git a/internal/project/effective.go b/internal/project/effective.go index bb2406b..745cd1c 100644 --- a/internal/project/effective.go +++ b/internal/project/effective.go @@ -13,51 +13,52 @@ import ( ) const ( - DefaultBuildPath = "build" - DefaultCachePath = ".cache" - DefaultToolsPath = "tools" - DefaultModuleArchiveTemplate = "{module.resref}.mod" - DefaultHAKManifest = "haks.json" - DefaultHAKArchiveTemplate = "{hak.name}.hak" - DefaultSourceJSONPattern = "{resref}.{extension}.json" - DefaultValidationProfile = "nwn_module" - DefaultScriptsSourceDir = "scripts" - DefaultScriptsCache = "{paths.cache}/ncs" - DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER" - DefaultMusicStageRoot = "{paths.cache}/music" - DefaultCreditsRoot = "{paths.cache}/credits" - DefaultCreditsOverlayFile = "CREDITS.md" - DefaultMusicNamingScheme = "nwn_bmu" - DefaultMusicOutputExtension = ".bmu" - DefaultTopDataBuild = "{paths.build}/topdata" - DefaultTopDataCompiled2DADir = "2da" - DefaultTopDataCompiledTLK = "sow_tlk.tlk" - DefaultTopDataWikiOutputRoot = "wiki" - DefaultTopDataWikiPagesDir = "pages" - DefaultTopDataWikiStateFile = "state.json" - DefaultTopDataWikiSource = "topdata/wiki" - DefaultTopDataWikiRenderer = "nodebb_tiptap_html" - DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" - DefaultTopDataWikiNamespacesFile = "namespaces.yaml" - DefaultTopDataWikiTemplatesDir = "templates" - DefaultTopDataWikiManualSectionsDir = "manual-sections" - DefaultTopDataWikiStaleDefault = "report" - DefaultTopDataWikiStaleLiveCleanup = "archive" - DefaultTopDataWikiMarkerFormat = "html_comments" - DefaultTopDataWikiPageMarkerPrefix = "sow-topdata-wiki" - DefaultWikiDeployManifest = ".wiki_deploy_manifest.json" - DefaultWikiDeployEditSummary = "Auto-generated from native builder" - DefaultTopDataPackageHAK = "sow_top.hak" - DefaultTopDataPackageTLK = "sow_tlk.tlk" - DefaultAutogenCacheRoot = "{paths.cache}" - DefaultAutogenCacheMaxAge = time.Hour - DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH" - DefaultPartsManifestRefreshEnv = "SOW_PARTS_MANIFEST_REFRESH" - DefaultAutogenReleaseProvider = "gitea" - DefaultAutogenServerURLEnv = "SOW_ASSETS_SERVER_URL" - DefaultAutogenRepoEnv = "SOW_ASSETS_REPO" - DefaultExtractLayout = "nwn_canonical_json" - DefaultExtractHAKDiscovery = "build_glob" + DefaultBuildPath = "build" + DefaultCachePath = ".cache" + DefaultToolsPath = "tools" + DefaultModuleArchiveTemplate = "{module.resref}.mod" + DefaultHAKManifest = "haks.json" + DefaultHAKArchiveTemplate = "{hak.name}.hak" + DefaultSourceJSONPattern = "{resref}.{extension}.json" + DefaultValidationProfile = "nwn_module" + DefaultScriptsSourceDir = "scripts" + DefaultScriptsCache = "{paths.cache}/ncs" + DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER" + DefaultMusicStageRoot = "{paths.cache}/music" + DefaultCreditsRoot = "{paths.cache}/credits" + DefaultCreditsOverlayFile = "CREDITS.md" + DefaultMusicNamingScheme = "nwn_bmu" + DefaultMusicOutputExtension = ".bmu" + DefaultTopDataBuild = "{paths.build}/topdata" + DefaultTopDataCompiled2DADir = "2da" + DefaultTopDataCompiledTLK = "sow_tlk.tlk" + DefaultTopDataWikiOutputRoot = "wiki" + DefaultTopDataWikiPagesDir = "pages" + DefaultTopDataWikiStateFile = "state.json" + DefaultTopDataWikiSource = "topdata/wiki" + DefaultTopDataWikiRenderer = "nodebb_tiptap_html" + DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" + DefaultTopDataWikiNamespacesFile = "namespaces.yaml" + DefaultTopDataWikiTemplatesDir = "templates" + DefaultTopDataWikiManualSectionsDir = "manual-sections" + DefaultTopDataWikiStaleDefault = "report" + DefaultTopDataWikiStaleLiveCleanup = "archive" + DefaultTopDataWikiMarkerFormat = "html_comments" + DefaultTopDataWikiPageMarkerPrefix = "sow-topdata-wiki" + DefaultTopDataWikiTitlePrefixMinLength = 3 + DefaultWikiDeployManifest = ".wiki_deploy_manifest.json" + DefaultWikiDeployEditSummary = "Auto-generated from native builder" + DefaultTopDataPackageHAK = "sow_top.hak" + DefaultTopDataPackageTLK = "sow_tlk.tlk" + DefaultAutogenCacheRoot = "{paths.cache}" + DefaultAutogenCacheMaxAge = time.Hour + DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH" + DefaultPartsManifestRefreshEnv = "SOW_PARTS_MANIFEST_REFRESH" + DefaultAutogenReleaseProvider = "gitea" + DefaultAutogenServerURLEnv = "SOW_ASSETS_SERVER_URL" + DefaultAutogenRepoEnv = "SOW_ASSETS_REPO" + DefaultExtractLayout = "nwn_canonical_json" + DefaultExtractHAKDiscovery = "build_glob" ) type ConfigProvenance map[string]ConfigValueProvenance @@ -232,18 +233,19 @@ func (p *Project) EffectiveConfig() EffectiveConfig { PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), Wiki: TopDataWikiConfig{ - OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), - PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), - StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile), - ManagedNamespaces: defaultStringSlice(p.Config.TopData.Wiki.ManagedNamespaces, []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}), - DeployManifest: defaultString(p.Config.TopData.Wiki.DeployManifest, DefaultWikiDeployManifest), - DeployEditSummary: defaultString(p.Config.TopData.Wiki.DeployEditSummary, DefaultWikiDeployEditSummary), - Source: defaultString(p.Config.TopData.Wiki.Source, DefaultTopDataWikiSource), - Renderer: defaultString(p.Config.TopData.Wiki.Renderer, DefaultTopDataWikiRenderer), - LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), - NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), - TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), - ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), + OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), + PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), + StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile), + ManagedNamespaces: defaultStringSlice(p.Config.TopData.Wiki.ManagedNamespaces, []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}), + DeployManifest: defaultString(p.Config.TopData.Wiki.DeployManifest, DefaultWikiDeployManifest), + DeployEditSummary: defaultString(p.Config.TopData.Wiki.DeployEditSummary, DefaultWikiDeployEditSummary), + Source: defaultString(p.Config.TopData.Wiki.Source, DefaultTopDataWikiSource), + Renderer: defaultString(p.Config.TopData.Wiki.Renderer, DefaultTopDataWikiRenderer), + LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), + NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), + TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), + ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), + TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength), StalePages: TopDataWikiStalePagesConfig{ Default: defaultString(p.Config.TopData.Wiki.StalePages.Default, DefaultTopDataWikiStaleDefault), LiveCleanup: defaultString(p.Config.TopData.Wiki.StalePages.LiveCleanup, DefaultTopDataWikiStaleLiveCleanup), @@ -471,6 +473,7 @@ func markMissingDefaults(provenance ConfigProvenance) { "topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile, "topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir, "topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir, + "topdata.wiki.title_prefix_min_length": "3", "topdata.wiki.stale_pages.default": DefaultTopDataWikiStaleDefault, "topdata.wiki.stale_pages.live_cleanup": DefaultTopDataWikiStaleLiveCleanup, "topdata.wiki.managed_region.marker_format": DefaultTopDataWikiMarkerFormat, @@ -511,6 +514,13 @@ func defaultString(value, fallback string) string { return trimmed } +func defaultInt(value, fallback int) int { + if value == 0 { + return fallback + } + return value +} + func defaultStringSlice(value, fallback []string) []string { if len(value) == 0 { return slices.Clone(fallback) diff --git a/internal/project/project.go b/internal/project/project.go index 6ad8cf4..028186d 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -204,20 +204,21 @@ type TopDataConfig struct { } type TopDataWikiConfig struct { - OutputRoot string `json:"output_root" yaml:"output_root"` - PagesDir string `json:"pages_dir" yaml:"pages_dir"` - StateFile string `json:"state_file" yaml:"state_file"` - ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"` - DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"` - DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"` - Source string `json:"source" yaml:"source"` - Renderer string `json:"renderer" yaml:"renderer"` - LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` - NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` - TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` - ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` - StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"` - ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"` + OutputRoot string `json:"output_root" yaml:"output_root"` + PagesDir string `json:"pages_dir" yaml:"pages_dir"` + StateFile string `json:"state_file" yaml:"state_file"` + ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"` + DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"` + DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"` + Source string `json:"source" yaml:"source"` + Renderer string `json:"renderer" yaml:"renderer"` + LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` + NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` + TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` + ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` + TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"` + StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"` + ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"` } type TopDataWikiStalePagesConfig struct { @@ -530,6 +531,9 @@ func (p *Project) ValidateLayout() error { default: failures = append(failures, fmt.Errorf("topdata.wiki.link_strategy %q is not supported", effective.TopData.Wiki.LinkStrategy)) } + if effective.TopData.Wiki.TitlePrefixMinLength < 1 { + failures = append(failures, fmt.Errorf("topdata.wiki.title_prefix_min_length must be at least 1")) + } for key, policy := range map[string]string{ "topdata.wiki.stale_pages.default": effective.TopData.Wiki.StalePages.Default, "topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup, diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 7679169..c4e80ee 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -266,6 +266,7 @@ topdata: manual_sections_dir: manual-sections deploy_manifest: wiki-manifest.json deploy_edit_summary: Custom summary + title_prefix_min_length: 4 stale_pages: default: report live_cleanup: archive @@ -330,6 +331,9 @@ autogen: if got, want := effective.TopData.Wiki.StalePages.LiveCleanup, "archive"; got != want { t.Fatalf("expected wiki stale live cleanup %q, got %q", want, got) } + if got, want := effective.TopData.Wiki.TitlePrefixMinLength, 4; got != want { + t.Fatalf("expected wiki title prefix minimum length %d, got %d", want, got) + } if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want { t.Fatalf("expected autogen cache max age %q, got %q", want, got) } diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go index 54c6e95..616ea90 100644 --- a/internal/topdata/wiki_deploy.go +++ b/internal/topdata/wiki_deploy.go @@ -33,20 +33,21 @@ const ( ) type DeployWikiOptions struct { - SourceDir string - Endpoint string - Token string - Version string - Namespaces []string - CategoryIDs map[string]int - ManifestPath string - DryRun bool - Force bool - AllowCreates bool - StalePolicy string - Username string - Password string - NotesDelimiter string + SourceDir string + Endpoint string + Token string + Version string + Namespaces []string + CategoryIDs map[string]int + ManifestPath string + DryRun bool + Force bool + AllowCreates bool + StalePolicy string + Username string + Password string + NotesDelimiter string + TitlePrefixMinLength int } type deployResult struct { @@ -57,6 +58,7 @@ type deployResult struct { Archived int Skipped int Drifted int + Renamed int Manifest string } @@ -78,6 +80,7 @@ type wikiDeployManifestPage struct { 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"` @@ -91,6 +94,7 @@ type wikiDeployPlan struct { Action string Content string RemoteHash string + Title string } type wikiNamespacesDocument struct { @@ -128,6 +132,9 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" { 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 { @@ -173,7 +180,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress result.LocalPages = len(pages) result.Manifest = manifestPath - progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, skip %d, stale %d, archive %d, drift %d", result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Drifted)) + progress(fmt.Sprintf("NodeBB wiki plan: create %d, update %d, rename %d, skip %d, stale %d, archive %d, drift %d", result.Created, result.Updated, result.Renamed, result.Skipped, result.Stale, result.Archived, result.Drifted)) if opts.DryRun { return result, nil } @@ -215,6 +222,10 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress 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 "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 { @@ -346,6 +357,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife 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 { @@ -376,11 +388,22 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife return nil, result, next, fmt.Errorf("wiki page %q requires --category %s= for creation", pageID, page.Namespace) } result.Created++ - page.Title = nodeBBTopicTitle(page) + page.Title = entry.TopicTitle 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) { + remotePage, ok, err := findMappedRemotePage(entry, remotePagesByCID, client) + if err != nil { + return nil, result, next, err + } + if ok && titleNeedsRename(remotePageTitle(remotePage), entry.TopicTitle) { + result.Renamed++ + plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "rename", Title: entry.TopicTitle}) + } + } + if manifest.Pages[pageID].Hash == page.Hash { result.Skipped++ continue @@ -442,6 +465,49 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife return plans, result, next, nil } +func findMappedRemotePage(entry wikiDeployManifestPage, remotePagesByCID map[int][]nodeBBWikiPage, client *nodeBBClient) (nodeBBWikiPage, bool, error) { + remotePages, ok := remotePagesByCID[entry.CID] + if !ok { + var err error + remotePages, err = client.listNamespacePages(entry.CID) + if err != nil { + return nodeBBWikiPage{}, false, err + } + remotePagesByCID[entry.CID] = remotePages + } + for _, remotePage := range remotePages { + if remotePage.TID == entry.TID { + return remotePage, true, nil + } + } + return nodeBBWikiPage{}, false, nil +} + +func remotePageTitle(page nodeBBWikiPage) string { + if title := strings.TrimSpace(page.Title); title != "" { + return title + } + return strings.TrimSpace(page.TitleLeaf) +} + +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 || !ok { @@ -461,6 +527,7 @@ func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, cl 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 manifest.Pages[plan.Page.PageID] = entry @@ -831,9 +898,12 @@ func extractHTMLTitle(content, fallback string) string { return namespaceTitle(parts[len(parts)-1]) } -func nodeBBTopicTitle(page wikiDeployPage) string { +func nodeBBTopicTitle(page wikiDeployPage, minLength int) string { + if minLength <= 0 { + minLength = project.DefaultTopDataWikiTitlePrefixMinLength + } title := strings.TrimSpace(page.Title) - if len([]rune(title)) >= 5 { + if len([]rune(title)) >= minLength { return title } namespace := strings.TrimSpace(namespaceTitle(page.Namespace)) @@ -1007,6 +1077,14 @@ func (c *nodeBBClient) updatePost(tid, pid int, content, summary string) error { 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) acquireEditLock(tid int) (nodeBBEditLock, error) { if tid == 0 { return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki post update requires a topic id for edit locking") diff --git a/internal/topdata/wiki_deploy_test.go b/internal/topdata/wiki_deploy_test.go index 9585165..b9d5686 100644 --- a/internal/topdata/wiki_deploy_test.go +++ b/internal/topdata/wiki_deploy_test.go @@ -147,7 +147,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) { } } -func TestDeployWikiCreatesNodeBBTopicWithFallbackForShortTitle(t *testing.T) { +func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages") if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil { @@ -174,8 +174,8 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForShortTitle(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Fatalf("decode create request: %v", err) } - if req.Title != "Spells: Aid" { - t.Fatalf("expected fallback title, got %q", req.Title) + if req.Title != "Aid" { + t.Fatalf("expected unprefixed title, got %q", req.Title) } if req.Content != generated { t.Fatalf("expected page body to keep short h1 unchanged:\n%s", req.Content) @@ -206,6 +206,139 @@ func TestDeployWikiCreatesNodeBBTopicWithFallbackForShortTitle(t *testing.T) { } } +func TestDeployWikiCreatesNodeBBTopicWithFallbackForTitleShorterThanConfiguredMinimum(t *testing.T) { + root := t.TempDir() + sourceDir := filepath.Join(root, "pages") + if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil { + t.Fatalf("create source dir: %v", err) + } + generated := "[//]: # (sow-topdata-wiki:managed:start)\n# Ox\n\nGenerated spell page\n[//]: # (sow-topdata-wiki:managed:end)\n" + if err := os.WriteFile(filepath.Join(sourceDir, "spells", "ox.md"), []byte(generated), 0644); err != nil { + t.Fatalf("write source page: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v3/plugins/westgate-wiki/namespace/5/pages" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"pages": []any{}, "hasMore": false}}) + return + } + if r.Method != http.MethodPost || r.URL.Path != "/api/v3/topics" { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + var req struct { + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode create request: %v", err) + } + if req.Title != "Spells: Ox" { + t.Fatalf("expected fallback title, got %q", req.Title) + } + 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": 42, + "tid": 11, + }, + }, + }) + })) + defer server.Close() + + _, err := DeployWikiWithOptions(&project.Project{}, DeployWikiOptions{ + SourceDir: sourceDir, + Endpoint: server.URL, + Token: "nodebb-token", + ManifestPath: filepath.Join(root, "deploy-manifest.json"), + CategoryIDs: map[string]int{"spells": 5}, + AllowCreates: true, + }, nil) + if err != nil { + t.Fatalf("DeployWikiWithOptions create failed: %v", err) + } +} + +func TestDeployWikiRenamesExistingPrefixedTopicWhenTitleIsLongEnough(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 := ` + +

Belt

+

Generated belt page

+ +` + if err := os.WriteFile(filepath.Join(sourceDir, "itemtypes", "belt.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{ + "itemtypes:belt": {Hash: computeManagedHash(generated), Title: "Belt", Namespace: "itemtypes", TID: 7, PID: 42, CID: 5}, + }, + }); err != nil { + t.Fatalf("write deploy manifest: %v", err) + } + + renameCalls := 0 + 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/5/pages": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "response": map[string]any{ + "pages": []map[string]any{ + {"tid": 7, "title": "Itemtypes: Belt", "titleLeaf": "Itemtypes: Belt", "slug": "7/itemtypes-belt", "wikiPath": "/wiki/itemtypes/itemtypes-belt"}, + }, + "hasMore": false, + }, + }) + case r.Method == http.MethodPut && r.URL.Path == "/api/v3/plugins/westgate-wiki/page/move": + renameCalls++ + var req struct { + TID int `json:"tid"` + CID int `json:"cid"` + Title string `json:"title"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode rename request: %v", err) + } + if req.TID != 7 || req.CID != 5 || req.Title != "Belt" { + t.Fatalf("unexpected rename request: %#v", req) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"response": map[string]any{"tid": 7, "cid": 5, "title": "Belt"}}) + 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 rename cleanup failed: %v", err) + } + if result.Renamed != 1 || result.Updated != 0 || result.Skipped != 1 || renameCalls != 1 { + t.Fatalf("expected one rename cleanup and skipped body update, result=%#v renameCalls=%d", result, renameCalls) + } + manifest := loadDeployManifest(manifestPath) + entry := manifest.Pages["itemtypes:belt"] + if entry.TopicTitle != "Belt" || entry.Title != "Belt" { + t.Fatalf("expected manifest to record cleaned topic title, got %#v", entry) + } +} + func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) { root := t.TempDir() sourceDir := filepath.Join(root, "pages")