sow-topdata moved skills into a tree (skills/core, skills/specs, skillcats, skillspecranks), which broke validate/build: the generated feat families hardcoded dataset "skills". Resolution is now data-driven: the family spec's child_source names the dataset, child slugs come from the row key's own namespace, and the wiki loader accepts both the flat and tree layouts (topdata main still uses the flat one until the overhaul merges). Verified against the skill-grouping-overhaul topdata branch: validate/build/wiki all clean with zero lock churn, focus feats stay on their adopted rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #43 Co-authored-by: vickydotbat <vickydotbat@tutamail.com> Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2162 lines
63 KiB
Go
2162 lines
63 KiB
Go
package topdata
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.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
|
|
classSpellGainTables map[string]wikiTable
|
|
classSpellKnownTables map[string]wikiTable
|
|
classSpellbooks map[string]classSpellbookSpec
|
|
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
|
|
alignmentLinkPattern string
|
|
alignmentLinkFormat 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)
|
|
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 = p.TopDataWikiPageTemplatesDir()
|
|
ctx.alignmentLinkPattern = p.EffectiveConfig().TopData.Wiki.AlignmentLinks.TargetPattern
|
|
ctx.alignmentLinkFormat = p.EffectiveConfig().TopData.Wiki.AlignmentLinks.LinkFormat
|
|
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 = p.TopDataWikiDataPath()
|
|
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(p.TopDataWikiPagePathsPath())
|
|
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(p.TopDataWikiPageIndexPath(), 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()
|
|
path := p.TopDataWikiManualSectionsPath()
|
|
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(p *project.Project) (string, error) {
|
|
hasher := sha256.New()
|
|
_, _ = hasher.Write([]byte(wikiGeneratorVersion))
|
|
sourceDir := p.TopDataSourceDir()
|
|
wikiSourceDir := p.TopDataWikiSourceDir()
|
|
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"),
|
|
wikiSourceDir,
|
|
}
|
|
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(p *project.Project) (time.Time, string, error) {
|
|
newest := time.Time{}
|
|
newestPath := ""
|
|
sourceDir := p.TopDataSourceDir()
|
|
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"),
|
|
p.TopDataWikiSourceDir(),
|
|
}
|
|
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(names ...string) (nativeCollectedDataset, bool, error) {
|
|
for _, name := range names {
|
|
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", "skills/core")
|
|
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 := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "bonusfeats"), filepath.Join(dataDir, "classes", "bfeat"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSaveTables, err := loadWikiTablesFromDirs(filepath.Join(dataDir, "classes", "savingthrows"), filepath.Join(dataDir, "classes", "savthr"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSpellGainTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsgained"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSpellKnownTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "spellsknown"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
classSpellbookList, diagnostics := loadClassSpellbookSpecs(filepath.Join(dataDir, "classes", "spellbooks"))
|
|
if len(diagnostics) > 0 {
|
|
return nil, fmt.Errorf("load class spellbooks for wiki: %s", diagnosticsTextForError(diagnostics))
|
|
}
|
|
classSpellbooks := map[string]classSpellbookSpec{}
|
|
for _, spec := range classSpellbookList {
|
|
classSpellbooks[spec.Class] = spec
|
|
}
|
|
|
|
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,
|
|
classSpellGainTables: classSpellGainTables,
|
|
classSpellKnownTables: classSpellKnownTables,
|
|
classSpellbooks: classSpellbooks,
|
|
}
|
|
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, false)
|
|
}
|
|
|
|
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") || strings.EqualFold(filepath.Base(path), "global.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 loadWikiTablesFromDirs(roots ...string) (map[string]wikiTable, error) {
|
|
merged := map[string]wikiTable{}
|
|
for _, root := range roots {
|
|
tables, err := loadWikiTables(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for key, table := range tables {
|
|
merged[key] = table
|
|
}
|
|
}
|
|
return merged, 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) resolveSpellName(key string) string {
|
|
if row := ctx.spellRows[key]; row != nil {
|
|
return ctx.resolveRowName("spells", 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) resolveRaceName(key string) string {
|
|
if row := ctx.raceRows[key]; row != nil {
|
|
return ctx.resolveRowName("racialtypes", row)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (ctx *wikiContext) resolveBaseItemName(key string) string {
|
|
if row := ctx.baseitemRows[key]; row != nil {
|
|
return ctx.resolveRowName("baseitems", 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")
|
|
}
|