Wiki title prefix policy
This commit is contained in:
@@ -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=<cid> 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")
|
||||
|
||||
@@ -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 := `<!-- 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) {
|
||||
root := t.TempDir()
|
||||
sourceDir := filepath.Join(root, "pages")
|
||||
|
||||
Reference in New Issue
Block a user