package topdata import ( "crypto/sha256" "embed" "encoding/json" "fmt" "html" "io/fs" "os" "path/filepath" "slices" "strconv" "strings" "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" "gopkg.in/yaml.v3" ) const ( wikiStatusNew = "new" wikiStatusModified = "modified" wikiStatusVanilla = "vanilla" wikiRootDirName = "wiki" legacyWikiRootDirName = ".wiki" wikiPagesDirName = "pages" wikiStateFileName = "state.json" wikiGeneratorVersion = "nodebb-tiptap-html-v2" wikiGeneratedStatus = "generated" wikiSkippedStatus = "skipped" ) var ( wikiStatusPriority = map[string]int{wikiStatusVanilla: 0, wikiStatusModified: 1, wikiStatusNew: 2} wikiStatusLabels = map[string]string{wikiStatusNew: "New", wikiStatusModified: "Modified", wikiStatusVanilla: "Vanilla"} ) //go:embed wiki_templates/*.txt var wikiTemplateFS embed.FS type wikiStateDocument struct { Version string `json:"version"` Digest string `json:"digest"` Pages int `json:"pages"` } type wikiPageIndex struct { Version string `json:"version"` Pages []wikiPageIndexEntry `json:"pages"` } type wikiPageIndexEntry struct { PageID string `json:"page_id"` Namespace string `json:"namespace"` SourceDataset string `json:"source_dataset"` SourceKey string `json:"source_key"` Title string `json:"title"` PublicPath string `json:"public_path"` Hash string `json:"hash"` OutputPath string `json:"output_path"` EditPolicy string `json:"edit_policy"` StalePolicy string `json:"stale_policy"` } type wikiManualSectionsDocument struct { ManualSections []wikiManualSection `json:"manual_sections" yaml:"manual_sections"` } type wikiManualSection struct { ID string `json:"id" yaml:"id"` Alias string `json:"alias" yaml:"alias"` Heading string `json:"heading" yaml:"heading"` Placement string `json:"placement" yaml:"placement"` InitialHTML string `json:"initial_html" yaml:"initial_html"` } type wikiResult struct { OutputDir string PageCount int Status string Digest string } type wikiTable struct { Key string Output string OutputStem string Rows []map[string]any } type wikiContext struct { dialog map[string]string rowsByKey map[string]map[string]any featRows map[string]map[string]any featIDToKey map[int]string skillRows map[string]map[string]any skillIDToKey map[int]string spellRows map[string]map[string]any baseitemRows map[string]map[string]any classRows map[string]map[string]any classIDToKey map[int]string raceRows map[string]map[string]any raceIDToKey map[int]string masterfeatRows map[string]map[string]any classFeatTables map[string]wikiTable classSkillTables map[string]wikiTable classBonusTables map[string]wikiTable classSaveTables map[string]wikiTable raceFeatTables map[string]wikiTable statuses map[string]map[string]string implementedFeats map[string]struct{} manualSections []wikiManualSection templateDir string wikiTablesPath string wikiTableDefinitions map[string]wikiTableDefinition wikiDataPath string wikiDataProviders map[string]wikiDataProviderDefinition visibilityPolicy *wikiVisibilityDefinitions visibleWikiKeys map[string]bool namespaceTitles map[string]string } func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) { if progress == nil { progress = func(string) {} } rootDir := wikiOutputRootDir(p) outputDir := wikiOutputPagesDir(p) statePath := wikiOutputStatePath(p) digest, err := computeWikiSourceDigest(p.TopDataSourceDir()) if err != nil { return wikiResult{}, err } state, _ := loadWikiState(statePath) if !force && digest != "" && state.Digest == digest && wikiCachedPagesAreCurrent(outputDir, state) { return wikiResult{ OutputDir: outputDir, PageCount: state.Pages, Status: wikiSkippedStatus, Digest: digest, }, nil } progress("Building native wiki pages...") ctx, err := loadWikiContext(filepath.Join(p.TopDataSourceDir(), "data"), p.TopDataSourceDir()) if err != nil { return wikiResult{}, err } manualSections := loadWikiManualSections(p) ctx.manualSections = manualSections ctx.templateDir = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TemplatesDir), "pages") ctx.wikiTablesPath = filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.TablesFile)) tableDefinitions, err := loadWikiTableDefinitions(ctx.wikiTablesPath) if err != nil { return wikiResult{}, err } ctx.wikiTableDefinitions = tableDefinitions ctx.wikiDataPath = filepath.Join(p.TopDataWikiSourceDir(), "data.yaml") dataProviders, err := loadWikiDataProviders(ctx.wikiDataPath) if err != nil { return wikiResult{}, err } ctx.wikiDataProviders = dataProviders visibilityPath := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.VisibilityFile)) if err := ctx.loadWikiVisibility(visibilityPath); err != nil { return wikiResult{}, err } pageSlugOverrides, err := loadWikiPagePathDeclarations(filepath.Join(p.TopDataWikiSourceDir(), "wiki.yaml")) if err != nil { return wikiResult{}, err } _ = pageSlugOverrides namespaceDeclarations, err := loadWikiNamespaceDeclarations(p) if err != nil { return wikiResult{}, err } ctx.namespaceTitles = wikiNamespaceTitles(namespaceDeclarations) 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} 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 } 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 err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, p.EffectiveConfig().TopData.Wiki.StatusListingScope, manualSections); err != nil { return wikiResult{}, err } if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil { return wikiResult{}, err } if err := saveWikiPageIndex(filepath.Join(rootDir, "page-index.json"), pageIndex); err != nil { return wikiResult{}, err } if err := os.MkdirAll(rootDir, 0o755); err != nil { return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err) } pageCount := len(pageIndex.Pages) state = wikiStateDocument{Version: wikiGeneratorVersion, Digest: digest, Pages: pageCount} if err := saveWikiState(statePath, state); err != nil { return wikiResult{}, err } return wikiResult{ OutputDir: outputDir, PageCount: pageCount, Status: wikiGeneratedStatus, Digest: digest, }, nil } func wikiCachedPagesAreCurrent(outputDir string, state wikiStateDocument) bool { if state.Pages <= 0 { return false } count := 0 err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".html") { count++ } return nil }) return err == nil && count == state.Pages } func loadWikiManualSections(p *project.Project) []wikiManualSection { defaults := defaultWikiManualSections() cfg := p.EffectiveConfig().TopData.Wiki path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml") raw, err := os.ReadFile(path) if err != nil { return defaults } var doc wikiManualSectionsDocument if err := yaml.Unmarshal(raw, &doc); err != nil || len(doc.ManualSections) == 0 { return defaults } out := []wikiManualSection{} for _, section := range doc.ManualSections { section.ID = strings.TrimSpace(section.ID) if section.ID == "" { continue } section.Alias = strings.TrimSpace(section.Alias) if section.Alias == "" { switch section.ID { case "user_top": section.Alias = "UserTopSection" case "user_bottom": section.Alias = "UserBottomSection" } } if strings.TrimSpace(section.InitialHTML) == "" { heading := strings.TrimSpace(section.Heading) if heading == "" { heading = section.ID } section.InitialHTML = "

