Files
sow-tools/internal/topdata/wiki_native.go
T
2026-04-30 20:43:53 +02:00

1743 lines
49 KiB
Go

package topdata
import (
"crypto/sha256"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const (
wikiStatusNew = "new"
wikiStatusModified = "modified"
wikiStatusVanilla = "vanilla"
wikiRootDirName = "wiki"
legacyWikiRootDirName = ".wiki"
wikiPagesDirName = "pages"
wikiStateFileName = "state.json"
wikiGeneratorVersion = "nodebb-markdown-v1"
wikiGeneratedStatus = "generated"
wikiSkippedStatus = "skipped"
)
var (
wikiManagedNamespaces = []string{"classes", "feat", "itemtypes", "races", "skills", "spells"}
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 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
raceFeatTables map[string]wikiTable
statuses map[string]map[string]string
implementedFeats map[string]struct{}
}
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 {
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
}
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{}
pageCount := 0
writePage := func(pageID, content, title, status string) error {
path := filepath.Join(outputDir, wikiPageIDToRelPath(pageID))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
content = renderNodeBBManagedMarkdown(pageID, content)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return err
}
pageTitles[pageID] = title
pageStatuses[pageID] = status
pageCount++
return nil
}
if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
return wikiResult{}, err
}
if err := generateStatusPages(outputDir, pageStatuses, pageTitles); err != nil {
return wikiResult{}, err
}
if err := os.MkdirAll(rootDir, 0o755); err != nil {
return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err)
}
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 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"),
}
for _, dir := range relevantSourceDirs {
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
if strings.EqualFold(filepath.Ext(path), ".json") {
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
}
}
loadBase := func(name string) (nativeCollectedDataset, bool, error) {
datasets, err := discoverNativeDatasets(dataDir)
if err != nil {
return nativeCollectedDataset{}, false, err
}
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 := collectRacialtypesRegistryDatasets(dataDir)
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
}
raceFeatTables[outputStem(dataset.Dataset.OutputName)] = wikiTable{
Key: dataset.TableKey,
Output: dataset.Dataset.OutputName,
OutputStem: outputStem(dataset.Dataset.OutputName),
Rows: cloneRows(dataset.Rows),
}
}
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
}
ctx := &wikiContext{
dialog: dialog,
rowsByKey: map[string]map[string]any{},
statuses: map[string]map[string]string{},
raceFeatTables: raceFeatTables,
classFeatTables: classFeatTables,
classSkillTables: classSkillTables,
classBonusTables: classBonusTables,
}
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"] = collectRaceStatuses(filepath.Join(dataDir, "racialtypes", "registry"))
ctx.implementedFeats = ctx.collectImplementedFeatKeys()
return ctx, nil
}
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
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
}
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) renderPage(category, key string, row map[string]any, title string) (string, error) {
templateName := category
if category == "baseitems" {
templateName = "items"
} else if category == "racialtypes" {
templateName = "races"
}
templateBytes, err := wikiTemplateFS.ReadFile("wiki_templates/" + templateName + ".txt")
if err != nil {
return "", err
}
rendered := string(templateBytes)
rendered = strings.ReplaceAll(rendered, "{{name}}", title)
rendered = strings.ReplaceAll(rendered, "{{status}}", buildWikiStatusBlock(ctx.pageStatus(category, key, row), category))
rendered = strings.ReplaceAll(rendered, "{{description}}", toDokuWiki(ctx.resolveRowDescription(category, row)))
rendered = strings.ReplaceAll(rendered, "{{facts}}", ctx.renderFacts(category, key, row))
rendered = strings.ReplaceAll(rendered, "{{progression}}", ctx.renderProgression(category, row))
rendered = strings.ReplaceAll(rendered, "{{nameforms}}", ctx.renderRaceNameForms(row))
return ensureTrailingNewline(rendered), nil
}
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", toInlineDokuWiki(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 == "" {
return ""
}
name := nameResolver(key)
if name == "" {
name = key
}
if pageID := wikiPageIDForKey(key); pageID != "" {
return "[[" + pageID + "|" + 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."
wrap := "info"
switch status {
case wikiStatusNew:
message = "This is a new " + wikiStatusNoun(category) + "!"
wrap = "tip"
case wikiStatusModified:
message = "This " + wikiStatusNoun(category) + " has been altered from vanilla."
wrap = "help"
}
return "<WRAP round " + wrap + ">\n" + message + "\n</WRAP>"
}
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) error {
summaries := buildWikiNamespaceSummaries(pageStatuses)
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.md"), renderWikiStatusReportPage(summaries)); err != nil {
return err
}
for _, namespace := range wikiManagedNamespaces {
summary := summaries[namespace]
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace+".md"), renderWikiNamespaceFragment(namespace, summary)); 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+".md"), renderWikiStatusListingPage(namespaceTitle(namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles)); err != nil {
return err
}
}
}
for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
pageIDs := filterWikiPageIDs(pageStatuses, "", status)
if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".md"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles)); err != nil {
return err
}
}
return nil
}
func writeWikiFile(path, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
content = renderNodeBBManagedMarkdown(wikiRelPathToPageID(path), content)
return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
}
func buildWikiNamespaceSummaries(pageStatuses map[string]string) map[string]map[string]any {
summaries := map[string]map[string]any{}
for _, namespace := range wikiManagedNamespaces {
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 renderWikiNamespaceFragment(namespace string, summary map[string]any) string {
return strings.Join([]string{
"====== " + namespaceTitle(namespace) + " ======",
"",
fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)]),
fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int)),
fmt.Sprintf("**New:** %d\\\\", summary["new"].(int)),
fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int)),
fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int)),
"",
fmt.Sprintf(" * [[%s:index|%s Index]]", namespace, namespaceTitle(namespace)),
}, "\n")
}
func renderWikiStatusReportPage(summaries map[string]map[string]any) string {
lines := []string{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""}
for _, namespace := range wikiManagedNamespaces {
summary := summaries[namespace]
lines = append(lines, "===== "+namespaceTitle(namespace)+" =====", "")
lines = append(lines, fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)]))
lines = append(lines, fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int)))
lines = append(lines, fmt.Sprintf("**New:** %d\\\\", summary["new"].(int)))
lines = append(lines, fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int)))
lines = append(lines, fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int)))
lines = append(lines, fmt.Sprintf("**Namespace:** [[%s:index|%s]]", namespace, namespaceTitle(namespace)), "")
}
lines = append(lines, "<WRAP center round important>", "**Auto-generated content.** This section is rebuilt from game data on each release.", "</WRAP>", "", "===== Notes =====")
return strings.Join(lines, "\n")
}
func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string) string {
lines := []string{"====== " + title + " ======", ""}
if len(pageIDs) == 0 {
lines = append(lines, "No matching generated pages.")
} else {
for _, pageID := range pageIDs {
title := pageTitles[pageID]
if title == "" {
title = pageID
}
lines = append(lines, " * [["+pageID+"|"+title+"]]")
}
}
lines = append(lines, "", "<WRAP center round important>", "**Auto-generated content.** This section is rebuilt from game data on each release.", "</WRAP>", "", "===== Notes =====")
return strings.Join(lines, "\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 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 ""
}
return namespace + ":" + strings.ReplaceAll(value, "_", ":")
}
func wikiPageIDToRelPath(pageID string) string {
return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".md")
}
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 "Itemtypes"
default:
return strings.Title(namespace)
}
}
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 toInlineDokuWiki(text string) string {
lines := strings.Split(strings.TrimSpace(text), "\n")
for index, line := range lines {
lines[index] = strings.TrimSpace(line)
}
return strings.Join(lines, `\\`)
}
func toDokuWiki(text string) string {
text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
if text == "" {
return ""
}
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case trimmed == "":
out = append(out, "")
case strings.HasPrefix(trimmed, "- "):
out = append(out, " * "+strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
default:
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func renderNodeBBManagedMarkdown(pageID, dokuText string) string {
generated, notes := splitDokuWikiNotes(dokuText)
body := dokuWikiToNodeBBMarkdown(generated)
parts := []string{
"[//]: # (sow-topdata-wiki:page=" + pageID + ")",
"[//]: # (sow-topdata-wiki:managed:start)",
body,
"[//]: # (sow-topdata-wiki:managed:end)",
}
notesMarkdown := dokuWikiToNodeBBMarkdown("===== Notes =====\n" + notes)
if strings.TrimSpace(notesMarkdown) != "" {
parts = append(parts, notesMarkdown)
}
return ensureTrailingNewline(strings.Join(parts, "\n"))
}
func splitDokuWikiNotes(text string) (string, string) {
const delimiter = "===== Notes ====="
before, after, ok := strings.Cut(text, delimiter)
if !ok {
return text, ""
}
return before, after
}
func dokuWikiToNodeBBMarkdown(text string) string {
text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
if text == "" {
return ""
}
var out []string
inNotice := false
closeNotice := func() {
if inNotice {
inNotice = false
if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
out = append(out, "")
}
}
}
appendBlank := func() {
if len(out) == 0 || strings.TrimSpace(out[len(out)-1]) == "" {
return
}
out = append(out, "")
}
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
appendBlank()
continue
}
if strings.HasPrefix(trimmed, "<WRAP") {
appendBlank()
inNotice = true
continue
}
if trimmed == "</WRAP>" {
closeNotice()
continue
}
if heading, level, ok := parseDokuHeading(trimmed); ok {
closeNotice()
appendBlank()
out = append(out, strings.Repeat("#", level)+" "+renderInlineNodeBBMarkdown(heading))
out = append(out, "")
continue
}
if strings.HasPrefix(trimmed, " * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") {
item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, " * "), "* "), "- "))
prefix := "- "
if inNotice {
prefix = "> - "
}
out = append(out, prefix+renderInlineNodeBBMarkdown(item))
continue
}
parts := strings.Split(trimmed, `\\`)
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
prefix := ""
if inNotice {
prefix = "> "
}
out = append(out, prefix+renderInlineNodeBBMarkdown(part))
}
}
}
closeNotice()
return strings.TrimSpace(strings.Join(out, "\n"))
}
func parseDokuHeading(line string) (string, int, bool) {
if !strings.HasPrefix(line, "=") || !strings.HasSuffix(line, "=") {
return "", 0, false
}
left := len(line) - len(strings.TrimLeft(line, "="))
right := len(line) - len(strings.TrimRight(line, "="))
if left == 0 || left != right {
return "", 0, false
}
title := strings.TrimSpace(line[left : len(line)-right])
if title == "" {
return "", 0, false
}
level := 7 - left
if level < 1 {
level = 1
}
if level > 6 {
level = 6
}
return title, level, true
}
func renderInlineNodeBBMarkdown(text string) string {
return renderWikiLinks(text)
}
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")
}