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:
+206
-39
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user