" + html.EscapeString(heading) + "

" } 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 != "" { if previous, ok := publicPaths[entry.PublicPath]; ok { return fmt.Errorf("duplicate wiki page-index public_path %q for %s and %s", entry.PublicPath, previous, entry.PageID) } publicPaths[entry.PublicPath] = entry.PageID } pageIDs[entry.PageID] = entry.OutputPath outputPaths[entry.OutputPath] = entry.PageID } return nil } func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *wikiPageIndex) error { seen := map[string]struct{}{} for _, entry := range index.Pages { seen[entry.PageID] = struct{}{} } 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 } pageID := strings.TrimSuffix(filepath.ToSlash(relToPages), filepath.Ext(relToPages)) pageID = strings.ReplaceAll(pageID, "/", ":") if _, ok := seen[pageID]; ok { return nil } raw, err := os.ReadFile(path) if err != nil { return err } namespace := strings.SplitN(pageID, ":", 2)[0] relToRoot, err := filepath.Rel(rootDir, path) if err != nil { relToRoot = filepath.Base(path) } index.Pages = append(index.Pages, wikiPageIndexEntry{ PageID: pageID, Namespace: namespace, SourceDataset: categoryFromWikiNamespace(namespace), SourceKey: pageID, Title: extractHTMLTitle(string(raw), pageID), PublicPath: (&wikiContext{}).publicWikiPath(namespace, extractHTMLTitle(string(raw), pageID)), Hash: computeManagedHash(string(raw)), OutputPath: filepath.ToSlash(relToRoot), EditPolicy: wikiEditPolicy(namespace), StalePolicy: stalePolicy, }) seen[pageID] = struct{}{} return nil }) } func loadWikiState(path string) (wikiStateDocument, error) { raw, err := os.ReadFile(path) if err != nil { return wikiStateDocument{}, err } var doc wikiStateDocument if err := json.Unmarshal(raw, &doc); err != nil { return wikiStateDocument{}, fmt.Errorf("parse wiki state: %w", err) } return doc, nil } func saveWikiState(path string, state wikiStateDocument) error { raw, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("marshal wiki state: %w", err) } raw = append(raw, '\n') if err := os.WriteFile(path, raw, 0o644); err != nil { return fmt.Errorf("write wiki state: %w", err) } return nil } func computeWikiSourceDigest(sourceDir string) (string, error) { hasher := sha256.New() _, _ = hasher.Write([]byte(wikiGeneratorVersion)) files := []string{} relevantSourceDirs := []string{ filepath.Join(sourceDir, "data", "feat"), filepath.Join(sourceDir, "data", "skills"), filepath.Join(sourceDir, "data", "spells"), filepath.Join(sourceDir, "data", "baseitems"), filepath.Join(sourceDir, "data", "classes"), filepath.Join(sourceDir, "data", "racialtypes"), filepath.Join(sourceDir, "wiki"), } for _, dir := range relevantSourceDirs { _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } switch strings.ToLower(filepath.Ext(path)) { case ".json", ".yaml", ".yml", ".html": files = append(files, path) } return nil }) } if len(files) == 0 { return "", nil } slices.Sort(files) for _, path := range files { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { continue } return "", err } rel, err := filepath.Rel(sourceDir, path) if err == nil && !strings.HasPrefix(rel, "..") { rel = filepath.ToSlash(rel) } else { rel = filepath.Base(path) } _, _ = hasher.Write([]byte(rel)) raw, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read wiki digest input %s: %w", path, err) } _, _ = hasher.Write(raw) } return fmt.Sprintf("%x", hasher.Sum(nil)), nil } func newestRelevantWikiSource(sourceDir string) (time.Time, string, error) { newest := time.Time{} newestPath := "" relevantSourceDirs := []string{ filepath.Join(sourceDir, "data", "feat"), filepath.Join(sourceDir, "data", "skills"), filepath.Join(sourceDir, "data", "spells"), filepath.Join(sourceDir, "data", "baseitems"), filepath.Join(sourceDir, "data", "classes"), filepath.Join(sourceDir, "data", "racialtypes"), } for _, dir := range relevantSourceDirs { err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { if os.IsNotExist(err) { return nil } return err } if d.IsDir() { return nil } if !strings.EqualFold(filepath.Ext(path), ".json") { return nil } info, err := d.Info() if err != nil { return err } if newest.IsZero() || info.ModTime().After(newest) { newest = info.ModTime() newestPath = path } return nil }) if err != nil { return time.Time{}, "", err } } return newest, newestPath, nil } func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) { dialogData, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json")) if err != nil { return nil, err } dialog := map[string]string{} if dialogData != nil { for key, entry := range dialogData.Entries { dialog[key] = entry.Text } } 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 } classSaveTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "savthr")) if err != nil { return nil, err } ctx := &wikiContext{ dialog: dialog, rowsByKey: map[string]map[string]any{}, statuses: map[string]map[string]string{}, raceFeatTables: raceFeatTables, classFeatTables: classFeatTables, classSkillTables: classSkillTables, classBonusTables: classBonusTables, classSaveTables: classSaveTables, } if ok { ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows) } else { ctx.featRows, ctx.featIDToKey = map[string]map[string]any{}, map[int]string{} } if skillOK { ctx.skillRows, ctx.skillIDToKey = rowsByKeyAndID(skillDataset.Rows) } else { ctx.skillRows, ctx.skillIDToKey = map[string]map[string]any{}, map[int]string{} } if spellOK { ctx.spellRows, _ = rowsByKeyAndID(spellDataset.Rows) } else { ctx.spellRows = map[string]map[string]any{} } if baseitemOK { ctx.baseitemRows, _ = rowsByKeyAndID(baseitemDataset.Rows) } else { ctx.baseitemRows = map[string]map[string]any{} } if classOK { ctx.classRows, ctx.classIDToKey = rowsByKeyAndID(classDataset.Rows) } else { ctx.classRows, ctx.classIDToKey = map[string]map[string]any{}, map[int]string{} } if masterfeatOK { ctx.masterfeatRows, _ = rowsByKeyAndID(masterfeatDataset.Rows) } else { ctx.masterfeatRows = map[string]map[string]any{} } ctx.raceRows = raceRows ctx.raceIDToKey = raceIDToKey for _, group := range []map[string]map[string]any{ctx.featRows, ctx.skillRows, ctx.spellRows, ctx.baseitemRows, ctx.classRows, ctx.raceRows, ctx.masterfeatRows} { for key, row := range group { ctx.rowsByKey[key] = row } } if ok { ctx.statuses["feat"] = collectDatasetStatuses(filepath.Join(dataDir, "feat"), "feat") } if skillOK { ctx.statuses["skills"] = collectDatasetStatuses(filepath.Join(dataDir, "skills"), "skills") } if spellOK { ctx.statuses["spells"] = collectDatasetStatuses(filepath.Join(dataDir, "spells"), "spells") } if baseitemOK { ctx.statuses["baseitems"] = collectDatasetStatuses(filepath.Join(dataDir, "baseitems"), "baseitems") } if classOK { ctx.statuses["classes"] = collectDatasetStatuses(filepath.Join(dataDir, "classes", "core"), "classes/core") } ctx.statuses["racialtypes"] = 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 if table.Key != "" { tables[table.Key] = table } return nil }) if err != nil { return nil, err } return tables, nil } func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error { if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil { return err } if err := generateCategoryPages("feat", ctx.featRows, ctx, writePage); err != nil { return err } if err := generateCategoryPages("skills", ctx.skillRows, ctx, writePage); err != nil { return err } if err := generateCategoryPages("spells", ctx.spellRows, ctx, writePage); err != nil { return err } if err := generateCategoryPages("baseitems", ctx.baseitemRows, ctx, writePage); err != nil { return err } if err := generateCategoryPages("racialtypes", ctx.raceRows, ctx, writePage); err != nil { return err } _ = outputDir return nil } func generateCategoryPages(category string, rows map[string]map[string]any, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error { keys := sortedKeys(rows) for _, key := range keys { row := rows[key] if !ctx.shouldGeneratePage(category, key, row) { continue } title := ctx.resolveRowName(category, row) if title == "" { continue } content, err := ctx.renderPage(category, key, row, title) if err != nil { return err } if err := writePage(wikiPageIDForKey(key), content, title, ctx.pageStatus(category, key, row)); err != nil { return err } } return nil } func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]any) bool { if row == nil { return false } if ctx.visibilityPolicy != nil { return ctx.canReferenceWikiKey(key) } override := ctx.wikiGenerateOverride(row) if override == "0" { return false } name := ctx.resolveRowName(category, row) description := ctx.resolveRowDescription(category, row) switch category { case "classes": if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { return false } return override == "1" || stringValue(row, "PlayerClass") == "1" case "feat": if name == "" || description == "" || strings.EqualFold(stringValue(row, "FEAT"), nullValue) { return false } if override == "1" { return true } if stringValue(row, "ALLCLASSESCANUSE") == "1" { return true } _, ok := ctx.implementedFeats[key] return ok case "skills": if name == "" || description == "" { return false } return override == "1" || stringValue(row, "HideFromLevelUp") != "1" case "racialtypes": if name == "" || description == "" { return false } return override == "1" || stringValue(row, "PlayerRace") == "1" case "spells": if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { return false } return name != "" && description != "" default: return name != "" && description != "" } } func (ctx *wikiContext) wikiRowCanRender(category string, row map[string]any) bool { if row == nil { return false } name := ctx.resolveRowName(category, row) description := ctx.resolveRowDescription(category, row) switch category { case "classes", "spells": if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) { return false } } return name != "" && description != "" } func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) { manualSections := ctx.manualSections if len(manualSections) == 0 { manualSections = defaultWikiManualSections() } page := wikiTemplatePage{ PageID: wikiPageIDForKey(key), Category: category, Key: key, Title: title, Status: ctx.pageStatus(category, key, row), Row: row, ManualSections: manualSections, } return ctx.renderWikiPageTemplate(category, page) } func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string { lines := []string{} switch category { case "feat": lines = append(lines, ctx.renderFeatFacts(row)...) case "skills": lines = append(lines, formatFact("Key Ability", stringValue(row, "KeyAbility"))) lines = append(lines, formatFact("Untrained", yesNoValue(stringValue(row, "Untrained")))) lines = append(lines, formatFact("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty")))) case "spells": lines = append(lines, formatFact("Constant", stringValue(row, "Constant"))) case "baseitems": lines = append(lines, formatFact("Item Class", stringValue(row, "ItemClass"))) lines = append(lines, formatFact("Weapon Type", stringValue(row, "WeaponType"))) lines = append(lines, formatFact("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row)))) lines = append(lines, formatFact("Base Item Stats", 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 == "" || !ctx.canReferenceWikiKey(key) { return "" } name := nameResolver(key) if name == "" { name = key } if target := ctx.publicWikiTargetForKey(key, name); target != "" { return "[[" + target + "|" + name + "]]" } return name } func (ctx *wikiContext) renderReferenceList(values []any) string { parts := []string{} for _, value := range values { if rendered := ctx.renderReference(value, ctx.featIDToKey, ctx.resolveFeatName); rendered != "" { parts = append(parts, rendered) } } return strings.Join(parts, ", ") } func (ctx *wikiContext) resolveRowName(category string, row map[string]any) string { spec := specForDataset(category) nameFields := spec.NameFields if category == "classes" { nameFields = []string{"Name", "Plural", "Lower", "CLASS", "Label", "Short"} } for _, field := range nameFields { if value, ok := lookupField(row, field); ok { if text := ctx.resolveTextValue(value, nil); text != "" { return text } } } return "" } func (ctx *wikiContext) resolveRowDescription(category string, row map[string]any) string { spec := specForDataset(category) for _, field := range spec.DescriptionFields { if value, ok := lookupField(row, field); ok { if text := ctx.resolveTextValue(value, nil); text != "" { return text } } } return "" } func (ctx *wikiContext) resolveTextValue(value any, stack map[string]struct{}) string { switch typed := value.(type) { case nil: return "" case string: trimmed := strings.TrimSpace(typed) if trimmed == "" || trimmed == nullValue { return "" } if entry, ok := ctx.dialog[trimmed]; ok { return entry } return trimmed case int: if entry, ok := ctx.dialog[strconv.Itoa(typed)]; ok { return entry } return strconv.Itoa(typed) case float64: text := strconv.Itoa(int(typed)) if entry, ok := ctx.dialog[text]; ok { return entry } return text case map[string]any: payload, ok, err := parseTLKPayload(typed, true) if err == nil && ok { switch { case payload.Text != "": return payload.Text case payload.Ref != "": if stack == nil { stack = map[string]struct{}{} } token := payload.Ref + "." + payload.RefField if _, seen := stack[token]; seen { return "" } stack[token] = struct{}{} defer delete(stack, token) target := ctx.rowsByKey[payload.Ref] if target == nil { return "" } field, ok := lookupField(target, payload.RefField) if !ok { return "" } return ctx.resolveTextValue(field, stack) case payload.Key != "": if entry, ok := ctx.dialog[payload.Key]; ok { return entry } return "" } } } return "" } func (ctx *wikiContext) resolveFeatName(key string) string { if row := ctx.featRows[key]; row != nil { return ctx.resolveRowName("feat", row) } if row := ctx.masterfeatRows[key]; row != nil { return ctx.resolveRowName("masterfeats", row) } return "" } func (ctx *wikiContext) resolveSkillName(key string) string { if row := ctx.skillRows[key]; row != nil { return ctx.resolveRowName("skills", row) } return "" } func (ctx *wikiContext) resolveClassName(key string) string { if row := ctx.classRows[key]; row != nil { return ctx.resolveRowName("classes", row) } return "" } func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string { if meta, err := parseExistingMetadata(row); err == nil && meta != nil { if wiki, ok := meta["wiki"].(map[string]any); ok { if value, ok := wiki["generate"]; ok { return normalizeWikiScalar(value) } } } if value, ok := lookupField(row, "WIKIGENERATE"); ok { return normalizeWikiScalar(value) } return "" } func (ctx *wikiContext) wikiReqFeats(row map[string]any) []any { if meta, err := parseExistingMetadata(row); err == nil && meta != nil { if wiki, ok := meta["wiki"].(map[string]any); ok { if value, ok := wiki["reqfeats"]; ok { if typed, ok := value.([]any); ok { return typed } } } } return nil } func (ctx *wikiContext) pageStatus(category, key string, row map[string]any) string { if meta, err := parseExistingMetadata(row); err == nil && meta != nil { if wiki, ok := meta["wiki"].(map[string]any); ok { if value, ok := wiki["status"]; ok { if normalized := normalizeWikiScalar(value); normalized != "" { return normalized } } } } if categoryStatuses, ok := ctx.statuses[category]; ok { if status, ok := categoryStatuses[key]; ok && status != "" { return status } } return wikiStatusVanilla } func (ctx *wikiContext) collectImplementedFeatKeys() map[string]struct{} { implemented := map[string]struct{}{} masterfeatGroups := map[string][]string{} for key, row := range ctx.featRows { if group := masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows); group != "" { masterfeatGroups[group] = append(masterfeatGroups[group], key) } } classCounts := func(row map[string]any) bool { override := ctx.wikiGenerateOverride(row) if override == "1" { return true } if override == "0" { return false } return stringValue(row, "PlayerClass") == "1" } for _, row := range ctx.classRows { if !classCounts(row) { continue } for _, tableMap := range []map[string]wikiTable{ctx.classFeatTables, ctx.classBonusTables} { if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), tableMap); table != nil { ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) } if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), tableMap); table != nil { ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) } } } for _, row := range ctx.raceRows { override := ctx.wikiGenerateOverride(row) if override == "0" || (override != "1" && stringValue(row, "PlayerRace") != "1") { continue } if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables); table != nil { ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows) } } return implemented } func (ctx *wikiContext) collectImplementedFromTable(target map[string]struct{}, masterfeatGroups map[string][]string, rows []map[string]any) { for _, row := range rows { key := resolveReferenceKey(fieldValue(row, "FeatIndex"), ctx.featIDToKey) if key == "" { if key = resolveReferenceKey(fieldValue(row, "FeatIndex"), nil); key == "" { continue } } if strings.HasPrefix(key, "masterfeats:") { for _, featKey := range masterfeatGroups[key] { target[featKey] = struct{}{} } continue } target[key] = struct{}{} } } func (ctx *wikiContext) tableForValue(value any, tables map[string]wikiTable) *wikiTable { switch typed := value.(type) { case string: if table, ok := tables[typed]; ok { return &table } case map[string]any: if tableKey, ok := typed["table"].(string); ok { tableKey = outputStem(tableKey) if table, ok := tables[tableKey]; ok { return &table } } } return nil } func collectDatasetStatuses(root, datasetName string) map[string]string { statuses := map[string]string{} basePath := filepath.Join(root, "base.json") baseObj, err := loadJSONObject(basePath) if err == nil { if rows, ok := baseObj["rows"].([]any); ok { for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } if key := stringField(row, "key"); key != "" { statuses[key] = wikiStatusVanilla } } } } mergeExplicitWikiStatuses(basePath, statuses) for _, dirName := range []string{"modules", "generated"} { dir := filepath.Join(root, dirName) paths, err := collectModulePaths(dir) if err != nil { continue } for _, path := range paths { obj, err := loadJSONObject(path) if err != nil { continue } if entries, ok := obj["entries"].(map[string]any); ok { for key, raw := range entries { row, ok := raw.(map[string]any) if !ok { continue } if _, exists := statuses[key]; exists { statuses[key] = wikiStatusModified } else { statuses[key] = wikiStatusNew } mergeRowWikiStatus(key, row, statuses) } } if overrides, ok := obj["overrides"].([]any); ok { for _, raw := range overrides { row, ok := raw.(map[string]any) if !ok { continue } key := stringField(row, "key") if key == "" { continue } if _, exists := statuses[key]; exists { statuses[key] = wikiStatusModified } else { statuses[key] = wikiStatusNew } mergeRowWikiStatus(key, row, statuses) } } } } _ = datasetName return statuses } func collectRaceStatuses(root string) map[string]string { statuses := map[string]string{} baseObj, err := loadJSONObject(filepath.Join(root, "base.json")) if err == nil { if rows, ok := baseObj["rows"].([]any); ok { for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } if key := stringField(row, "key"); key != "" { statuses[key] = wikiStatusVanilla } } } } paths, err := collectModulePaths(filepath.Join(root, "races")) if err != nil { return statuses } for _, path := range paths { row, err := loadJSONObject(path) if err != nil { continue } key := stringField(row, "key") if key == "" { continue } if _, exists := statuses[key]; exists { statuses[key] = wikiStatusModified } else { statuses[key] = wikiStatusNew } mergeRowWikiStatus(key, row, statuses) if core, ok := row["core"].(map[string]any); ok { mergeRowWikiStatus(key, core, statuses) } } return statuses } func mergeExplicitWikiStatuses(path string, statuses map[string]string) { obj, err := loadJSONObject(path) if err != nil { return } if rows, ok := obj["rows"].([]any); ok { for _, raw := range rows { row, ok := raw.(map[string]any) if !ok { continue } if key := stringField(row, "key"); key != "" { mergeRowWikiStatus(key, row, statuses) } } } } func mergeRowWikiStatus(key string, row map[string]any, statuses map[string]string) { if meta, err := parseExistingMetadata(row); err == nil && meta != nil { if wiki, ok := meta["wiki"].(map[string]any); ok { if value, ok := wiki["status"]; ok { if normalized := normalizeWikiScalar(value); normalized != "" { statuses[key] = normalized } } } } } func buildWikiStatusBlock(status, category string) string { message := "This " + wikiStatusNoun(category) + " is unchanged from vanilla." switch status { case wikiStatusNew: message = "This is a new " + wikiStatusNoun(category) + "!" case wikiStatusModified: message = "This " + wikiStatusNoun(category) + " has been altered from vanilla." } return "

" + html.EscapeString(message) + "

" } 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) error { summaries := buildWikiNamespaceSummaries(pageStatuses, namespaces) if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.html"), renderWikiStatusReportPage(summaries, namespaces), 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), 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(namespaceTitle(namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles), 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), 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 renderWikiNamespaceFragment(namespace string, summary map[string]any) string { return renderStatusTable(namespaceTitle(namespace), summary, "") } func renderWikiStatusReportPage(summaries map[string]map[string]any, namespaces []string) string { lines := []string{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""} for _, namespace := range namespaces { summary := summaries[namespace] lines = append(lines, renderStatusTable(namespaceTitle(namespace), summary, fmt.Sprintf("[[%s:index|%s]]", namespace, namespaceTitle(namespace)))) } 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+"]]") } } return strings.Join(lines, "\n") } func renderStatusTable(title string, summary map[string]any, namespaceLink string) string { rows := []string{ "===== " + title + " =====", "", 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)), } if namespaceLink != "" { rows = append(rows, fmt.Sprintf("**Namespace:** %s", namespaceLink)) } 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 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 { namespaceTitle := namespaceTitle(namespace) if ctx != nil && ctx.namespaceTitles != nil { if declared := strings.TrimSpace(ctx.namespaceTitles[namespace]); declared != "" { namespaceTitle = declared } } segments := []string{namespaceTitle} for _, part := range strings.Split(title, " :: ") { segments = append(segments, part) } return canonicalWikiPath(segments...) } func wikiSlugifyTitle(value string) string { return slugifyWikiText(value, "topic") } 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 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 renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string { body := sourceText if !strings.Contains(sourceText, "", "", strings.TrimSpace(body), "", } 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 dokuWikiToNodeBBHTML(text string) string { text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n") if text == "" { return "" } lines := strings.Split(text, "\n") var out []string var paragraph []string var list []string var tableRows []string flushParagraph := func() { if len(paragraph) == 0 { return } out = append(out, "

"+renderInlineHTML(strings.Join(paragraph, " "))+"

") paragraph = nil } flushList := func() { if len(list) == 0 { return } out = append(out, "") list = nil } flushTable := func() { if len(tableRows) == 0 { return } out = append(out, "") out = append(out, tableRows...) out = append(out, "
") tableRows = nil } flushBlocks := func() { flushParagraph() flushList() flushTable() } for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" { flushBlocks() continue } if strings.HasPrefix(trimmed, "

%s", level, renderInlineHTML(heading), level)) continue } if strings.HasPrefix(trimmed, " * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") { flushParagraph() flushTable() item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, " * "), "* "), "- ")) list = append(list, item) continue } if label, value, ok := parseFactLine(trimmed); ok { flushParagraph() flushList() tableRows = append(tableRows, ""+html.EscapeString(label)+""+renderInlineHTML(value)+"") continue } flushList() flushTable() for _, part := range strings.Split(trimmed, `\\`) { part = strings.TrimSpace(part) if part != "" { paragraph = append(paragraph, part) } } } flushBlocks() return strings.TrimSpace(strings.Join(out, "\n")) } func parseFactLine(line string) (string, string, bool) { if !strings.HasPrefix(line, "**") { return "", "", false } rest := strings.TrimPrefix(line, "**") label, value, ok := strings.Cut(rest, ":**") if !ok { return "", "", false } return strings.TrimSpace(label), strings.Trim(strings.TrimSpace(value), `\`), true } 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 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, "" { 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") }