**Scope Audited** `module/` release/wiki wrapper flow and `toolkit/` wiki generation cache logic. **Changes Made** Root cause was stale wiki cache reuse. The generator cache considered digest + page count, but not generator version, so a restored VPS cache with old generated output could skip regeneration. Changed [wiki_native.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/wiki_native.go:29) to bump the wiki generator to `nodebb-tiptap-html-v3`, and changed [wiki_native.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/wiki_native.go:268) so cached pages are current only when `state.version` matches. **Configuration/Schema Impact** No YAML/config/schema changes. Existing `.cache/wiki/state.json` files with `nodebb-tiptap-html-v2` will be invalidated and regenerated. **Compatibility Impact** This is compatible with existing caches; it only forces one regeneration when the new toolkit runs. After that, normal digest-based skipping resumes. **Tests Added** Added [TestBuildWikiRegeneratesOldGeneratorCache](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/wiki_native_test.go:183), which simulates a `v2` cache and verifies `build-wiki` regenerates it. **Validation Performed** - Confirmed new test failed before the fix with `wiki status "skipped"`. - `go test ./internal/topdata -count=1` passes. - `./build-tool.sh` succeeds. - `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh` now reports: - `wiki pages: 1163` - `wiki status: generated` - Local generated race pages: `17`. **Remaining Risk / VPS Next Step** The VPS release job must use this rebuilt/published toolkit. On the next run, you should see the wiki build regenerate once before deploy, and deploy should load `1163` local pages instead of `1092`, including `races:*` pages.
2096 lines
61 KiB
Go
2096 lines
61 KiB
Go
package topdata
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
wikiStatusNew = "new"
|
|
wikiStatusModified = "modified"
|
|
wikiStatusVanilla = "vanilla"
|
|
wikiRootDirName = "wiki"
|
|
legacyWikiRootDirName = ".wiki"
|
|
wikiPagesDirName = "pages"
|
|
wikiStateFileName = "state.json"
|
|
wikiGeneratorVersion = "nodebb-tiptap-html-v3"
|
|
wikiGeneratedStatus = "generated"
|
|
wikiSkippedStatus = "skipped"
|
|
)
|
|
|
|
var (
|
|
wikiStatusPriority = map[string]int{wikiStatusVanilla: 0, wikiStatusModified: 1, wikiStatusNew: 2}
|
|
wikiStatusLabels = map[string]string{wikiStatusNew: "New", wikiStatusModified: "Modified", wikiStatusVanilla: "Vanilla"}
|
|
)
|
|
|
|
//go:embed wiki_templates/*.txt
|
|
var wikiTemplateFS embed.FS
|
|
|
|
type wikiStateDocument struct {
|
|
Version string `json:"version"`
|
|
Digest string `json:"digest"`
|
|
Pages int `json:"pages"`
|
|
}
|
|
|
|
type wikiPageIndex struct {
|
|
Version string `json:"version"`
|
|
Pages []wikiPageIndexEntry `json:"pages"`
|
|
}
|
|
|
|
type wikiPageIndexEntry struct {
|
|
PageID string `json:"page_id"`
|
|
Namespace string `json:"namespace"`
|
|
SourceDataset string `json:"source_dataset"`
|
|
SourceKey string `json:"source_key"`
|
|
Title string `json:"title"`
|
|
PublicPath string `json:"public_path"`
|
|
Hash string `json:"hash"`
|
|
OutputPath string `json:"output_path"`
|
|
EditPolicy string `json:"edit_policy"`
|
|
StalePolicy string `json:"stale_policy"`
|
|
}
|
|
|
|
type wikiManualSectionsDocument struct {
|
|
ManualSections []wikiManualSection `json:"manual_sections" yaml:"manual_sections"`
|
|
}
|
|
|
|
type wikiManualSection struct {
|
|
ID string `json:"id" yaml:"id"`
|
|
Alias string `json:"alias" yaml:"alias"`
|
|
Heading string `json:"heading" yaml:"heading"`
|
|
Placement string `json:"placement" yaml:"placement"`
|
|
InitialHTML string `json:"initial_html" yaml:"initial_html"`
|
|
}
|
|
|
|
type wikiResult struct {
|
|
OutputDir string
|
|
PageCount int
|
|
Status string
|
|
Digest string
|
|
}
|
|
|
|
type wikiTable struct {
|
|
Key string
|
|
Output string
|
|
OutputStem string
|
|
Rows []map[string]any
|
|
}
|
|
|
|
type wikiContext struct {
|
|
dialog map[string]string
|
|
rowsByKey map[string]map[string]any
|
|
featRows map[string]map[string]any
|
|
featIDToKey map[int]string
|
|
skillRows map[string]map[string]any
|
|
skillIDToKey map[int]string
|
|
spellRows map[string]map[string]any
|
|
baseitemRows map[string]map[string]any
|
|
classRows map[string]map[string]any
|
|
classIDToKey map[int]string
|
|
raceRows map[string]map[string]any
|
|
raceIDToKey map[int]string
|
|
masterfeatRows map[string]map[string]any
|
|
classFeatTables map[string]wikiTable
|
|
classSkillTables map[string]wikiTable
|
|
classBonusTables map[string]wikiTable
|
|
classSaveTables map[string]wikiTable
|
|
raceFeatTables map[string]wikiTable
|
|
statuses map[string]map[string]string
|
|
implementedFeats map[string]struct{}
|
|
manualSections []wikiManualSection
|
|
templateDir string
|
|
wikiTablesPath string
|
|
wikiTableDefinitions map[string]wikiTableDefinition
|
|
wikiDataPath string
|
|
wikiDataProviders map[string]wikiDataProviderDefinition
|
|
visibilityPolicy *wikiVisibilityDefinitions
|
|
visibleWikiKeys map[string]bool
|
|
namespaceTitles map[string]string
|
|
namespacePaths map[string]string
|
|
}
|
|
|
|
func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
|
|
if progress == nil {
|
|
progress = func(string) {}
|
|
}
|
|
rootDir := wikiOutputRootDir(p)
|
|
outputDir := wikiOutputPagesDir(p)
|
|
statePath := wikiOutputStatePath(p)
|
|
|
|
digest, err := computeWikiSourceDigest(p.TopDataSourceDir())
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
|
|
state, _ := loadWikiState(statePath)
|
|
if !force && digest != "" && state.Digest == digest && wikiCachedPagesAreCurrent(outputDir, state) {
|
|
return wikiResult{
|
|
OutputDir: outputDir,
|
|
PageCount: state.Pages,
|
|
Status: wikiSkippedStatus,
|
|
Digest: digest,
|
|
}, nil
|
|
}
|
|
|
|
progress("Building native wiki pages...")
|
|
ctx, err := loadWikiContext(filepath.Join(p.TopDataSourceDir(), "data"), p.TopDataSourceDir())
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
manualSections := loadWikiManualSections(p)
|
|
ctx.manualSections = manualSections
|
|
ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages")
|
|
ctx.wikiTablesPath = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TablesFile))
|
|
tableDefinitions, err := loadWikiTableDefinitions(ctx.wikiTablesPath)
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
ctx.wikiTableDefinitions = tableDefinitions
|
|
ctx.wikiDataPath = filepath.Join(p.TopDataWikiSourceDir(), "data.yaml")
|
|
dataProviders, err := loadWikiDataProviders(ctx.wikiDataPath)
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
ctx.wikiDataProviders = dataProviders
|
|
visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile))
|
|
if err := ctx.loadWikiVisibility(visibilityPath); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
pageSlugOverrides, err := loadWikiPagePathDeclarations(filepath.Join(p.TopDataWikiSourceDir(), "wiki.yaml"))
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
_ = pageSlugOverrides
|
|
namespaceDeclarations, err := loadWikiNamespaceDeclarations(p)
|
|
if err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations)
|
|
ctx.namespacePaths = wikiNamespacePaths(namespaceDeclarations)
|
|
|
|
if err := os.RemoveAll(outputDir); err != nil {
|
|
return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
|
|
}
|
|
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
return wikiResult{}, fmt.Errorf("create wiki output dir: %w", err)
|
|
}
|
|
|
|
pageTitles := map[string]string{}
|
|
pageStatuses := map[string]string{}
|
|
pageIndex := wikiPageIndex{Version: wikiGeneratorVersion}
|
|
outputPaths := map[string]string{}
|
|
|
|
writePage := func(pageID, content, title, status string) error {
|
|
relPath := wikiPageOutputRelPath(pageID, title)
|
|
if previous := outputPaths[relPath]; previous != "" {
|
|
return fmt.Errorf("duplicate wiki page output path %q for %s and %s", filepath.ToSlash(relPath), previous, pageID)
|
|
}
|
|
outputPaths[relPath] = pageID
|
|
path := filepath.Join(outputDir, relPath)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
namespace := strings.SplitN(pageID, ":", 2)[0]
|
|
publicPath := ctx.publicWikiPath(namespace, title)
|
|
content = renderNodeBBManagedHTML(pageID, content, manualSections)
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
return err
|
|
}
|
|
pageTitles[pageID] = title
|
|
pageStatuses[pageID] = status
|
|
rel, err := filepath.Rel(rootDir, path)
|
|
if err != nil {
|
|
rel = filepath.Base(path)
|
|
}
|
|
pageIndex.Pages = append(pageIndex.Pages, wikiPageIndexEntry{
|
|
PageID: pageID,
|
|
Namespace: namespace,
|
|
SourceDataset: categoryFromWikiNamespace(namespace),
|
|
SourceKey: pageID,
|
|
Title: title,
|
|
PublicPath: publicPath,
|
|
Hash: computeManagedHash(content),
|
|
OutputPath: filepath.ToSlash(rel),
|
|
EditPolicy: wikiEditPolicy(namespace),
|
|
StalePolicy: p.EffectiveConfig().TopData.Wiki.StalePages.Default,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
if wikiStatusPagesEnabled(p.EffectiveConfig().TopData.Wiki) {
|
|
if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, p.EffectiveConfig().TopData.Wiki.StatusListingScope, manualSections, ctx); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
}
|
|
if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, ctx, &pageIndex); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
if err := saveWikiPageIndex(filepath.Join(rootDir, "page-index.json"), pageIndex); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
|
|
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
|
return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err)
|
|
}
|
|
pageCount := len(pageIndex.Pages)
|
|
state = wikiStateDocument{Version: wikiGeneratorVersion, Digest: digest, Pages: pageCount}
|
|
if err := saveWikiState(statePath, state); err != nil {
|
|
return wikiResult{}, err
|
|
}
|
|
return wikiResult{
|
|
OutputDir: outputDir,
|
|
PageCount: pageCount,
|
|
Status: wikiGeneratedStatus,
|
|
Digest: digest,
|
|
}, nil
|
|
}
|
|
|
|
func wikiStatusPagesEnabled(cfg project.TopDataWikiConfig) bool {
|
|
return cfg.StatusPages == nil || *cfg.StatusPages
|
|
}
|
|
|
|
func wikiCachedPagesAreCurrent(outputDir string, state wikiStateDocument) bool {
|
|
if state.Version != wikiGeneratorVersion {
|
|
return false
|
|
}
|
|
if state.Pages <= 0 {
|
|
return false
|
|
}
|
|
count := 0
|
|
err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".html") {
|
|
count++
|
|
}
|
|
return nil
|
|
})
|
|
return err == nil && count == state.Pages
|
|
}
|
|
|
|
func loadWikiManualSections(p *project.Project) []wikiManualSection {
|
|
defaults := defaultWikiManualSections()
|
|
cfg := p.EffectiveConfig().TopData.Wiki
|
|
path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return defaults
|
|
}
|
|
var doc wikiManualSectionsDocument
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil || len(doc.ManualSections) == 0 {
|
|
return defaults
|
|
}
|
|
out := []wikiManualSection{}
|
|
for _, section := range doc.ManualSections {
|
|
section.ID = strings.TrimSpace(section.ID)
|
|
if section.ID == "" {
|
|
continue
|
|
}
|
|
section.Alias = strings.TrimSpace(section.Alias)
|
|
if section.Alias == "" {
|
|
switch section.ID {
|
|
case "user_top":
|
|
section.Alias = "UserTopSection"
|
|
case "user_bottom":
|
|
section.Alias = "UserBottomSection"
|
|
}
|
|
}
|
|
if strings.TrimSpace(section.InitialHTML) == "" {
|
|
heading := strings.TrimSpace(section.Heading)
|
|
if heading == "" {
|
|
heading = section.ID
|
|
}
|
|
section.InitialHTML = "<h2>" + html.EscapeString(heading) + "</h2><p></p>"
|
|
}
|
|
out = append(out, section)
|
|
}
|
|
if len(out) == 0 {
|
|
return defaults
|
|
}
|
|
return out
|
|
}
|
|
|
|
func saveWikiPageIndex(path string, index wikiPageIndex) error {
|
|
if err := validateWikiPageIndex(index); err != nil {
|
|
return err
|
|
}
|
|
slices.SortFunc(index.Pages, func(a, b wikiPageIndexEntry) int {
|
|
return strings.Compare(a.PageID, b.PageID)
|
|
})
|
|
raw, err := json.MarshalIndent(index, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal wiki page index: %w", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, append(raw, '\n'), 0o644)
|
|
}
|
|
|
|
func validateWikiPageIndex(index wikiPageIndex) error {
|
|
pageIDs := map[string]string{}
|
|
outputPaths := map[string]string{}
|
|
publicPaths := map[string]string{}
|
|
for _, entry := range index.Pages {
|
|
if previous, ok := pageIDs[entry.PageID]; ok {
|
|
return fmt.Errorf("duplicate wiki page-index page_id %q for %s and %s", entry.PageID, previous, entry.OutputPath)
|
|
}
|
|
if previous, ok := outputPaths[entry.OutputPath]; ok {
|
|
return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
|
|
}
|
|
if entry.PublicPath != "" {
|
|
publicPathKey := canonicalWikiSlashPathFoldedKey(entry.PublicPath)
|
|
if publicPathKey == "" {
|
|
publicPathKey = entry.PublicPath
|
|
}
|
|
if previous, ok := publicPaths[publicPathKey]; ok {
|
|
return fmt.Errorf("duplicate wiki page-index public_path %q for %s and %s", entry.PublicPath, previous, entry.PageID)
|
|
}
|
|
publicPaths[publicPathKey] = entry.PageID
|
|
}
|
|
pageIDs[entry.PageID] = entry.OutputPath
|
|
outputPaths[entry.OutputPath] = entry.PageID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, ctx *wikiContext, index *wikiPageIndex) error {
|
|
seen := map[string]struct{}{}
|
|
for _, entry := range index.Pages {
|
|
seen[entry.PageID] = struct{}{}
|
|
}
|
|
if ctx == nil {
|
|
ctx = &wikiContext{}
|
|
}
|
|
return filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".html") {
|
|
return nil
|
|
}
|
|
relToPages, err := filepath.Rel(outputDir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pageID := extractWikiPageID(string(raw))
|
|
if pageID == "" {
|
|
pageID = strings.TrimSuffix(filepath.ToSlash(relToPages), filepath.Ext(relToPages))
|
|
pageID = strings.ReplaceAll(pageID, "/", ":")
|
|
}
|
|
if _, ok := seen[pageID]; ok {
|
|
return nil
|
|
}
|
|
namespace := strings.SplitN(pageID, ":", 2)[0]
|
|
relToRoot, err := filepath.Rel(rootDir, path)
|
|
if err != nil {
|
|
relToRoot = filepath.Base(path)
|
|
}
|
|
title := extractHTMLTitle(string(raw), pageID)
|
|
index.Pages = append(index.Pages, wikiPageIndexEntry{
|
|
PageID: pageID,
|
|
Namespace: namespace,
|
|
SourceDataset: categoryFromWikiNamespace(namespace),
|
|
SourceKey: pageID,
|
|
Title: title,
|
|
PublicPath: ctx.publicWikiPath(namespace, title),
|
|
Hash: computeManagedHash(string(raw)),
|
|
OutputPath: filepath.ToSlash(relToRoot),
|
|
EditPolicy: wikiEditPolicy(namespace),
|
|
StalePolicy: stalePolicy,
|
|
})
|
|
seen[pageID] = struct{}{}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func loadWikiState(path string) (wikiStateDocument, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return wikiStateDocument{}, err
|
|
}
|
|
var doc wikiStateDocument
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return wikiStateDocument{}, fmt.Errorf("parse wiki state: %w", err)
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
func saveWikiState(path string, state wikiStateDocument) error {
|
|
raw, err := json.MarshalIndent(state, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal wiki state: %w", err)
|
|
}
|
|
raw = append(raw, '\n')
|
|
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
|
return fmt.Errorf("write wiki state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func computeWikiSourceDigest(sourceDir string) (string, error) {
|
|
hasher := sha256.New()
|
|
_, _ = hasher.Write([]byte(wikiGeneratorVersion))
|
|
files := []string{}
|
|
relevantSourceDirs := []string{
|
|
filepath.Join(sourceDir, "data", "feat"),
|
|
filepath.Join(sourceDir, "data", "skills"),
|
|
filepath.Join(sourceDir, "data", "spells"),
|
|
filepath.Join(sourceDir, "data", "baseitems"),
|
|
filepath.Join(sourceDir, "data", "classes"),
|
|
filepath.Join(sourceDir, "data", "racialtypes"),
|
|
filepath.Join(sourceDir, "wiki"),
|
|
}
|
|
for _, dir := range relevantSourceDirs {
|
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".json", ".yaml", ".yml", ".html":
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if len(files) == 0 {
|
|
return "", nil
|
|
}
|
|
slices.Sort(files)
|
|
for _, path := range files {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
return "", err
|
|
}
|
|
rel, err := filepath.Rel(sourceDir, path)
|
|
if err == nil && !strings.HasPrefix(rel, "..") {
|
|
rel = filepath.ToSlash(rel)
|
|
} else {
|
|
rel = filepath.Base(path)
|
|
}
|
|
_, _ = hasher.Write([]byte(rel))
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read wiki digest input %s: %w", path, err)
|
|
}
|
|
_, _ = hasher.Write(raw)
|
|
}
|
|
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
|
|
}
|
|
|
|
func newestRelevantWikiSource(sourceDir string) (time.Time, string, error) {
|
|
newest := time.Time{}
|
|
newestPath := ""
|
|
relevantSourceDirs := []string{
|
|
filepath.Join(sourceDir, "data", "feat"),
|
|
filepath.Join(sourceDir, "data", "skills"),
|
|
filepath.Join(sourceDir, "data", "spells"),
|
|
filepath.Join(sourceDir, "data", "baseitems"),
|
|
filepath.Join(sourceDir, "data", "classes"),
|
|
filepath.Join(sourceDir, "data", "racialtypes"),
|
|
}
|
|
for _, dir := range relevantSourceDirs {
|
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.EqualFold(filepath.Ext(path), ".json") {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if newest.IsZero() || info.ModTime().After(newest) {
|
|
newest = info.ModTime()
|
|
newestPath = path
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return time.Time{}, "", err
|
|
}
|
|
}
|
|
return newest, newestPath, nil
|
|
}
|
|
|
|
func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
|
|
dialogData, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dialog := map[string]string{}
|
|
if dialogData != nil {
|
|
for key, entry := range dialogData.Entries {
|
|
dialog[key] = entry.Text
|
|
}
|
|
}
|
|
|
|
datasets, err := discoverNativeDatasets(dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loadBase := func(name string) (nativeCollectedDataset, bool, error) {
|
|
for _, dataset := range datasets {
|
|
if dataset.Name == name {
|
|
collected, err := collectNativeDataset(dataset)
|
|
return collected, true, err
|
|
}
|
|
}
|
|
return nativeCollectedDataset{}, false, nil
|
|
}
|
|
|
|
featDataset, ok, err := loadBase("feat")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
skillDataset, skillOK, err := loadBase("skills")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
spellDataset, spellOK, err := loadBase("spells")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
baseitemDataset, baseitemOK, err := loadBase("baseitems")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classDataset, classOK, err := loadBase("classes/core")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
masterfeatDataset, masterfeatOK, err := loadBase("masterfeats")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raceDatasets, err := collectWikiRacialtypesDatasets(dataDir, datasets)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
raceRows := map[string]map[string]any{}
|
|
raceIDToKey := map[int]string{}
|
|
raceFeatTables := map[string]wikiTable{}
|
|
for _, dataset := range raceDatasets {
|
|
if dataset.Dataset.OutputName == "racialtypes.2da" {
|
|
raceRows, raceIDToKey = rowsByKeyAndID(dataset.Rows)
|
|
continue
|
|
}
|
|
table := wikiTable{
|
|
Key: dataset.TableKey,
|
|
Output: dataset.Dataset.OutputName,
|
|
OutputStem: outputStem(dataset.Dataset.OutputName),
|
|
Rows: cloneRows(dataset.Rows),
|
|
}
|
|
raceFeatTables[table.OutputStem] = table
|
|
if table.Key != "" {
|
|
raceFeatTables[table.Key] = table
|
|
}
|
|
}
|
|
|
|
classFeatTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "feats"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSkillTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "skills"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classBonusTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "bfeat"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSaveTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "savthr"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx := &wikiContext{
|
|
dialog: dialog,
|
|
rowsByKey: map[string]map[string]any{},
|
|
statuses: map[string]map[string]string{},
|
|
raceFeatTables: raceFeatTables,
|
|
classFeatTables: classFeatTables,
|
|
classSkillTables: classSkillTables,
|
|
classBonusTables: classBonusTables,
|
|
classSaveTables: classSaveTables,
|
|
}
|
|
if ok {
|
|
ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows)
|
|
} else {
|
|
ctx.featRows, ctx.featIDToKey = map[string]map[string]any{}, map[int]string{}
|
|
}
|
|
if skillOK {
|
|
ctx.skillRows, ctx.skillIDToKey = rowsByKeyAndID(skillDataset.Rows)
|
|
} else {
|
|
ctx.skillRows, ctx.skillIDToKey = map[string]map[string]any{}, map[int]string{}
|
|
}
|
|
if spellOK {
|
|
ctx.spellRows, _ = rowsByKeyAndID(spellDataset.Rows)
|
|
} else {
|
|
ctx.spellRows = map[string]map[string]any{}
|
|
}
|
|
if baseitemOK {
|
|
ctx.baseitemRows, _ = rowsByKeyAndID(baseitemDataset.Rows)
|
|
} else {
|
|
ctx.baseitemRows = map[string]map[string]any{}
|
|
}
|
|
if classOK {
|
|
ctx.classRows, ctx.classIDToKey = rowsByKeyAndID(classDataset.Rows)
|
|
} else {
|
|
ctx.classRows, ctx.classIDToKey = map[string]map[string]any{}, map[int]string{}
|
|
}
|
|
if masterfeatOK {
|
|
ctx.masterfeatRows, _ = rowsByKeyAndID(masterfeatDataset.Rows)
|
|
} else {
|
|
ctx.masterfeatRows = map[string]map[string]any{}
|
|
}
|
|
ctx.raceRows = raceRows
|
|
ctx.raceIDToKey = raceIDToKey
|
|
for _, group := range []map[string]map[string]any{ctx.featRows, ctx.skillRows, ctx.spellRows, ctx.baseitemRows, ctx.classRows, ctx.raceRows, ctx.masterfeatRows} {
|
|
for key, row := range group {
|
|
ctx.rowsByKey[key] = row
|
|
}
|
|
}
|
|
if ok {
|
|
ctx.statuses["feat"] = collectDatasetStatuses(filepath.Join(dataDir, "feat"), "feat")
|
|
}
|
|
if skillOK {
|
|
ctx.statuses["skills"] = collectDatasetStatuses(filepath.Join(dataDir, "skills"), "skills")
|
|
}
|
|
if spellOK {
|
|
ctx.statuses["spells"] = collectDatasetStatuses(filepath.Join(dataDir, "spells"), "spells")
|
|
}
|
|
if baseitemOK {
|
|
ctx.statuses["baseitems"] = collectDatasetStatuses(filepath.Join(dataDir, "baseitems"), "baseitems")
|
|
}
|
|
if classOK {
|
|
ctx.statuses["classes"] = collectDatasetStatuses(filepath.Join(dataDir, "classes", "core"), "classes/core")
|
|
}
|
|
ctx.statuses["racialtypes"] = collectWikiRacialtypeStatuses(dataDir)
|
|
ctx.implementedFeats = ctx.collectImplementedFeatKeys()
|
|
return ctx, nil
|
|
}
|
|
|
|
func collectWikiRacialtypesDatasets(dataDir string, datasets []nativeDataset) ([]nativeCollectedDataset, error) {
|
|
out := []nativeCollectedDataset{}
|
|
for _, dataset := range datasets {
|
|
name := filepath.ToSlash(dataset.Name)
|
|
if !strings.HasPrefix(name, "racialtypes/") {
|
|
continue
|
|
}
|
|
collected, err := collectNativeDataset(dataset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if collected.Dataset.OutputName == "racialtypes.2da" || collected.TableKey != "" {
|
|
out = append(out, collected)
|
|
}
|
|
}
|
|
if len(out) > 0 {
|
|
return out, nil
|
|
}
|
|
return collectRacialtypesRegistryDatasets(dataDir)
|
|
}
|
|
|
|
func collectWikiRacialtypeStatuses(dataDir string) map[string]string {
|
|
coreRoot := filepath.Join(dataDir, "racialtypes", "core")
|
|
if _, err := os.Stat(coreRoot); err == nil {
|
|
return collectDatasetStatuses(coreRoot, "racialtypes/core")
|
|
}
|
|
return collectRaceStatuses(filepath.Join(dataDir, "racialtypes", "registry"))
|
|
}
|
|
|
|
func rowsByKeyAndID(rows []map[string]any) (map[string]map[string]any, map[int]string) {
|
|
byKey := map[string]map[string]any{}
|
|
byID := map[int]string{}
|
|
for _, row := range rows {
|
|
key, _ := row["key"].(string)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
byKey[key] = row
|
|
if id, ok := row["id"].(int); ok {
|
|
byID[id] = key
|
|
}
|
|
}
|
|
return byKey, byID
|
|
}
|
|
|
|
func cloneRows(rows []map[string]any) []map[string]any {
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, cloneRowMap(row))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func loadWikiTables(root string) (map[string]wikiTable, error) {
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return map[string]wikiTable{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("%s must be a directory", root)
|
|
}
|
|
tables := map[string]wikiTable{}
|
|
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") || strings.EqualFold(filepath.Base(path), "lock.json") {
|
|
return nil
|
|
}
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rawRows, ok := obj["rows"].([]any)
|
|
if !ok {
|
|
return fmt.Errorf("%s: rows must be an array", path)
|
|
}
|
|
rows := make([]map[string]any, 0, len(rawRows))
|
|
for _, raw := range rawRows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%s: row must be an object", path)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
output, _ := obj["output"].(string)
|
|
if output == "" {
|
|
output = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".2da"
|
|
}
|
|
table := wikiTable{
|
|
Key: stringField(obj, "key"),
|
|
Output: output,
|
|
OutputStem: outputStem(output),
|
|
Rows: rows,
|
|
}
|
|
tables[table.OutputStem] = table
|
|
if table.Key != "" {
|
|
tables[table.Key] = table
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return tables, nil
|
|
}
|
|
|
|
func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
|
|
if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
if err := generateCategoryPages("feat", ctx.featRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
if err := generateCategoryPages("skills", ctx.skillRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
if err := generateCategoryPages("spells", ctx.spellRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
if err := generateCategoryPages("baseitems", ctx.baseitemRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
if err := generateCategoryPages("racialtypes", ctx.raceRows, ctx, writePage); err != nil {
|
|
return err
|
|
}
|
|
_ = outputDir
|
|
return nil
|
|
}
|
|
|
|
func generateCategoryPages(category string, rows map[string]map[string]any, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
|
|
keys := sortedKeys(rows)
|
|
for _, key := range keys {
|
|
row := rows[key]
|
|
if !ctx.shouldGeneratePage(category, key, row) {
|
|
continue
|
|
}
|
|
title := ctx.resolveRowName(category, row)
|
|
if title == "" {
|
|
continue
|
|
}
|
|
content, err := ctx.renderPage(category, key, row, title)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := writePage(wikiPageIDForKey(key), content, title, ctx.pageStatus(category, key, row)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]any) bool {
|
|
if row == nil {
|
|
return false
|
|
}
|
|
if ctx.visibilityPolicy != nil {
|
|
return ctx.canReferenceWikiKey(key)
|
|
}
|
|
override := ctx.wikiGenerateOverride(row)
|
|
if override == "0" {
|
|
return false
|
|
}
|
|
name := ctx.resolveRowName(category, row)
|
|
description := ctx.resolveRowDescription(category, row)
|
|
switch category {
|
|
case "classes":
|
|
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
|
|
return false
|
|
}
|
|
return override == "1" || stringValue(row, "PlayerClass") == "1"
|
|
case "feat":
|
|
if name == "" || description == "" || strings.EqualFold(stringValue(row, "FEAT"), nullValue) {
|
|
return false
|
|
}
|
|
if override == "1" {
|
|
return true
|
|
}
|
|
if stringValue(row, "ALLCLASSESCANUSE") == "1" {
|
|
return true
|
|
}
|
|
_, ok := ctx.implementedFeats[key]
|
|
return ok
|
|
case "skills":
|
|
if name == "" || description == "" {
|
|
return false
|
|
}
|
|
return override == "1" || stringValue(row, "HideFromLevelUp") != "1"
|
|
case "racialtypes":
|
|
if name == "" || description == "" {
|
|
return false
|
|
}
|
|
return override == "1" || stringValue(row, "PlayerRace") == "1"
|
|
case "spells":
|
|
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
|
|
return false
|
|
}
|
|
return name != "" && description != ""
|
|
default:
|
|
return name != "" && description != ""
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiRowCanRender(category string, row map[string]any) bool {
|
|
if row == nil {
|
|
return false
|
|
}
|
|
name := ctx.resolveRowName(category, row)
|
|
description := ctx.resolveRowDescription(category, row)
|
|
switch category {
|
|
case "classes", "spells":
|
|
if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
|
|
return false
|
|
}
|
|
}
|
|
return name != "" && description != ""
|
|
}
|
|
|
|
func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
|
|
manualSections := ctx.manualSections
|
|
if len(manualSections) == 0 {
|
|
manualSections = defaultWikiManualSections()
|
|
}
|
|
page := wikiTemplatePage{
|
|
PageID: wikiPageIDForKey(key),
|
|
Category: category,
|
|
Key: key,
|
|
Title: title,
|
|
Status: ctx.pageStatus(category, key, row),
|
|
Row: row,
|
|
ManualSections: manualSections,
|
|
}
|
|
return ctx.renderWikiPageTemplate(category, page)
|
|
}
|
|
|
|
func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string {
|
|
lines := []string{}
|
|
switch category {
|
|
case "feat":
|
|
lines = append(lines, ctx.renderFeatFacts(row)...)
|
|
case "skills":
|
|
lines = append(lines, formatFact("Key Ability", stringValue(row, "KeyAbility")))
|
|
lines = append(lines, formatFact("Untrained", yesNoValue(stringValue(row, "Untrained"))))
|
|
lines = append(lines, formatFact("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty"))))
|
|
case "spells":
|
|
lines = append(lines, formatFact("Constant", stringValue(row, "Constant")))
|
|
case "baseitems":
|
|
lines = append(lines, formatFact("Item Class", stringValue(row, "ItemClass")))
|
|
lines = append(lines, formatFact("Weapon Type", stringValue(row, "WeaponType")))
|
|
lines = append(lines, formatFact("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row))))
|
|
lines = append(lines, formatFact("Base Item Stats", ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil)))
|
|
case "classes":
|
|
lines = append(lines, formatFact("Hit Die", "d"+stringValue(row, "HitDie")))
|
|
lines = append(lines, formatFact("Skill Points", stringValue(row, "SkillPointBase")))
|
|
lines = append(lines, formatFact("Primary Ability", stringValue(row, "PrimaryAbil")))
|
|
case "racialtypes":
|
|
lines = append(lines, formatFact("Ability Adjustments", formatAbilityAdjustments(row)))
|
|
lines = append(lines, formatFact("Favored Class", ctx.renderReference(fieldValue(row, "Favored"), ctx.classIDToKey, ctx.resolveClassName)))
|
|
lines = append(lines, formatFact("Favored Enemy", ctx.renderReference(fieldValue(row, "FavoredEnemyFeat"), ctx.featIDToKey, ctx.resolveFeatName)))
|
|
lines = append(lines, formatFact("Racial Feats", ctx.renderRaceFeatList(row)))
|
|
}
|
|
out := make([]string, 0, len(lines))
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) != "" {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func (ctx *wikiContext) renderFeatFacts(row map[string]any) []string {
|
|
lines := []string{}
|
|
lines = append(lines, formatFact("Prerequisites", ctx.renderFeatPrerequisites(row)))
|
|
lines = append(lines, formatFact("Minimum Attack Bonus", stringValue(row, "MINATTACKBONUS")))
|
|
lines = append(lines, formatFact("Minimum Spell Level", stringValue(row, "MINSPELLLVL")))
|
|
lines = append(lines, formatFact("Minimum Fortitude Save", stringValue(row, "MinFortSave")))
|
|
return lines
|
|
}
|
|
|
|
func (ctx *wikiContext) renderFeatPrerequisites(row map[string]any) string {
|
|
parts := []string{}
|
|
for _, field := range []struct {
|
|
Key string
|
|
Label string
|
|
}{
|
|
{"MINSTR", "Str"}, {"MINDEX", "Dex"}, {"MINCON", "Con"}, {"MININT", "Int"}, {"MINWIS", "Wis"}, {"MINCHA", "Cha"},
|
|
} {
|
|
if value := stringValue(row, field.Key); value != "" {
|
|
parts = append(parts, field.Label+" "+value)
|
|
}
|
|
}
|
|
for _, field := range []string{"PREREQFEAT1", "PREREQFEAT2"} {
|
|
if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
|
|
parts = append(parts, rendered)
|
|
}
|
|
}
|
|
orReqs := []string{}
|
|
for _, field := range []string{"OrReqFeat0", "OrReqFeat1", "OrReqFeat2", "OrReqFeat3", "OrReqFeat4"} {
|
|
if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
|
|
orReqs = append(orReqs, rendered)
|
|
}
|
|
}
|
|
if len(orReqs) > 0 {
|
|
parts = append(parts, strings.Join(orReqs, " or "))
|
|
}
|
|
if rendered := ctx.renderReference(fieldValue(row, "MinLevelClass"), ctx.classIDToKey, ctx.resolveClassName); rendered != "" {
|
|
if value := stringValue(row, "MinLevel"); value != "" {
|
|
parts = append(parts, "Level "+value+" of "+rendered)
|
|
}
|
|
}
|
|
for _, spec := range []struct {
|
|
SkillField string
|
|
RankField string
|
|
}{
|
|
{"REQSKILL", "ReqSkillMinRanks"},
|
|
{"REQSKILL2", "ReqSkillMinRanks2"},
|
|
} {
|
|
if skill := ctx.renderReference(fieldValue(row, spec.SkillField), ctx.skillIDToKey, ctx.resolveSkillName); skill != "" {
|
|
ranks := stringValue(row, spec.RankField)
|
|
if ranks != "" {
|
|
parts = append(parts, skill+" "+ranks+" ranks")
|
|
} else {
|
|
parts = append(parts, skill)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (ctx *wikiContext) renderProgression(category string, row map[string]any) string {
|
|
if category != "classes" {
|
|
return ""
|
|
}
|
|
lines := []string{}
|
|
if table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables); table != nil {
|
|
skills := []string{}
|
|
for _, skillRow := range table.Rows {
|
|
if stringValue(skillRow, "ClassSkill") != "1" {
|
|
continue
|
|
}
|
|
name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName)
|
|
if name != "" {
|
|
skills = append(skills, name)
|
|
}
|
|
}
|
|
if len(skills) > 0 {
|
|
lines = append(lines, "==== Class Skills ====")
|
|
lines = append(lines, "")
|
|
lines = append(lines, " * "+strings.Join(skills, ", "))
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables); table != nil {
|
|
byLevel := map[string][]string{}
|
|
selectable := []string{}
|
|
for _, featRow := range table.Rows {
|
|
name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
|
|
if name == "" {
|
|
name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" })
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
level := stringValue(featRow, "GrantedOnLevel")
|
|
if level == "" || strings.HasPrefix(level, "-") {
|
|
selectable = append(selectable, name)
|
|
continue
|
|
}
|
|
byLevel[level] = append(byLevel[level], name)
|
|
}
|
|
if len(byLevel) > 0 {
|
|
lines = append(lines, "==== Granted Feats ====")
|
|
lines = append(lines, "")
|
|
levels := sortedKeys(byLevel)
|
|
for _, level := range levels {
|
|
lines = append(lines, " * Level "+level+": "+strings.Join(byLevel[level], ", "))
|
|
}
|
|
lines = append(lines, "")
|
|
}
|
|
if len(selectable) > 0 {
|
|
lines = append(lines, "==== Selectable Feats ====")
|
|
lines = append(lines, "")
|
|
lines = append(lines, " * "+strings.Join(selectable, ", "))
|
|
lines = append(lines, "")
|
|
}
|
|
}
|
|
if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables); table != nil && len(table.Rows) > 0 {
|
|
lines = append(lines, "==== Bonus Feats ====")
|
|
lines = append(lines, "")
|
|
lines = append(lines, " * Bonus feat table: "+table.OutputStem)
|
|
lines = append(lines, "")
|
|
}
|
|
if len(lines) == 0 {
|
|
return "No class progression data."
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (ctx *wikiContext) renderRaceNameForms(row map[string]any) string {
|
|
parts := []string{}
|
|
if plural := ctx.resolveTextValue(fieldValue(row, "NamePlural"), nil); plural != "" {
|
|
parts = append(parts, "**Plural:** "+plural+`\\`)
|
|
}
|
|
if converted := ctx.resolveTextValue(fieldValue(row, "ConverName"), nil); converted != "" {
|
|
parts = append(parts, "**Converted Name:** "+converted+`\\`)
|
|
}
|
|
if lower := ctx.resolveTextValue(fieldValue(row, "ConverNameLower"), nil); lower != "" {
|
|
parts = append(parts, "**Lower Name:** "+lower+`\\`)
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func (ctx *wikiContext) renderRaceFeatList(row map[string]any) string {
|
|
table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables)
|
|
if table == nil {
|
|
return ""
|
|
}
|
|
parts := []string{}
|
|
for _, featRow := range table.Rows {
|
|
if rendered := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
|
|
parts = append(parts, rendered)
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string {
|
|
key := resolveReferenceKey(value, idToKey)
|
|
if key == "" || !ctx.canReferenceWikiKey(key) {
|
|
return ""
|
|
}
|
|
name := nameResolver(key)
|
|
if name == "" {
|
|
name = key
|
|
}
|
|
if target := ctx.publicWikiTargetForKey(key, name); target != "" {
|
|
return "[[" + target + "|" + name + "]]"
|
|
}
|
|
return name
|
|
}
|
|
|
|
func (ctx *wikiContext) renderReferenceList(values []any) string {
|
|
parts := []string{}
|
|
for _, value := range values {
|
|
if rendered := ctx.renderReference(value, ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
|
|
parts = append(parts, rendered)
|
|
}
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveRowName(category string, row map[string]any) string {
|
|
spec := specForDataset(category)
|
|
nameFields := spec.NameFields
|
|
if category == "classes" {
|
|
nameFields = []string{"Name", "Plural", "Lower", "CLASS", "Label", "Short"}
|
|
}
|
|
for _, field := range nameFields {
|
|
if value, ok := lookupField(row, field); ok {
|
|
if text := ctx.resolveTextValue(value, nil); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveRowDescription(category string, row map[string]any) string {
|
|
spec := specForDataset(category)
|
|
for _, field := range spec.DescriptionFields {
|
|
if value, ok := lookupField(row, field); ok {
|
|
if text := ctx.resolveTextValue(value, nil); text != "" {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveTextValue(value any, stack map[string]struct{}) string {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
trimmed := strings.TrimSpace(typed)
|
|
if trimmed == "" || trimmed == nullValue {
|
|
return ""
|
|
}
|
|
if entry, ok := ctx.dialog[trimmed]; ok {
|
|
return entry
|
|
}
|
|
return trimmed
|
|
case int:
|
|
if entry, ok := ctx.dialog[strconv.Itoa(typed)]; ok {
|
|
return entry
|
|
}
|
|
return strconv.Itoa(typed)
|
|
case float64:
|
|
text := strconv.Itoa(int(typed))
|
|
if entry, ok := ctx.dialog[text]; ok {
|
|
return entry
|
|
}
|
|
return text
|
|
case map[string]any:
|
|
payload, ok, err := parseTLKPayload(typed, true)
|
|
if err == nil && ok {
|
|
switch {
|
|
case payload.Text != "":
|
|
return payload.Text
|
|
case payload.Ref != "":
|
|
if stack == nil {
|
|
stack = map[string]struct{}{}
|
|
}
|
|
token := payload.Ref + "." + payload.RefField
|
|
if _, seen := stack[token]; seen {
|
|
return ""
|
|
}
|
|
stack[token] = struct{}{}
|
|
defer delete(stack, token)
|
|
target := ctx.rowsByKey[payload.Ref]
|
|
if target == nil {
|
|
return ""
|
|
}
|
|
field, ok := lookupField(target, payload.RefField)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return ctx.resolveTextValue(field, stack)
|
|
case payload.Key != "":
|
|
if entry, ok := ctx.dialog[payload.Key]; ok {
|
|
return entry
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveFeatName(key string) string {
|
|
if row := ctx.featRows[key]; row != nil {
|
|
return ctx.resolveRowName("feat", row)
|
|
}
|
|
if row := ctx.masterfeatRows[key]; row != nil {
|
|
return ctx.resolveRowName("masterfeats", row)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveSkillName(key string) string {
|
|
if row := ctx.skillRows[key]; row != nil {
|
|
return ctx.resolveRowName("skills", row)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveClassName(key string) string {
|
|
if row := ctx.classRows[key]; row != nil {
|
|
return ctx.resolveRowName("classes", row)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string {
|
|
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
|
|
if wiki, ok := meta["wiki"].(map[string]any); ok {
|
|
if value, ok := wiki["generate"]; ok {
|
|
return normalizeWikiScalar(value)
|
|
}
|
|
}
|
|
}
|
|
if value, ok := lookupField(row, "WIKIGENERATE"); ok {
|
|
return normalizeWikiScalar(value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) wikiReqFeats(row map[string]any) []any {
|
|
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
|
|
if wiki, ok := meta["wiki"].(map[string]any); ok {
|
|
if value, ok := wiki["reqfeats"]; ok {
|
|
if typed, ok := value.([]any); ok {
|
|
return typed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ctx *wikiContext) pageStatus(category, key string, row map[string]any) string {
|
|
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
|
|
if wiki, ok := meta["wiki"].(map[string]any); ok {
|
|
if value, ok := wiki["status"]; ok {
|
|
if normalized := normalizeWikiScalar(value); normalized != "" {
|
|
return normalized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if categoryStatuses, ok := ctx.statuses[category]; ok {
|
|
if status, ok := categoryStatuses[key]; ok && status != "" {
|
|
return status
|
|
}
|
|
}
|
|
return wikiStatusVanilla
|
|
}
|
|
|
|
func (ctx *wikiContext) collectImplementedFeatKeys() map[string]struct{} {
|
|
implemented := map[string]struct{}{}
|
|
masterfeatGroups := map[string][]string{}
|
|
for key, row := range ctx.featRows {
|
|
if group := masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows); group != "" {
|
|
masterfeatGroups[group] = append(masterfeatGroups[group], key)
|
|
}
|
|
}
|
|
classCounts := func(row map[string]any) bool {
|
|
override := ctx.wikiGenerateOverride(row)
|
|
if override == "1" {
|
|
return true
|
|
}
|
|
if override == "0" {
|
|
return false
|
|
}
|
|
return stringValue(row, "PlayerClass") == "1"
|
|
}
|
|
for _, row := range ctx.classRows {
|
|
if !classCounts(row) {
|
|
continue
|
|
}
|
|
for _, tableMap := range []map[string]wikiTable{ctx.classFeatTables, ctx.classBonusTables} {
|
|
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), tableMap); table != nil {
|
|
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
|
|
}
|
|
if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), tableMap); table != nil {
|
|
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
|
|
}
|
|
}
|
|
}
|
|
for _, row := range ctx.raceRows {
|
|
override := ctx.wikiGenerateOverride(row)
|
|
if override == "0" || (override != "1" && stringValue(row, "PlayerRace") != "1") {
|
|
continue
|
|
}
|
|
if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables); table != nil {
|
|
ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
|
|
}
|
|
}
|
|
return implemented
|
|
}
|
|
|
|
func (ctx *wikiContext) collectImplementedFromTable(target map[string]struct{}, masterfeatGroups map[string][]string, rows []map[string]any) {
|
|
for _, row := range rows {
|
|
key := resolveReferenceKey(fieldValue(row, "FeatIndex"), ctx.featIDToKey)
|
|
if key == "" {
|
|
if key = resolveReferenceKey(fieldValue(row, "FeatIndex"), nil); key == "" {
|
|
continue
|
|
}
|
|
}
|
|
if strings.HasPrefix(key, "masterfeats:") {
|
|
for _, featKey := range masterfeatGroups[key] {
|
|
target[featKey] = struct{}{}
|
|
}
|
|
continue
|
|
}
|
|
target[key] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func (ctx *wikiContext) tableForValue(value any, tables map[string]wikiTable) *wikiTable {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if table, ok := tables[typed]; ok {
|
|
return &table
|
|
}
|
|
case map[string]any:
|
|
if tableKey, ok := typed["table"].(string); ok {
|
|
tableKey = outputStem(tableKey)
|
|
if table, ok := tables[tableKey]; ok {
|
|
return &table
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectDatasetStatuses(root, datasetName string) map[string]string {
|
|
statuses := map[string]string{}
|
|
basePath := filepath.Join(root, "base.json")
|
|
baseObj, err := loadJSONObject(basePath)
|
|
if err == nil {
|
|
if rows, ok := baseObj["rows"].([]any); ok {
|
|
for _, raw := range rows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key := stringField(row, "key"); key != "" {
|
|
statuses[key] = wikiStatusVanilla
|
|
}
|
|
}
|
|
}
|
|
}
|
|
mergeExplicitWikiStatuses(basePath, statuses)
|
|
for _, dirName := range []string{"modules", "generated"} {
|
|
dir := filepath.Join(root, dirName)
|
|
paths, err := collectModulePaths(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, path := range paths {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if entries, ok := obj["entries"].(map[string]any); ok {
|
|
for key, raw := range entries {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, exists := statuses[key]; exists {
|
|
statuses[key] = wikiStatusModified
|
|
} else {
|
|
statuses[key] = wikiStatusNew
|
|
}
|
|
mergeRowWikiStatus(key, row, statuses)
|
|
}
|
|
}
|
|
if overrides, ok := obj["overrides"].([]any); ok {
|
|
for _, raw := range overrides {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
key := stringField(row, "key")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := statuses[key]; exists {
|
|
statuses[key] = wikiStatusModified
|
|
} else {
|
|
statuses[key] = wikiStatusNew
|
|
}
|
|
mergeRowWikiStatus(key, row, statuses)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ = datasetName
|
|
return statuses
|
|
}
|
|
|
|
func collectRaceStatuses(root string) map[string]string {
|
|
statuses := map[string]string{}
|
|
baseObj, err := loadJSONObject(filepath.Join(root, "base.json"))
|
|
if err == nil {
|
|
if rows, ok := baseObj["rows"].([]any); ok {
|
|
for _, raw := range rows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key := stringField(row, "key"); key != "" {
|
|
statuses[key] = wikiStatusVanilla
|
|
}
|
|
}
|
|
}
|
|
}
|
|
paths, err := collectModulePaths(filepath.Join(root, "races"))
|
|
if err != nil {
|
|
return statuses
|
|
}
|
|
for _, path := range paths {
|
|
row, err := loadJSONObject(path)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
key := stringField(row, "key")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, exists := statuses[key]; exists {
|
|
statuses[key] = wikiStatusModified
|
|
} else {
|
|
statuses[key] = wikiStatusNew
|
|
}
|
|
mergeRowWikiStatus(key, row, statuses)
|
|
if core, ok := row["core"].(map[string]any); ok {
|
|
mergeRowWikiStatus(key, core, statuses)
|
|
}
|
|
}
|
|
return statuses
|
|
}
|
|
|
|
func mergeExplicitWikiStatuses(path string, statuses map[string]string) {
|
|
obj, err := loadJSONObject(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if rows, ok := obj["rows"].([]any); ok {
|
|
for _, raw := range rows {
|
|
row, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if key := stringField(row, "key"); key != "" {
|
|
mergeRowWikiStatus(key, row, statuses)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func mergeRowWikiStatus(key string, row map[string]any, statuses map[string]string) {
|
|
if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
|
|
if wiki, ok := meta["wiki"].(map[string]any); ok {
|
|
if value, ok := wiki["status"]; ok {
|
|
if normalized := normalizeWikiScalar(value); normalized != "" {
|
|
statuses[key] = normalized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildWikiStatusBlock(status, category string) string {
|
|
message := "This " + wikiStatusNoun(category) + " is unchanged from vanilla."
|
|
switch status {
|
|
case wikiStatusNew:
|
|
message = "This is a new " + wikiStatusNoun(category) + "!"
|
|
case wikiStatusModified:
|
|
message = "This " + wikiStatusNoun(category) + " has been altered from vanilla."
|
|
}
|
|
return "<p class=\"wiki-callout wiki-callout--status\">" + html.EscapeString(message) + "</p>"
|
|
}
|
|
|
|
func wikiStatusNoun(category string) string {
|
|
switch category {
|
|
case "feat":
|
|
return "feat"
|
|
case "skills":
|
|
return "skill"
|
|
case "spells":
|
|
return "spell"
|
|
case "classes":
|
|
return "class"
|
|
case "racialtypes":
|
|
return "race"
|
|
case "baseitems":
|
|
return "item type"
|
|
default:
|
|
return "page"
|
|
}
|
|
}
|
|
|
|
func generateStatusPages(outputDir string, pageStatuses map[string]string, pageTitles map[string]string, namespaces []string, listingScope string, manualSections []wikiManualSection, ctx *wikiContext) error {
|
|
summaries := buildWikiNamespaceSummaries(pageStatuses, namespaces)
|
|
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.html"), renderWikiStatusReportPage(summaries, namespaces, ctx), manualSections); err != nil {
|
|
return err
|
|
}
|
|
for _, namespace := range namespaces {
|
|
summary := summaries[namespace]
|
|
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace+".html"), renderWikiNamespaceFragment(namespace, summary, ctx), manualSections); err != nil {
|
|
return err
|
|
}
|
|
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
|
|
pageIDs := filterWikiPageIDs(pageStatuses, namespace, status)
|
|
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace, status+".html"), renderWikiStatusListingPage(wikiNamespacePublicTitle(ctx, namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles, ctx), manualSections); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if listingScope == "" {
|
|
listingScope = project.DefaultTopDataWikiStatusListingScope
|
|
}
|
|
if listingScope == "all" {
|
|
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
|
|
pageIDs := filterWikiPageIDs(pageStatuses, "", status)
|
|
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".html"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles, ctx), manualSections); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeWikiFile(path, content string, manualSections []wikiManualSection) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
pageID := wikiRelPathToPageID(path)
|
|
content = renderNodeBBManagedHTML(pageID, content, manualSections)
|
|
return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
|
|
}
|
|
|
|
func buildWikiNamespaceSummaries(pageStatuses map[string]string, namespaces []string) map[string]map[string]any {
|
|
summaries := map[string]map[string]any{}
|
|
for _, namespace := range namespaces {
|
|
statuses := []string{}
|
|
for pageID, status := range pageStatuses {
|
|
if strings.SplitN(pageID, ":", 2)[0] == namespace {
|
|
statuses = append(statuses, status)
|
|
}
|
|
}
|
|
summaries[namespace] = map[string]any{
|
|
"status": rollupWikiStatuses(statuses),
|
|
"new": countWikiStatus(statuses, wikiStatusNew),
|
|
"modified": countWikiStatus(statuses, wikiStatusModified),
|
|
"vanilla": countWikiStatus(statuses, wikiStatusVanilla),
|
|
"total": len(statuses),
|
|
}
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func countWikiStatus(statuses []string, target string) int {
|
|
count := 0
|
|
for _, status := range statuses {
|
|
if status == target {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func rollupWikiStatuses(statuses []string) string {
|
|
best := wikiStatusVanilla
|
|
for _, status := range statuses {
|
|
if wikiStatusPriority[status] > wikiStatusPriority[best] {
|
|
best = status
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func wikiNamespacePublicTitle(ctx *wikiContext, namespace string) string {
|
|
if ctx != nil && ctx.namespaceTitles != nil {
|
|
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
|
|
return declared
|
|
}
|
|
}
|
|
return namespaceTitle(namespace)
|
|
}
|
|
|
|
func renderWikiNamespaceFragment(namespace string, summary map[string]any, ctx *wikiContext) string {
|
|
return renderStatusTable(wikiNamespacePublicTitle(ctx, namespace)+" Namespace", summary, "")
|
|
}
|
|
|
|
func renderWikiStatusReportPage(summaries map[string]map[string]any, namespaces []string, ctx *wikiContext) string {
|
|
if ctx == nil {
|
|
ctx = &wikiContext{}
|
|
}
|
|
lines := []string{"<h2>Wiki Status</h2>", "<p>This page is auto-generated from builder source provenance.</p>"}
|
|
for _, namespace := range namespaces {
|
|
summary := summaries[namespace]
|
|
title := wikiNamespacePublicTitle(ctx, namespace)
|
|
lines = append(lines, renderStatusTable(title, summary, fmt.Sprintf("[[%s|%s]]", ctx.publicWikiPath(namespace, ""), title)))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string, ctx *wikiContext) string {
|
|
if ctx == nil {
|
|
ctx = &wikiContext{}
|
|
}
|
|
lines := []string{"<h2>" + html.EscapeString(title) + "</h2>"}
|
|
if len(pageIDs) == 0 {
|
|
lines = append(lines, "<p>No matching generated pages.</p>")
|
|
} else {
|
|
lines = append(lines, `<ul class="wiki-list wiki-status-list">`)
|
|
for _, pageID := range pageIDs {
|
|
title := pageTitles[pageID]
|
|
if title == "" {
|
|
title = pageID
|
|
}
|
|
namespace := strings.SplitN(pageID, ":", 2)[0]
|
|
lines = append(lines, "<li>"+renderInlineHTML("[["+ctx.publicWikiPath(namespace, title)+"|"+title+"]]")+"</li>")
|
|
}
|
|
lines = append(lines, "</ul>")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func renderStatusTable(title string, summary map[string]any, namespaceLink string) string {
|
|
rows := []string{
|
|
"<h2>" + html.EscapeString(title) + "</h2>",
|
|
`<table class="wiki-facts wiki-status-summary"><tbody>`,
|
|
"<tr><th>Namespace Status</th><td>" + html.EscapeString(wikiStatusLabels[summary["status"].(string)]) + "</td></tr>",
|
|
fmt.Sprintf("<tr><th>Generated Pages</th><td>%d</td></tr>", summary["total"].(int)),
|
|
fmt.Sprintf("<tr><th>New</th><td>%d</td></tr>", summary["new"].(int)),
|
|
fmt.Sprintf("<tr><th>Modified</th><td>%d</td></tr>", summary["modified"].(int)),
|
|
fmt.Sprintf("<tr><th>Vanilla</th><td>%d</td></tr>", summary["vanilla"].(int)),
|
|
}
|
|
if namespaceLink != "" {
|
|
rows = append(rows, "<tr><th>Namespace</th><td>"+renderInlineHTML(namespaceLink)+"</td></tr>")
|
|
}
|
|
rows = append(rows, "</tbody></table>")
|
|
return strings.Join(rows, "\n")
|
|
}
|
|
|
|
func filterWikiPageIDs(pageStatuses map[string]string, namespace, status string) []string {
|
|
pageIDs := []string{}
|
|
for pageID, candidate := range pageStatuses {
|
|
if candidate != status {
|
|
continue
|
|
}
|
|
if namespace != "" && strings.SplitN(pageID, ":", 2)[0] != namespace {
|
|
continue
|
|
}
|
|
pageIDs = append(pageIDs, pageID)
|
|
}
|
|
slices.Sort(pageIDs)
|
|
return pageIDs
|
|
}
|
|
|
|
func wikiNamespaceTitles(declarations []wikiNamespaceDeclaration) map[string]string {
|
|
titles := map[string]string{}
|
|
for _, declaration := range declarations {
|
|
id := strings.TrimSpace(declaration.ID)
|
|
title := strings.TrimSpace(declaration.Title)
|
|
if id != "" && title != "" {
|
|
titles[id] = title
|
|
}
|
|
}
|
|
return titles
|
|
}
|
|
|
|
func wikiNamespacePaths(declarations []wikiNamespaceDeclaration) map[string]string {
|
|
paths := map[string]string{}
|
|
for _, declaration := range declarations {
|
|
id := strings.TrimSpace(declaration.ID)
|
|
path := strings.TrimSpace(declaration.CanonicalPath)
|
|
if id != "" && path != "" {
|
|
paths[id] = path
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func wikiPageIDForKey(key string) string {
|
|
if key == "" {
|
|
return ""
|
|
}
|
|
category, value, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
namespace := map[string]string{
|
|
"baseitems": "itemtypes",
|
|
"racialtypes": "races",
|
|
"skills": "skills",
|
|
"spells": "spells",
|
|
"classes": "classes",
|
|
"feat": "feat",
|
|
}[category]
|
|
if namespace == "" {
|
|
return ""
|
|
}
|
|
value = strings.NewReplacer("/", "_", "\\", "_").Replace(value)
|
|
return namespace + ":" + value
|
|
}
|
|
|
|
func (ctx *wikiContext) publicWikiTargetForKey(key, fallbackTitle string) string {
|
|
pageID := wikiPageIDForKey(key)
|
|
if pageID == "" {
|
|
return ""
|
|
}
|
|
title := ""
|
|
if row, ok := ctx.rowsByKey[key]; ok {
|
|
category, _, _ := strings.Cut(key, ":")
|
|
title = ctx.resolveRowName(category, row)
|
|
}
|
|
if strings.TrimSpace(title) == "" {
|
|
title = fallbackTitle
|
|
}
|
|
if strings.TrimSpace(title) == "" {
|
|
title = key
|
|
}
|
|
namespace := strings.SplitN(pageID, ":", 2)[0]
|
|
return ctx.publicWikiPath(namespace, title)
|
|
}
|
|
|
|
func (ctx *wikiContext) publicWikiPath(namespace, title string) string {
|
|
namespacePath := canonicalWikiSegment(namespaceTitle(namespace))
|
|
if ctx != nil && ctx.namespacePaths != nil {
|
|
if declared := strings.TrimSpace(ctx.namespacePaths[namespace]); declared != "" {
|
|
namespacePath = declared
|
|
}
|
|
}
|
|
if ctx != nil && ctx.namespaceTitles != nil && (ctx.namespacePaths == nil || strings.TrimSpace(ctx.namespacePaths[namespace]) == "") {
|
|
if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" {
|
|
namespacePath = canonicalWikiSegment(declared)
|
|
}
|
|
}
|
|
titlePath := canonicalWikiPath(wikiTitlePathParts(title)...)
|
|
switch {
|
|
case namespacePath == "":
|
|
return titlePath
|
|
case titlePath == "":
|
|
return namespacePath
|
|
default:
|
|
return namespacePath + "/" + titlePath
|
|
}
|
|
}
|
|
|
|
func wikiTitlePathParts(title string) []string {
|
|
parts := []string{}
|
|
for _, part := range strings.Split(title, "::") {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func wikiSlugifyTitle(value string) string {
|
|
return slugifyWikiText(value, "topic")
|
|
}
|
|
|
|
func wikiPageOutputRelPath(pageID, title string) string {
|
|
namespace := strings.TrimSpace(strings.SplitN(pageID, ":", 2)[0])
|
|
if namespace == "" {
|
|
namespace = "page"
|
|
}
|
|
parts := []string{namespace}
|
|
parts = append(parts, wikiTitlePathParts(title)...)
|
|
path := canonicalWikiPath(parts...)
|
|
if path == "" {
|
|
path = wikiPageIDToRelPath(pageID)
|
|
return path
|
|
}
|
|
return filepath.FromSlash(path + ".html")
|
|
}
|
|
|
|
func wikiPageIDToRelPath(pageID string) string {
|
|
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
|
|
}
|
|
|
|
func wikiRelPathToPageID(path string) string {
|
|
path = filepath.ToSlash(path)
|
|
path = strings.TrimSuffix(path, ".md")
|
|
path = strings.TrimSuffix(path, ".html")
|
|
if index := strings.LastIndex(path, "/pages/"); index >= 0 {
|
|
path = path[index+len("/pages/"):]
|
|
}
|
|
return strings.ReplaceAll(path, "/", ":")
|
|
}
|
|
|
|
func namespaceTitle(namespace string) string {
|
|
switch namespace {
|
|
case "itemtypes":
|
|
return "Item Types"
|
|
case "feat":
|
|
return "Feats"
|
|
default:
|
|
return strings.Title(namespace)
|
|
}
|
|
}
|
|
|
|
func categoryFromWikiNamespace(namespace string) string {
|
|
switch namespace {
|
|
case "itemtypes":
|
|
return "baseitems"
|
|
case "races":
|
|
return "racialtypes"
|
|
default:
|
|
return namespace
|
|
}
|
|
}
|
|
|
|
func wikiEditPolicy(namespace string) string {
|
|
if namespace == "meta" {
|
|
return "generated_only"
|
|
}
|
|
return "preserve_manual_sections"
|
|
}
|
|
|
|
func normalizeWikiScalar(value any) string {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
trimmed := strings.TrimSpace(strings.ToLower(typed))
|
|
if _, ok := wikiStatusPriority[trimmed]; ok {
|
|
return trimmed
|
|
}
|
|
if trimmed == "0" || trimmed == "1" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func resolveReferenceKey(value any, idToKey map[int]string) string {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
if key, ok := typed["key"].(string); ok && strings.Contains(key, ":") {
|
|
return key
|
|
}
|
|
if id, ok := typed["id"].(string); ok && strings.Contains(id, ":") {
|
|
return id
|
|
}
|
|
if idToKey != nil {
|
|
if rawID, ok := typed["id"]; ok {
|
|
if id, err := asInt(rawID); err == nil {
|
|
return idToKey[id]
|
|
}
|
|
}
|
|
}
|
|
case string:
|
|
if strings.Contains(typed, ":") {
|
|
return typed
|
|
}
|
|
if idToKey != nil {
|
|
if id, err := strconv.Atoi(typed); err == nil {
|
|
return idToKey[id]
|
|
}
|
|
}
|
|
case int:
|
|
if idToKey != nil {
|
|
return idToKey[typed]
|
|
}
|
|
case float64:
|
|
if idToKey != nil {
|
|
return idToKey[int(typed)]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func masterfeatGroupKey(value any, masterfeatRows map[string]map[string]any) string {
|
|
key := resolveReferenceKey(value, nil)
|
|
if strings.HasPrefix(key, "masterfeats:") {
|
|
return key
|
|
}
|
|
id, err := asInt(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for key, row := range masterfeatRows {
|
|
if rowID, ok := row["id"].(int); ok && rowID == id {
|
|
return key
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func fieldValue(row map[string]any, field string) any {
|
|
value, _ := lookupField(row, field)
|
|
return value
|
|
}
|
|
|
|
func stringValue(row map[string]any, field string) string {
|
|
value, ok := lookupField(row, field)
|
|
if !ok || value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if typed == nullValue {
|
|
return ""
|
|
}
|
|
return typed
|
|
case int:
|
|
return strconv.Itoa(typed)
|
|
case float64:
|
|
return strconv.Itoa(int(typed))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func formatAbilityAdjustments(row map[string]any) string {
|
|
parts := []string{}
|
|
for _, spec := range []struct {
|
|
Field string
|
|
Label string
|
|
}{
|
|
{"StrAdjust", "Strength"},
|
|
{"DexAdjust", "Dexterity"},
|
|
{"ConAdjust", "Constitution"},
|
|
{"IntAdjust", "Intelligence"},
|
|
{"WisAdjust", "Wisdom"},
|
|
{"ChaAdjust", "Charisma"},
|
|
} {
|
|
value := stringValue(row, spec.Field)
|
|
if value == "" || value == "0" {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(value, "-") {
|
|
value = "+" + value
|
|
}
|
|
parts = append(parts, value+" "+spec.Label)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func yesNoValue(value string) string {
|
|
switch value {
|
|
case "1":
|
|
return "Yes"
|
|
case "0":
|
|
return "No"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func formatFact(label, value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return ""
|
|
}
|
|
return "**" + label + ":** " + value + `\\`
|
|
}
|
|
|
|
func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string {
|
|
body := strings.TrimSpace(sourceText)
|
|
hash := computeManagedHash(body)
|
|
parts := []string{
|
|
"<!-- sow-topdata-wiki:page=" + pageID + " -->",
|
|
"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
|
|
body,
|
|
"<!-- sow-topdata-wiki:managed:end -->",
|
|
}
|
|
renderedSections := extractManualRegions(body)
|
|
for _, section := range manualSections {
|
|
if _, ok := renderedSections[section.ID]; ok {
|
|
continue
|
|
}
|
|
parts = append(parts,
|
|
renderWikiManualSection(section),
|
|
)
|
|
}
|
|
return ensureTrailingNewline(strings.Join(parts, "\n"))
|
|
}
|
|
|
|
func renderInlineHTML(text string) string {
|
|
var out strings.Builder
|
|
for {
|
|
start := strings.Index(text, "[[")
|
|
if start < 0 {
|
|
out.WriteString(html.EscapeString(text))
|
|
break
|
|
}
|
|
end := strings.Index(text[start+2:], "]]")
|
|
if end < 0 {
|
|
out.WriteString(html.EscapeString(text))
|
|
break
|
|
}
|
|
end += start + 2
|
|
out.WriteString(html.EscapeString(text[:start]))
|
|
out.WriteString(text[start : end+2])
|
|
text = text[end+2:]
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func renderWikiLinks(text string) string {
|
|
var out strings.Builder
|
|
for {
|
|
start := strings.Index(text, "[[")
|
|
if start < 0 {
|
|
out.WriteString(text)
|
|
break
|
|
}
|
|
end := strings.Index(text[start+2:], "]]")
|
|
if end < 0 {
|
|
out.WriteString(text)
|
|
break
|
|
}
|
|
end += start + 2
|
|
out.WriteString(text[:start])
|
|
marker := text[start : end+2]
|
|
out.WriteString(marker)
|
|
text = text[end+2:]
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
func ensureTrailingNewline(text string) string {
|
|
if strings.HasSuffix(text, "\n") {
|
|
return text
|
|
}
|
|
return text + "\n"
|
|
}
|
|
|
|
func isToolLike(values ...string) bool {
|
|
haystack := strings.ToLower(strings.Join(values, " "))
|
|
return strings.Contains(haystack, "dm tool") || strings.Contains(haystack, "player tool") || strings.Contains(haystack, "activate item") || strings.Contains(haystack, "dm_tool") || strings.Contains(haystack, "player_tool") || strings.Contains(haystack, "activate_item")
|
|
}
|