Wiki title prefix policy

This commit is contained in:
2026-05-16 23:58:46 +02:00
parent d34a640efe
commit faf1459dba
6 changed files with 329 additions and 92 deletions
+8
View File
@@ -215,6 +215,7 @@ topdata:
managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta] managed_namespaces: [classes, feat, itemtypes, races, skills, spells, meta]
deploy_manifest: .wiki_deploy_manifest.json deploy_manifest: .wiki_deploy_manifest.json
deploy_edit_summary: Auto-generated from native builder deploy_edit_summary: Auto-generated from native builder
title_prefix_min_length: 3
autogen: autogen:
cache: 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 before saving, then send the returned `wikiEditLockToken` with the NodeBB post
edit request. 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 `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 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 are rendered as commit links using the commit author. It defaults to
+67 -57
View File
@@ -13,51 +13,52 @@ import (
) )
const ( const (
DefaultBuildPath = "build" DefaultBuildPath = "build"
DefaultCachePath = ".cache" DefaultCachePath = ".cache"
DefaultToolsPath = "tools" DefaultToolsPath = "tools"
DefaultModuleArchiveTemplate = "{module.resref}.mod" DefaultModuleArchiveTemplate = "{module.resref}.mod"
DefaultHAKManifest = "haks.json" DefaultHAKManifest = "haks.json"
DefaultHAKArchiveTemplate = "{hak.name}.hak" DefaultHAKArchiveTemplate = "{hak.name}.hak"
DefaultSourceJSONPattern = "{resref}.{extension}.json" DefaultSourceJSONPattern = "{resref}.{extension}.json"
DefaultValidationProfile = "nwn_module" DefaultValidationProfile = "nwn_module"
DefaultScriptsSourceDir = "scripts" DefaultScriptsSourceDir = "scripts"
DefaultScriptsCache = "{paths.cache}/ncs" DefaultScriptsCache = "{paths.cache}/ncs"
DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER" DefaultScriptCompilerEnvPath = "SOW_NWN_SCRIPT_COMPILER"
DefaultMusicStageRoot = "{paths.cache}/music" DefaultMusicStageRoot = "{paths.cache}/music"
DefaultCreditsRoot = "{paths.cache}/credits" DefaultCreditsRoot = "{paths.cache}/credits"
DefaultCreditsOverlayFile = "CREDITS.md" DefaultCreditsOverlayFile = "CREDITS.md"
DefaultMusicNamingScheme = "nwn_bmu" DefaultMusicNamingScheme = "nwn_bmu"
DefaultMusicOutputExtension = ".bmu" DefaultMusicOutputExtension = ".bmu"
DefaultTopDataBuild = "{paths.build}/topdata" DefaultTopDataBuild = "{paths.build}/topdata"
DefaultTopDataCompiled2DADir = "2da" DefaultTopDataCompiled2DADir = "2da"
DefaultTopDataCompiledTLK = "sow_tlk.tlk" DefaultTopDataCompiledTLK = "sow_tlk.tlk"
DefaultTopDataWikiOutputRoot = "wiki" DefaultTopDataWikiOutputRoot = "wiki"
DefaultTopDataWikiPagesDir = "pages" DefaultTopDataWikiPagesDir = "pages"
DefaultTopDataWikiStateFile = "state.json" DefaultTopDataWikiStateFile = "state.json"
DefaultTopDataWikiSource = "topdata/wiki" DefaultTopDataWikiSource = "topdata/wiki"
DefaultTopDataWikiRenderer = "nodebb_tiptap_html" DefaultTopDataWikiRenderer = "nodebb_tiptap_html"
DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links" DefaultTopDataWikiLinkStrategy = "preserve_westgate_wiki_links"
DefaultTopDataWikiNamespacesFile = "namespaces.yaml" DefaultTopDataWikiNamespacesFile = "namespaces.yaml"
DefaultTopDataWikiTemplatesDir = "templates" DefaultTopDataWikiTemplatesDir = "templates"
DefaultTopDataWikiManualSectionsDir = "manual-sections" DefaultTopDataWikiManualSectionsDir = "manual-sections"
DefaultTopDataWikiStaleDefault = "report" DefaultTopDataWikiStaleDefault = "report"
DefaultTopDataWikiStaleLiveCleanup = "archive" DefaultTopDataWikiStaleLiveCleanup = "archive"
DefaultTopDataWikiMarkerFormat = "html_comments" DefaultTopDataWikiMarkerFormat = "html_comments"
DefaultTopDataWikiPageMarkerPrefix = "sow-topdata-wiki" DefaultTopDataWikiPageMarkerPrefix = "sow-topdata-wiki"
DefaultWikiDeployManifest = ".wiki_deploy_manifest.json" DefaultTopDataWikiTitlePrefixMinLength = 3
DefaultWikiDeployEditSummary = "Auto-generated from native builder" DefaultWikiDeployManifest = ".wiki_deploy_manifest.json"
DefaultTopDataPackageHAK = "sow_top.hak" DefaultWikiDeployEditSummary = "Auto-generated from native builder"
DefaultTopDataPackageTLK = "sow_tlk.tlk" DefaultTopDataPackageHAK = "sow_top.hak"
DefaultAutogenCacheRoot = "{paths.cache}" DefaultTopDataPackageTLK = "sow_tlk.tlk"
DefaultAutogenCacheMaxAge = time.Hour DefaultAutogenCacheRoot = "{paths.cache}"
DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH" DefaultAutogenCacheMaxAge = time.Hour
DefaultPartsManifestRefreshEnv = "SOW_PARTS_MANIFEST_REFRESH" DefaultAutogenRefreshEnv = "SOW_AUTOGEN_MANIFEST_REFRESH"
DefaultAutogenReleaseProvider = "gitea" DefaultPartsManifestRefreshEnv = "SOW_PARTS_MANIFEST_REFRESH"
DefaultAutogenServerURLEnv = "SOW_ASSETS_SERVER_URL" DefaultAutogenReleaseProvider = "gitea"
DefaultAutogenRepoEnv = "SOW_ASSETS_REPO" DefaultAutogenServerURLEnv = "SOW_ASSETS_SERVER_URL"
DefaultExtractLayout = "nwn_canonical_json" DefaultAutogenRepoEnv = "SOW_ASSETS_REPO"
DefaultExtractHAKDiscovery = "build_glob" DefaultExtractLayout = "nwn_canonical_json"
DefaultExtractHAKDiscovery = "build_glob"
) )
type ConfigProvenance map[string]ConfigValueProvenance type ConfigProvenance map[string]ConfigValueProvenance
@@ -232,18 +233,19 @@ func (p *Project) EffectiveConfig() EffectiveConfig {
PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK), PackageHAK: defaultString(p.Config.TopData.PackageHAK, DefaultTopDataPackageHAK),
PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK), PackageTLK: defaultString(p.Config.TopData.PackageTLK, DefaultTopDataPackageTLK),
Wiki: TopDataWikiConfig{ Wiki: TopDataWikiConfig{
OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot), OutputRoot: defaultString(p.Config.TopData.Wiki.OutputRoot, DefaultTopDataWikiOutputRoot),
PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir), PagesDir: defaultString(p.Config.TopData.Wiki.PagesDir, DefaultTopDataWikiPagesDir),
StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile), StateFile: defaultString(p.Config.TopData.Wiki.StateFile, DefaultTopDataWikiStateFile),
ManagedNamespaces: defaultStringSlice(p.Config.TopData.Wiki.ManagedNamespaces, []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}), ManagedNamespaces: defaultStringSlice(p.Config.TopData.Wiki.ManagedNamespaces, []string{"classes", "feat", "itemtypes", "races", "skills", "spells", "meta"}),
DeployManifest: defaultString(p.Config.TopData.Wiki.DeployManifest, DefaultWikiDeployManifest), DeployManifest: defaultString(p.Config.TopData.Wiki.DeployManifest, DefaultWikiDeployManifest),
DeployEditSummary: defaultString(p.Config.TopData.Wiki.DeployEditSummary, DefaultWikiDeployEditSummary), DeployEditSummary: defaultString(p.Config.TopData.Wiki.DeployEditSummary, DefaultWikiDeployEditSummary),
Source: defaultString(p.Config.TopData.Wiki.Source, DefaultTopDataWikiSource), Source: defaultString(p.Config.TopData.Wiki.Source, DefaultTopDataWikiSource),
Renderer: defaultString(p.Config.TopData.Wiki.Renderer, DefaultTopDataWikiRenderer), Renderer: defaultString(p.Config.TopData.Wiki.Renderer, DefaultTopDataWikiRenderer),
LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy), LinkStrategy: defaultString(p.Config.TopData.Wiki.LinkStrategy, DefaultTopDataWikiLinkStrategy),
NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile), NamespacesFile: defaultString(p.Config.TopData.Wiki.NamespacesFile, DefaultTopDataWikiNamespacesFile),
TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir), TemplatesDir: defaultString(p.Config.TopData.Wiki.TemplatesDir, DefaultTopDataWikiTemplatesDir),
ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir), ManualSectionsDir: defaultString(p.Config.TopData.Wiki.ManualSectionsDir, DefaultTopDataWikiManualSectionsDir),
TitlePrefixMinLength: defaultInt(p.Config.TopData.Wiki.TitlePrefixMinLength, DefaultTopDataWikiTitlePrefixMinLength),
StalePages: TopDataWikiStalePagesConfig{ StalePages: TopDataWikiStalePagesConfig{
Default: defaultString(p.Config.TopData.Wiki.StalePages.Default, DefaultTopDataWikiStaleDefault), Default: defaultString(p.Config.TopData.Wiki.StalePages.Default, DefaultTopDataWikiStaleDefault),
LiveCleanup: defaultString(p.Config.TopData.Wiki.StalePages.LiveCleanup, DefaultTopDataWikiStaleLiveCleanup), LiveCleanup: defaultString(p.Config.TopData.Wiki.StalePages.LiveCleanup, DefaultTopDataWikiStaleLiveCleanup),
@@ -471,6 +473,7 @@ func markMissingDefaults(provenance ConfigProvenance) {
"topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile, "topdata.wiki.namespaces_file": DefaultTopDataWikiNamespacesFile,
"topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir, "topdata.wiki.templates_dir": DefaultTopDataWikiTemplatesDir,
"topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir, "topdata.wiki.manual_sections_dir": DefaultTopDataWikiManualSectionsDir,
"topdata.wiki.title_prefix_min_length": "3",
"topdata.wiki.stale_pages.default": DefaultTopDataWikiStaleDefault, "topdata.wiki.stale_pages.default": DefaultTopDataWikiStaleDefault,
"topdata.wiki.stale_pages.live_cleanup": DefaultTopDataWikiStaleLiveCleanup, "topdata.wiki.stale_pages.live_cleanup": DefaultTopDataWikiStaleLiveCleanup,
"topdata.wiki.managed_region.marker_format": DefaultTopDataWikiMarkerFormat, "topdata.wiki.managed_region.marker_format": DefaultTopDataWikiMarkerFormat,
@@ -511,6 +514,13 @@ func defaultString(value, fallback string) string {
return trimmed return trimmed
} }
func defaultInt(value, fallback int) int {
if value == 0 {
return fallback
}
return value
}
func defaultStringSlice(value, fallback []string) []string { func defaultStringSlice(value, fallback []string) []string {
if len(value) == 0 { if len(value) == 0 {
return slices.Clone(fallback) return slices.Clone(fallback)
+18 -14
View File
@@ -204,20 +204,21 @@ type TopDataConfig struct {
} }
type TopDataWikiConfig struct { type TopDataWikiConfig struct {
OutputRoot string `json:"output_root" yaml:"output_root"` OutputRoot string `json:"output_root" yaml:"output_root"`
PagesDir string `json:"pages_dir" yaml:"pages_dir"` PagesDir string `json:"pages_dir" yaml:"pages_dir"`
StateFile string `json:"state_file" yaml:"state_file"` StateFile string `json:"state_file" yaml:"state_file"`
ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"` ManagedNamespaces []string `json:"managed_namespaces" yaml:"managed_namespaces"`
DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"` DeployManifest string `json:"deploy_manifest" yaml:"deploy_manifest"`
DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"` DeployEditSummary string `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
Source string `json:"source" yaml:"source"` Source string `json:"source" yaml:"source"`
Renderer string `json:"renderer" yaml:"renderer"` Renderer string `json:"renderer" yaml:"renderer"`
LinkStrategy string `json:"link_strategy" yaml:"link_strategy"` LinkStrategy string `json:"link_strategy" yaml:"link_strategy"`
NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"` NamespacesFile string `json:"namespaces_file" yaml:"namespaces_file"`
TemplatesDir string `json:"templates_dir" yaml:"templates_dir"` TemplatesDir string `json:"templates_dir" yaml:"templates_dir"`
ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"` ManualSectionsDir string `json:"manual_sections_dir" yaml:"manual_sections_dir"`
StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"` TitlePrefixMinLength int `json:"title_prefix_min_length" yaml:"title_prefix_min_length"`
ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"` StalePages TopDataWikiStalePagesConfig `json:"stale_pages" yaml:"stale_pages"`
ManagedRegion TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"`
} }
type TopDataWikiStalePagesConfig struct { type TopDataWikiStalePagesConfig struct {
@@ -530,6 +531,9 @@ func (p *Project) ValidateLayout() error {
default: default:
failures = append(failures, fmt.Errorf("topdata.wiki.link_strategy %q is not supported", effective.TopData.Wiki.LinkStrategy)) 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{ for key, policy := range map[string]string{
"topdata.wiki.stale_pages.default": effective.TopData.Wiki.StalePages.Default, "topdata.wiki.stale_pages.default": effective.TopData.Wiki.StalePages.Default,
"topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup, "topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup,
+4
View File
@@ -266,6 +266,7 @@ topdata:
manual_sections_dir: manual-sections manual_sections_dir: manual-sections
deploy_manifest: wiki-manifest.json deploy_manifest: wiki-manifest.json
deploy_edit_summary: Custom summary deploy_edit_summary: Custom summary
title_prefix_min_length: 4
stale_pages: stale_pages:
default: report default: report
live_cleanup: archive live_cleanup: archive
@@ -330,6 +331,9 @@ autogen:
if got, want := effective.TopData.Wiki.StalePages.LiveCleanup, "archive"; got != want { if got, want := effective.TopData.Wiki.StalePages.LiveCleanup, "archive"; got != want {
t.Fatalf("expected wiki stale live cleanup %q, got %q", want, got) 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 { if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want {
t.Fatalf("expected autogen cache max age %q, got %q", want, got) t.Fatalf("expected autogen cache max age %q, got %q", want, got)
} }
+96 -18
View File
@@ -33,20 +33,21 @@ const (
) )
type DeployWikiOptions struct { type DeployWikiOptions struct {
SourceDir string SourceDir string
Endpoint string Endpoint string
Token string Token string
Version string Version string
Namespaces []string Namespaces []string
CategoryIDs map[string]int CategoryIDs map[string]int
ManifestPath string ManifestPath string
DryRun bool DryRun bool
Force bool Force bool
AllowCreates bool AllowCreates bool
StalePolicy string StalePolicy string
Username string Username string
Password string Password string
NotesDelimiter string NotesDelimiter string
TitlePrefixMinLength int
} }
type deployResult struct { type deployResult struct {
@@ -57,6 +58,7 @@ type deployResult struct {
Archived int Archived int
Skipped int Skipped int
Drifted int Drifted int
Renamed int
Manifest string Manifest string
} }
@@ -78,6 +80,7 @@ type wikiDeployManifestPage struct {
LastSeenHash string `json:"last_seen_hash,omitempty"` LastSeenHash string `json:"last_seen_hash,omitempty"`
ArchivedHash string `json:"archived_hash,omitempty"` ArchivedHash string `json:"archived_hash,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
TopicTitle string `json:"topic_title,omitempty"`
Namespace string `json:"namespace,omitempty"` Namespace string `json:"namespace,omitempty"`
Stale bool `json:"stale,omitempty"` Stale bool `json:"stale,omitempty"`
TID int `json:"tid,omitempty"` TID int `json:"tid,omitempty"`
@@ -91,6 +94,7 @@ type wikiDeployPlan struct {
Action string Action string
Content string Content string
RemoteHash string RemoteHash string
Title string
} }
type wikiNamespacesDocument struct { type wikiNamespacesDocument struct {
@@ -128,6 +132,9 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" { if opts.StalePolicy != "" && opts.StalePolicy != "report" && opts.StalePolicy != "archive" {
return deployResult{}, fmt.Errorf("wiki stale policy %q is not supported", opts.StalePolicy) 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 namespaces := opts.Namespaces
if len(namespaces) == 0 { if len(namespaces) == 0 {
@@ -173,7 +180,7 @@ func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress
result.LocalPages = len(pages) result.LocalPages = len(pages)
result.Manifest = manifestPath 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 { if opts.DryRun {
return result, nil 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 { 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) 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 { if err := saveDeployManifest(manifestPath, nextManifest); err != nil {
@@ -346,6 +357,7 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
entry.Hash = page.Hash entry.Hash = page.Hash
entry.LastSeenHash = page.Hash entry.LastSeenHash = page.Hash
entry.Title = page.Title entry.Title = page.Title
entry.TopicTitle = nodeBBTopicTitle(page, opts.TitlePrefixMinLength)
entry.Namespace = page.Namespace entry.Namespace = page.Namespace
entry.Stale = false entry.Stale = false
if entry.CID == 0 { 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=<cid> for creation", pageID, page.Namespace) return nil, result, next, fmt.Errorf("wiki page %q requires --category %s=<cid> for creation", pageID, page.Namespace)
} }
result.Created++ result.Created++
page.Title = nodeBBTopicTitle(page) page.Title = entry.TopicTitle
plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "create", Content: page.Content}) plans = append(plans, wikiDeployPlan{Page: page, Entry: entry, Action: "create", Content: page.Content})
continue 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 { if manifest.Pages[pageID].Hash == page.Hash {
result.Skipped++ result.Skipped++
continue continue
@@ -442,6 +465,49 @@ func planNodeBBDeploy(pages map[string]wikiDeployPage, manifest wikiDeployManife
return plans, result, next, nil 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) { func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, client *nodeBBClient, summary string) (bool, error) {
entry, ok, err := findExistingNodeBBPage(plan.Page, plan.Entry.CID, client) entry, ok, err := findExistingNodeBBPage(plan.Page, plan.Entry.CID, client)
if err != nil || !ok { if err != nil || !ok {
@@ -461,6 +527,7 @@ func recoverCreateCollision(plan wikiDeployPlan, manifest wikiDeployManifest, cl
entry.Hash = plan.Entry.Hash entry.Hash = plan.Entry.Hash
entry.LastSeenHash = plan.Entry.LastSeenHash entry.LastSeenHash = plan.Entry.LastSeenHash
entry.Title = plan.Entry.Title entry.Title = plan.Entry.Title
entry.TopicTitle = plan.Entry.TopicTitle
entry.Namespace = plan.Entry.Namespace entry.Namespace = plan.Entry.Namespace
entry.Stale = false entry.Stale = false
manifest.Pages[plan.Page.PageID] = entry manifest.Pages[plan.Page.PageID] = entry
@@ -831,9 +898,12 @@ func extractHTMLTitle(content, fallback string) string {
return namespaceTitle(parts[len(parts)-1]) 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) title := strings.TrimSpace(page.Title)
if len([]rune(title)) >= 5 { if len([]rune(title)) >= minLength {
return title return title
} }
namespace := strings.TrimSpace(namespaceTitle(page.Namespace)) 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) 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) { func (c *nodeBBClient) acquireEditLock(tid int) (nodeBBEditLock, error) {
if tid == 0 { if tid == 0 {
return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki post update requires a topic id for edit locking") return nodeBBEditLock{}, fmt.Errorf("NodeBB wiki post update requires a topic id for edit locking")
+136 -3
View File
@@ -147,7 +147,7 @@ func TestDeployWikiCreatesNodeBBTopicAndWritesManifest(t *testing.T) {
} }
} }
func TestDeployWikiCreatesNodeBBTopicWithFallbackForShortTitle(t *testing.T) { func TestDeployWikiCreatesNodeBBTopicWithoutFallbackForDefaultThreeCharacterTitle(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") sourceDir := filepath.Join(root, "pages")
if err := os.MkdirAll(filepath.Join(sourceDir, "spells"), 0755); err != nil { 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 { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode create request: %v", err) t.Fatalf("decode create request: %v", err)
} }
if req.Title != "Spells: Aid" { if req.Title != "Aid" {
t.Fatalf("expected fallback title, got %q", req.Title) t.Fatalf("expected unprefixed title, got %q", req.Title)
} }
if req.Content != generated { if req.Content != generated {
t.Fatalf("expected page body to keep short h1 unchanged:\n%s", req.Content) 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 := `<!-- sow-topdata-wiki:page=itemtypes:belt -->
<!-- sow-topdata-wiki:managed:start hash="sha256:local" -->
<h1>Belt</h1>
<p>Generated belt page</p>
<!-- sow-topdata-wiki:managed:end -->
`
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) { func TestDeployWikiAdoptsExistingNodeBBPageWhenManifestIsMissingWithoutCreate(t *testing.T) {
root := t.TempDir() root := t.TempDir()
sourceDir := filepath.Join(root, "pages") sourceDir := filepath.Join(root, "pages")