Align topdata wiki canonical paths (#11)

Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/11
Co-authored-by: Michał Piasecki <mpiasecki720@protonmail.com>
Co-committed-by: Michał Piasecki <mpiasecki720@protonmail.com>
This commit is contained in:
2026-05-23 16:38:02 +02:00
committed by archvillainette
parent 7288d7e2b7
commit 6d6367ad18
6 changed files with 754 additions and 76 deletions
+206 -39
View File
@@ -104,12 +104,13 @@ type wikiNamespacesDocument struct {
}
type wikiNamespaceDeclaration struct {
ID string `json:"id" yaml:"id"`
Title string `json:"title" yaml:"title"`
CategoryEnv string `json:"category_env" yaml:"category_env"`
PageTitle string `json:"page_title" yaml:"page_title"`
Tags []string `json:"tags" yaml:"tags"`
EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
ID string `json:"id" yaml:"id"`
Title string `json:"title" yaml:"title"`
CanonicalPath string `json:"canonical_path" yaml:"canonical_path"`
CategoryEnv string `json:"category_env" yaml:"category_env"`
PageTitle string `json:"page_title" yaml:"page_title"`
Tags []string `json:"tags" yaml:"tags"`
EditPolicy string `json:"edit_policy" yaml:"edit_policy"`
}
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
@@ -267,6 +268,9 @@ func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclarati
if strings.TrimSpace(declaration.CategoryEnv) == "" {
return nil, fmt.Errorf("topdata wiki namespace %q category_env is required", id)
}
if err := validateWikiNamespaceDeclarationPath(declaration); err != nil {
return nil, err
}
switch strings.TrimSpace(declaration.EditPolicy) {
case "", "preserve_manual_sections", "generated_only":
default:
@@ -276,6 +280,46 @@ func loadWikiNamespaceDeclarations(p *project.Project) ([]wikiNamespaceDeclarati
return doc.Namespaces, nil
}
func validateWikiNamespaceDeclarationPath(declaration wikiNamespaceDeclaration) error {
id := strings.TrimSpace(declaration.ID)
title := strings.TrimSpace(declaration.Title)
canonicalTitle := ""
if title != "" {
canonicalTitle = canonicalWikiSegment(title)
if canonicalTitle == "" {
return fmt.Errorf("topdata wiki namespace %q title %q does not produce a valid canonical segment", id, title)
}
}
path := strings.TrimSpace(declaration.CanonicalPath)
if path == "" {
if canonicalTitle != "" {
path = canonicalTitle
} else {
path = canonicalWikiSegment(namespaceTitle(id))
}
if path == "" {
return fmt.Errorf("topdata wiki namespace %q does not produce a valid canonical namespace path", id)
}
if isReservedCanonicalWikiFirstSegment(path) {
return fmt.Errorf("topdata wiki namespace %q title %q uses reserved first segment %q", id, title, path)
}
return nil
}
canonicalPath := canonicalWikiSlashPath(path)
if canonicalPath == "" {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q does not produce a valid canonical namespace path", id, path)
}
if canonicalPath != path {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q must already be canonical as %q", id, path, canonicalPath)
}
if isReservedCanonicalWikiFirstSegment(path) {
return fmt.Errorf("topdata wiki namespace %q canonical_path %q uses reserved first segment", id, path)
}
return nil
}
func categoryIDsFromNamespaceEnv(declarations []wikiNamespaceDeclaration) (map[string]int, error) {
categories := map[string]int{}
for _, declaration := range declarations {
@@ -301,6 +345,10 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
for _, namespace := range namespaces {
namespaceSet[namespace] = struct{}{}
}
pageIndex, err := loadDeployPageIndex(sourceDir)
if err != nil {
return nil, err
}
pages := map[string]wikiDeployPage{}
for _, ns := range namespaces {
nsDir := filepath.Join(sourceDir, ns)
@@ -337,10 +385,14 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return nil
}
}
publicPath := extractWikiPagePublicPath(content)
if publicPath == "" {
publicPath = pageIndex[pageID].PublicPath
}
pages[pageID] = wikiDeployPage{
PageID: pageID,
Title: extractPageTitle(content, pageID),
PublicPath: extractWikiPagePublicPath(content),
PublicPath: publicPath,
Namespace: namespace,
Content: content,
Hash: computeManagedHash(content),
@@ -353,6 +405,28 @@ func collectLocalPages(sourceDir string, namespaces []string) (map[string]wikiDe
return pages, nil
}
func loadDeployPageIndex(sourceDir string) (map[string]wikiPageIndexEntry, error) {
path := filepath.Join(filepath.Dir(sourceDir), "page-index.json")
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]wikiPageIndexEntry{}, nil
}
return nil, fmt.Errorf("read wiki page index: %w", err)
}
var index wikiPageIndex
if err := json.Unmarshal(raw, &index); err != nil {
return nil, fmt.Errorf("parse wiki page index: %w", err)
}
byPageID := map[string]wikiPageIndexEntry{}
for _, entry := range index.Pages {
if strings.TrimSpace(entry.PageID) != "" {
byPageID[entry.PageID] = entry
}
}
return byPageID, nil
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
@@ -604,6 +678,9 @@ func adoptExistingNodeBBPage(page wikiDeployPage, cid int, remotePagesByCID map[
}
remotePage, ok := matchExistingNodeBBPage(page, remotePages)
if !ok || remotePage.TID == 0 {
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
return wikiDeployManifestPage{}, false, nil
}
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
@@ -624,6 +701,9 @@ func findExistingNodeBBPage(page wikiDeployPage, cid int, client *nodeBBClient)
if ok && remotePage.TID != 0 {
return fetchNodeBBWikiPageMapping(page, cid, remotePage, client)
}
if err := nestedCanonicalAdoptionFallbackError(page, remotePages); err != nil {
return wikiDeployManifestPage{}, false, err
}
}
return wikiDeployManifestPage{}, false, nil
}
@@ -642,49 +722,134 @@ func fetchNodeBBWikiPageMapping(page wikiDeployPage, cid int, remotePage nodeBBW
return wikiDeployManifestPage{TID: topic.TID, PID: topic.PID, CID: cid}, true, nil
}
func nestedCanonicalAdoptionFallbackError(page wikiDeployPage, remotePages []nodeBBWikiPage) error {
if !isNestedCanonicalWikiPath(page.PublicPath) {
return nil
}
targetLeaf := nodeBBPathLeaf(normalizeWikiPath(page.PublicPath))
desiredTitle := normalizeWikiTitle(page.Title)
for _, candidate := range remotePages {
if strings.TrimSpace(candidate.WikiPath) != "" || strings.TrimSpace(candidate.CanonicalPath) != "" {
continue
}
if targetLeaf != "" && !onlyHasRetiredGeneratedWikiPaths(page, candidate) {
for _, path := range []string{candidate.Slug} {
if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
}
}
}
if desiredTitle != "" {
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if normalizeWikiTitle(title) == desiredTitle {
return fmt.Errorf("wiki page %q public path %q requires exact canonical wikiPath/canonicalPath for adoption; remote page %d only exposed leaf-compatible slug/title data", page.PageID, page.PublicPath, candidate.TID)
}
}
}
}
return nil
}
func matchExistingNodeBBPage(page wikiDeployPage, remotePages []nodeBBWikiPage) (nodeBBWikiPage, bool) {
targetPath := normalizeWikiPath(page.PublicPath)
requiresExactPath := isNestedCanonicalWikiPath(page.PublicPath)
if page.PublicPath != "" {
for _, candidate := range remotePages {
for _, path := range []string{candidate.WikiPath, candidate.Slug} {
if normalizeWikiPath(path) == normalizeWikiPath(page.PublicPath) {
paths := []string{candidate.WikiPath, candidate.CanonicalPath}
if !requiresExactPath {
paths = append(paths, candidate.Slug)
}
for _, path := range paths {
if normalizeWikiPath(path) == targetPath {
return candidate, true
}
}
}
}
if requiresExactPath {
return nodeBBWikiPage{}, false
}
targetLeaf := nodeBBPathLeaf(targetPath)
if targetLeaf != "" {
matches := []nodeBBWikiPage{}
for _, candidate := range remotePages {
if strings.TrimSpace(candidate.WikiPath) != "" {
continue
}
for _, path := range []string{candidate.WikiPath, candidate.Slug} {
if isRetiredGeneratedWikiPath(page, path) {
continue
}
if strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == targetLeaf {
matches = append(matches, candidate)
break
}
}
}
if len(matches) == 1 {
return matches[0], true
}
}
desiredTitles := map[string]struct{}{}
for _, title := range []string{page.Title, pageIDLeaf(page.PageID)} {
for _, title := range []string{page.Title} {
if normalized := normalizeWikiTitle(title); normalized != "" {
desiredTitles[normalized] = struct{}{}
}
}
desiredSlugs := map[string]struct{}{}
for title := range desiredTitles {
if slug := strings.ToLower(canonicalWikiSegment(title)); slug != "" {
desiredSlugs[slug] = struct{}{}
}
}
for _, slug := range []string{pageIDLeaf(page.PageID), strings.ReplaceAll(pageIDRemainder(page.PageID), ":", "-")} {
if normalized := strings.ToLower(canonicalWikiSegment(slug)); normalized != "" {
desiredSlugs[normalized] = struct{}{}
}
}
matches := []nodeBBWikiPage{}
for _, candidate := range remotePages {
if targetPath != "" && strings.TrimSpace(candidate.WikiPath) != "" {
continue
}
if onlyHasRetiredGeneratedWikiPaths(page, candidate) {
continue
}
for _, title := range []string{candidate.TitleLeaf, candidate.Title} {
if _, ok := desiredTitles[normalizeWikiTitle(title)]; ok {
return candidate, true
}
}
for _, slug := range []string{nodeBBPathLeaf(candidate.Slug), nodeBBPathLeaf(candidate.WikiPath)} {
if _, ok := desiredSlugs[strings.ToLower(canonicalWikiSegment(slug))]; ok {
return candidate, true
matches = append(matches, candidate)
break
}
}
}
if len(matches) == 1 {
return matches[0], true
}
return nodeBBWikiPage{}, false
}
func onlyHasRetiredGeneratedWikiPaths(page wikiDeployPage, candidate nodeBBWikiPage) bool {
seenPath := false
for _, path := range []string{candidate.WikiPath, candidate.Slug} {
if strings.TrimSpace(path) == "" {
continue
}
seenPath = true
if !isRetiredGeneratedWikiPath(page, path) {
return false
}
}
return seenPath
}
func isRetiredGeneratedWikiPath(page wikiDeployPage, path string) bool {
retired := retiredGeneratedWikiPathLeaf(page.PageID)
if retired == "" {
return false
}
return strings.ToLower(canonicalWikiSegment(nodeBBPathLeaf(path))) == retired
}
func retiredGeneratedWikiPathLeaf(pageID string) string {
remainder := pageIDRemainder(pageID)
leaf := pageIDLeaf(pageID)
if remainder == "" || remainder == leaf {
return ""
}
return strings.ToLower(canonicalWikiSegment(strings.ReplaceAll(remainder, ":", "-")))
}
func pageIDRemainder(pageID string) string {
_, rest, ok := strings.Cut(pageID, ":")
if !ok {
@@ -966,7 +1131,7 @@ func extractWikiPagePublicPath(content string) string {
value := marker[fieldStart+len(pathField):]
if fields := strings.Fields(value); len(fields) > 0 {
path := strings.Trim(strings.TrimSpace(fields[0]), `"`)
if path != "" && canonicalWikiPath(path) == path {
if path != "" && canonicalWikiSlashPath(path) == path {
return path
}
}
@@ -1088,11 +1253,12 @@ type nodeBBEditLock struct {
}
type nodeBBWikiPage struct {
TID int
Title string
TitleLeaf string
Slug string
WikiPath string
TID int
Title string
TitleLeaf string
Slug string
WikiPath string
CanonicalPath string
}
func newNodeBBClient(endpoint, token string) *nodeBBClient {
@@ -1330,11 +1496,12 @@ func parseNodeBBWikiPageList(doc any) ([]nodeBBWikiPage, string, bool) {
continue
}
pages = append(pages, nodeBBWikiPage{
TID: firstInt(row, "tid"),
Title: firstString(row, "title"),
TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
Slug: firstString(row, "slug"),
WikiPath: firstString(row, "wikiPath", "wiki_path"),
TID: firstInt(row, "tid"),
Title: firstString(row, "title"),
TitleLeaf: firstString(row, "titleLeaf", "title_leaf"),
Slug: firstString(row, "slug"),
WikiPath: firstString(row, "wikiPath", "wiki_path"),
CanonicalPath: firstString(row, "canonicalPath", "canonical_path"),
})
}
return pages, firstString(m, "nextCursor", "next_cursor"), boolFromAny(m["hasMore"])