package topdata import ( "fmt" "html" "os" "slices" "strconv" "strings" "unicode" "gopkg.in/yaml.v3" ) type wikiTableDefinitionsDocument struct { Tables map[string]wikiTableDefinition `json:"tables" yaml:"tables"` } type wikiTableDefinition struct { Source string `json:"source" yaml:"source"` Class string `json:"class" yaml:"class"` When string `json:"when" yaml:"when"` Rows wikiTableRowsConfig `json:"rows" yaml:"rows"` ClassFeats wikiDataProviderClassFeatConfig `json:"class_feats" yaml:"class_feats"` Attacks wikiDataProviderAttacksConfig `json:"attacks" yaml:"attacks"` Columns []wikiTableColumn `json:"columns" yaml:"columns"` } type wikiTableRowsConfig struct { Levels string `json:"levels" yaml:"levels"` When string `json:"when" yaml:"when"` } type wikiTableColumn struct { Key string `json:"key" yaml:"key"` Label string `json:"label" yaml:"label"` Value string `json:"value" yaml:"value"` Empty string `json:"empty" yaml:"empty"` Class string `json:"class" yaml:"class"` Style string `json:"style" yaml:"style"` HeaderClass string `json:"header_class" yaml:"header_class"` HeaderStyle string `json:"header_style" yaml:"header_style"` AllowWikiMarkup bool `json:"allow_wiki_markup" yaml:"allow_wiki_markup"` When string `json:"when" yaml:"when"` Children []wikiTableColumn `json:"children" yaml:"children"` } type wikiTableRenderContext struct { Page wikiTemplatePage TableName string Path string } func loadWikiTableDefinitions(path string) (map[string]wikiTableDefinition, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return map[string]wikiTableDefinition{}, nil } return nil, fmt.Errorf("read wiki table definitions %s: %w", path, err) } return parseWikiTableDefinitions(raw, path) } func parseWikiTableDefinitions(raw []byte, path string) (map[string]wikiTableDefinition, error) { var doc wikiTableDefinitionsDocument if err := yaml.Unmarshal(raw, &doc); err != nil { return nil, fmt.Errorf("parse wiki table definitions %s: %w", path, err) } if len(doc.Tables) == 0 { return map[string]wikiTableDefinition{}, nil } for name, def := range doc.Tables { if strings.TrimSpace(def.Source) == "" { return nil, fmt.Errorf("wiki table definitions %s: table %s missing source", path, name) } if strings.TrimSpace(def.Source) != "class_progression" { if len(def.ClassFeats.Fields) > 0 { return nil, fmt.Errorf("wiki table definitions %s: table %s class feat projections require source class_progression", path, name) } if strings.TrimSpace(def.Attacks.Mode) != "" { return nil, fmt.Errorf("wiki table definitions %s: table %s attacks config requires source class_progression", path, name) } } if strings.TrimSpace(def.Source) == "class_progression" { for field, projection := range def.ClassFeats.Fields { if strings.TrimSpace(field) == "" { return nil, fmt.Errorf("wiki table definitions %s: table %s has class feat projection without field name", path, name) } if strings.TrimSpace(projection.When) == "" { return nil, fmt.Errorf("wiki table definitions %s: table %s class feat projection %s missing when", path, name, field) } } if err := validateWikiDataProviderAttacks(path, name, def.Attacks); err != nil { return nil, err } } if len(def.Columns) == 0 { return nil, fmt.Errorf("wiki table definitions %s: table %s missing columns", path, name) } for _, col := range def.Columns { if err := validateWikiTableColumn(path, name, col); err != nil { return nil, err } } } return doc.Tables, nil } func validateWikiTableColumn(path, tableName string, col wikiTableColumn) error { if strings.TrimSpace(col.Key) == "" { return fmt.Errorf("wiki table definitions %s: table %s has column without key", path, tableName) } if strings.TrimSpace(col.Value) != "" && len(col.Children) > 0 { return fmt.Errorf("wiki table definitions %s: table %s column %s cannot have both value and children", path, tableName, col.Key) } for _, child := range col.Children { if err := validateWikiTableColumn(path, tableName, child); err != nil { return err } } return nil } func (ctx *wikiContext) renderConfiguredWikiTable(templatePath, tableName string, page wikiTemplatePage) (string, error) { def, ok := ctx.wikiTableDefinitions[tableName] if !ok { return "", fmt.Errorf("render wiki template %s for %s: missing table definition %q in %s", templatePath, page.PageID, tableName, ctx.wikiTablesPath) } renderCtx := wikiTableRenderContext{Page: page, TableName: tableName, Path: ctx.wikiTablesPath} if strings.TrimSpace(def.When) != "" { value, err := ctx.evalWikiTableTemplate(def.When, page.Row, renderCtx) if err != nil { return "", err } if !truthyWikiTableValue(value) { return "", nil } } rows, err := ctx.wikiTableSourceRows(def, page) if err != nil { return "", fmt.Errorf("render wiki table %s for %s: %w", tableName, page.PageID, err) } if strings.TrimSpace(def.Rows.When) != "" { filtered := rows[:0] for _, row := range rows { value, err := ctx.evalWikiTableTemplate(def.Rows.When, row, renderCtx) if err != nil { return "", fmt.Errorf("render wiki table %s for %s row condition %q: %w", tableName, page.PageID, def.Rows.When, err) } if truthyWikiTableValue(value) { filtered = append(filtered, row) } } rows = filtered } if len(rows) == 0 { return "", nil } columns := visibleWikiTableColumns(def.Columns, aggregateWikiTableVisibilityRow(rows), ctx, renderCtx) if len(columns) == 0 { return "", nil } headerRows, leaves := buildWikiTableHeaders(columns) if len(leaves) == 0 { return "", nil } var out strings.Builder class := strings.TrimSpace(def.Class) if class != "" { out.WriteString(``) } else { out.WriteString("
") } out.WriteString("\n \n") for _, headerRow := range headerRows { out.WriteString(" ") for _, cell := range headerRow { out.WriteString(cell) } out.WriteString("\n") } out.WriteString(" \n \n") for _, row := range rows { out.WriteString(" ") for _, col := range leaves { cell, err := ctx.renderWikiTableCell(col, row, renderCtx) if err != nil { return "", err } out.WriteString(cell) } out.WriteString("\n") } out.WriteString(" \n
") return out.String(), nil } func visibleWikiTableColumns(columns []wikiTableColumn, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) []wikiTableColumn { out := []wikiTableColumn{} for _, col := range columns { if strings.TrimSpace(col.When) != "" { value, err := ctx.evalWikiTableTemplate(col.When, row, renderCtx) if err != nil || !truthyWikiTableValue(value) { continue } } if len(col.Children) > 0 { col.Children = visibleWikiTableColumns(col.Children, row, ctx, renderCtx) if len(col.Children) == 0 { continue } } out = append(out, col) } return out } func aggregateWikiTableVisibilityRow(rows []map[string]any) map[string]any { out := map[string]any{} for _, row := range rows { for key, value := range row { if _, exists := out[key]; exists { continue } if truthyWikiTableValue(value) { out[key] = value } } } return out } type wikiTableHeaderCell struct { HTML string } func buildWikiTableHeaders(columns []wikiTableColumn) ([][]string, []wikiTableColumn) { hasGroups := false for _, col := range columns { if len(col.Children) > 0 { hasGroups = true break } } first := []string{} second := []string{} leaves := []wikiTableColumn{} for _, col := range columns { label := html.EscapeString(defaultStringValue(col.Label, col.Key)) headerClass := attrClass(col.HeaderClass) headerStyle := attrStyle(col.HeaderStyle) if len(col.Children) == 0 { leaves = append(leaves, col) rowspan := "" if hasGroups { rowspan = ` rowspan="2"` } first = append(first, ``+label+``) continue } childLeaves := leafWikiTableColumns(col.Children) first = append(first, ``+label+``) for _, child := range col.Children { childLabel := html.EscapeString(defaultStringValue(child.Label, child.Key)) second = append(second, ``+childLabel+``) } leaves = append(leaves, childLeaves...) } if hasGroups { return [][]string{first, second}, leaves } return [][]string{first}, leaves } func leafWikiTableColumns(columns []wikiTableColumn) []wikiTableColumn { out := []wikiTableColumn{} for _, col := range columns { if len(col.Children) == 0 { out = append(out, col) continue } out = append(out, leafWikiTableColumns(col.Children)...) } return out } func (ctx *wikiContext) renderWikiTableCell(col wikiTableColumn, row map[string]any, renderCtx wikiTableRenderContext) (string, error) { value, err := ctx.evalWikiTableTemplate(col.Value, row, renderCtx) if err != nil { return "", fmt.Errorf("render wiki table %s for %s column %s expression %q: %w", renderCtx.TableName, renderCtx.Page.PageID, col.Key, col.Value, err) } text := stringifyWikiTableValue(value) if strings.TrimSpace(text) == "" { text = col.Empty } if !col.AllowWikiMarkup { text = html.EscapeString(text) } return `` + text + ``, nil } func attrClass(class string) string { class = strings.TrimSpace(class) if class == "" { return "" } return ` class="` + html.EscapeString(class) + `"` } func attrStyle(style string) string { style = strings.TrimSpace(style) if style == "" { return "" } return ` style="` + html.EscapeString(style) + `"` } func defaultStringValue(value, fallback string) string { if strings.TrimSpace(value) != "" { return value } return fallback } func (ctx *wikiContext) wikiTableSourceRows(def wikiTableDefinition, page wikiTemplatePage) ([]map[string]any, error) { switch strings.TrimSpace(def.Source) { case "class_progression": if page.Category != "classes" { return nil, nil } return ctx.classProgressionRows(def.Rows.Levels, def.ClassFeats.Fields, def.Attacks, page.Key, page.Row) case "class_feats": if page.Category != "classes" { return nil, nil } return ctx.classFeatRows(page.Row), nil case "spell_progression": if page.Category != "classes" { return nil, nil } if !hasSpellProgression(page.Row) { return nil, nil } return []map[string]any{}, nil case "class_summary": if page.Category != "classes" { return nil, nil } return ctx.classSummaryRows(page.Row), nil default: return nil, fmt.Errorf("unknown source %q", def.Source) } } func (ctx *wikiContext) classProgressionRows(levels string, projections map[string]wikiDataProviderProjectionField, attacks wikiDataProviderAttacksConfig, classKey string, classRow map[string]any) ([]map[string]any, error) { start, end, err := parseLevelRange(levels) if err != nil { return nil, err } saveRows := map[string]map[string]any{} if table := ctx.tableForValue(fieldValue(classRow, "SavingThrowTable"), ctx.classSaveTables); table != nil { for _, row := range table.Rows { level := stringValue(row, "Level") if level != "" { saveRows[level] = row } } } if len(projections) == 0 { projections = map[string]wikiDataProviderProjectionField{ "GrantedFeats": {When: "{{GrantedOnLevel > 0}}"}, } } classFeatFields := map[string]map[int][]string{} for field := range projections { classFeatFields[field] = map[int][]string{} } if table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables); table != nil { for _, featRow := range table.Rows { level, err := strconv.Atoi(stringValue(featRow, "GrantedOnLevel")) if err != nil || level < start || level > end { continue } name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName) if name == "" { continue } renderCtx := wikiTableRenderContext{TableName: "class_progression", Path: ctx.wikiDataPath} for field, projection := range projections { value, err := ctx.evalWikiTableTemplate(projection.When, featRow, renderCtx) if err != nil { return nil, fmt.Errorf("class progression field %s condition %q: %w", field, projection.When, err) } if truthyWikiTableValue(value) { classFeatFields[field][level] = append(classFeatFields[field][level], name) } } } } bonusByLevel := map[int]string{} if table := ctx.tableForValue(fieldValue(classRow, "BonusFeatsTable"), ctx.classBonusTables); table != nil { for _, bonusRow := range table.Rows { level, err := asInt(fieldValue(bonusRow, "id")) if err != nil { continue } bonusByLevel[level] = stringValue(bonusRow, "Bonus") } } hitDie, _ := asInt(fieldValue(classRow, "HitDie")) spellsPerDayByLevel := classSpellProgressionByLevel(ctx.tableForValue(fieldValue(classRow, "SpellGainTable"), ctx.classSpellGainTables)) spellsKnownByLevel := classSpellProgressionByLevel(ctx.tableForValue(fieldValue(classRow, "SpellKnownTable"), ctx.classSpellKnownTables)) hasPerDay := hasClassSpellProgressionTable(classRow, ctx.classSpellGainTables, "SpellGainTable") hasKnown := hasClassSpellProgressionTable(classRow, ctx.classSpellKnownTables, "SpellKnownTable") out := []map[string]any{} for level := start; level <= end; level++ { row := cloneRowMap(classRow) row["Level"] = level bab := classBABAtLevel(stringValue(classRow, "AttackBonusTable"), level) row["BABValue"] = bab row["BAB"] = formatSignedInt(bab) attackCount := classAttackCount(attacks, classKey, bab, level) if attackCount < 1 { attackCount = 1 } countField := defaultStringValue(attacks.CountField, "AttackCount") sequenceField := defaultStringValue(attacks.Field, "AttackSequence") row[countField] = attackCount row[sequenceField] = formatAttackSequence(bab, attackCount, attackStep(attacks)) if saveRow := saveRows[strconv.Itoa(level)]; saveRow != nil { for key, value := range saveRow { row[key] = value } } for field, values := range classFeatFields { row[field] = values[level] } row["BonusFeat"] = bonusByLevel[level] row["HPMin"] = level if hitDie > 0 { row["HPMax"] = level * hitDie } if !presentWikiValue(fieldValue(row, "Notes")) { if note := ctx.classProgressionNote(classRow, level); note != "" { row["Notes"] = note } } if _, ok := row["Notes"]; !ok { row["Notes"] = "" } row["HasSpellProgression"] = hasSpellProgression(classRow) row["HasSpellsPerDay"] = hasPerDay row["HasSpellsKnown"] = hasKnown populateClassSpellProgressionFields(row, "SpellsPerDay", spellsPerDayByLevel[level]) populateClassSpellProgressionFields(row, "SpellsKnown", spellsKnownByLevel[level]) out = append(out, row) } return out, nil } func (ctx *wikiContext) classProgressionNote(classRow map[string]any, level int) string { meta, err := parseExistingMetadata(classRow) if err != nil || meta == nil { return "" } wiki, _ := meta["wiki"].(map[string]any) if wiki == nil { return "" } raw := wiki["progression_notes"] if raw == nil { return "" } switch typed := raw.(type) { case map[string]any: for _, key := range []string{strconv.Itoa(level), formatLevelNoteKey(level)} { if value, ok := typed[key]; ok { return strings.TrimSpace(ctx.resolveTextValue(value, nil)) } } case []any: if level > 0 && level <= len(typed) { return strings.TrimSpace(ctx.resolveTextValue(typed[level-1], nil)) } } return "" } func formatLevelNoteKey(level int) string { return "level_" + strconv.Itoa(level) } func classSpellProgressionByLevel(table *wikiTable) map[int]map[int]string { out := map[int]map[int]string{} if table == nil { return out } for _, tableRow := range table.Rows { level, err := asInt(fieldValue(tableRow, "Level")) if err != nil || level < 1 { continue } values := map[int]string{} for key, value := range tableRow { if !strings.HasPrefix(key, "SpellLevel") { continue } spellLevel, err := strconv.Atoi(strings.TrimPrefix(key, "SpellLevel")) if err != nil || spellLevel < 0 { continue } values[spellLevel] = stringifyWikiTableValue(value) } out[level] = values } return out } func populateClassSpellProgressionFields(row map[string]any, prefix string, values map[int]string) { ordered := []string{} for _, spellLevel := range sortedIntMapKeys(values) { value := values[spellLevel] row[prefix+strconv.Itoa(spellLevel)] = value ordered = append(ordered, value) } row[prefix+"ByLevel"] = ordered } func sortedIntMapKeys(values map[int]string) []int { keys := make([]int, 0, len(values)) for key := range values { keys = append(keys, key) } slices.Sort(keys) return keys } func parseLevelRange(raw string) (int, int, error) { raw = strings.TrimSpace(raw) if raw == "" { return 1, 20, nil } if !strings.Contains(raw, "-") { level, err := strconv.Atoi(raw) if err != nil || level < 1 { return 0, 0, fmt.Errorf("bad row range %q", raw) } return level, level, nil } left, right, _ := strings.Cut(raw, "-") start, err1 := strconv.Atoi(strings.TrimSpace(left)) end, err2 := strconv.Atoi(strings.TrimSpace(right)) if err1 != nil || err2 != nil || start < 1 || end < start { return 0, 0, fmt.Errorf("bad row range %q", raw) } return start, end, nil } func classBABAtLevel(table string, level int) int { switch strings.ToLower(strings.TrimSpace(table)) { case "cls_atk_1": return level case "cls_atk_2": return (level * 3) / 4 case "cls_atk_3": return level / 2 default: return 0 } } func classAttackCount(cfg wikiDataProviderAttacksConfig, classKey string, bab, level int) int { count := baseClassAttackCount(cfg, bab) for _, override := range cfg.Overrides { if !wikiAttackOverrideMatches(override, classKey, level) { continue } if override.Attacks > 0 { count = override.Attacks continue } count += override.Add } if count < 1 { return 1 } return count } func baseClassAttackCount(cfg wikiDataProviderAttacksConfig, bab int) int { switch strings.TrimSpace(cfg.Mode) { case "fixed": if cfg.Attacks > 0 { return cfg.Attacks } case "bab_thresholds": return attackCountFromThresholds(cfg.Thresholds, bab) case "": return attackCountFromThresholds(defaultWikiAttackThresholds(), bab) } return 1 } func attackCountFromThresholds(thresholds []wikiDataProviderAttackThreshold, bab int) int { count := 1 for _, threshold := range thresholds { if threshold.Attacks > 0 && bab >= threshold.MinBAB { count = threshold.Attacks } } return count } func defaultWikiAttackThresholds() []wikiDataProviderAttackThreshold { return []wikiDataProviderAttackThreshold{ {MinBAB: 1, Attacks: 1}, {MinBAB: 6, Attacks: 2}, {MinBAB: 11, Attacks: 3}, {MinBAB: 16, Attacks: 4}, } } func wikiAttackOverrideMatches(override wikiDataProviderAttackOverrideRule, classKey string, level int) bool { foundClass := false for _, candidate := range override.Classes { if strings.TrimSpace(candidate) == classKey { foundClass = true break } } if !foundClass { return false } levels, err := parseWikiAttackLevels(override.Levels) if err != nil { return false } _, ok := levels[level] return ok } func attackStep(cfg wikiDataProviderAttacksConfig) int { if cfg.Step == 0 { return -5 } return cfg.Step } func formatAttackSequence(bab, attacks, step int) string { if attacks < 1 { attacks = 1 } values := make([]string, 0, attacks) current := bab for i := 0; i < attacks; i++ { values = append(values, formatSignedInt(current)) current += step } return strings.Join(values, "/") } func formatSignedInt(value int) string { if value > 0 { return "+" + strconv.Itoa(value) } return strconv.Itoa(value) } func (ctx *wikiContext) classFeatRows(classRow map[string]any) []map[string]any { table := ctx.tableForValue(fieldValue(classRow, "FeatsTable"), ctx.classFeatTables) if table == nil { return nil } out := []map[string]any{} for _, source := range table.Rows { row := cloneRowMap(source) key := resolveReferenceKey(fieldValue(source, "FeatIndex"), ctx.featIDToKey) if key == "" { key = resolveReferenceKey(fieldValue(source, "FeatIndex"), nil) } row["FeatKey"] = key row["FeatName"] = ctx.resolveFeatName(key) out = append(out, row) } return out } func (ctx *wikiContext) classSummaryRows(classRow map[string]any) []map[string]any { return []map[string]any{ {"Label": "Hit Die", "Value": "d" + stringValue(classRow, "HitDie")}, {"Label": "Skill Points", "Value": stringValue(classRow, "SkillPointBase")}, {"Label": "Primary Ability", "Value": stringValue(classRow, "PrimaryAbil")}, } } func isClassSpellcaster(row map[string]any) bool { return stringValue(row, "SpellCaster") == "1" } func hasSpellProgression(row map[string]any) bool { return isClassSpellcaster(row) && (hasTableReference(fieldValue(row, "SpellGainTable")) || hasTableReference(fieldValue(row, "SpellKnownTable"))) } func hasClassSpellProgressionTable(row map[string]any, tables map[string]wikiTable, field string) bool { return isClassSpellcaster(row) && ctxHasTableValue(row, tables, field) } func hasTableReference(value any) bool { switch typed := value.(type) { case string: return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != nullValue case map[string]any: tableKey, _ := typed["table"].(string) return strings.TrimSpace(tableKey) != "" default: return false } } func ctxHasTableValue(row map[string]any, tables map[string]wikiTable, field string) bool { switch value := fieldValue(row, field).(type) { case string: if _, ok := tables[value]; ok { return true } _, ok := tables[outputStem(value)] return ok case map[string]any: tableKey, _ := value["table"].(string) if tableKey == "" { return false } if _, ok := tables[tableKey]; ok { return true } _, ok := tables[outputStem(tableKey)] return ok default: return false } } func (ctx *wikiContext) classSpellbookRows(classKey string, classRow map[string]any) []map[string]any { spec, ok := ctx.classSpellbooks[classKey] if !ok || !ctx.hasClassSpellbook(classKey, classRow) { return nil } out := []map[string]any{} for _, level := range sortedIntKeys(spec.Levels) { spells := ctx.renderClassSpellbookLevel(spec.Levels[level]) if len(spells) == 0 { continue } out = append(out, map[string]any{ "SpellLevel": level, "SpellLevelLabel": spellbookLevelLabel(level), "Spells": spells, }) } return out } func (ctx *wikiContext) hasClassSpellbook(classKey string, classRow map[string]any) bool { if ctx == nil || !isClassSpellcaster(classRow) { return false } spec, ok := ctx.classSpellbooks[classKey] if !ok { return false } for _, spells := range spec.Levels { if len(spells) > 0 { return true } } return false } func (ctx *wikiContext) renderClassSpellbookLevel(spellKeys []string) []string { type renderedSpell struct { Name string Link string } rendered := []renderedSpell{} for _, spellKey := range spellKeys { name := ctx.resolveSpellName(spellKey) if name == "" { name = spellKey } link := ctx.renderReference(map[string]any{"id": spellKey}, nil, ctx.resolveSpellName) if link == "" { continue } rendered = append(rendered, renderedSpell{Name: name, Link: link}) } slices.SortFunc(rendered, func(a, b renderedSpell) int { if cmp := strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); cmp != 0 { return cmp } return strings.Compare(a.Link, b.Link) }) out := make([]string, 0, len(rendered)) for _, spell := range rendered { out = append(out, spell.Link) } return out } func spellbookLevelLabel(level int) string { if level == 0 { return "0-level spells" } return ordinalWikiTableValue(level) + "-level spells" } func (ctx *wikiContext) evalWikiTableTemplate(template string, row map[string]any, renderCtx wikiTableRenderContext) (any, error) { template = strings.TrimSpace(template) if strings.HasPrefix(template, "{{") && strings.HasSuffix(template, "}}") { expr := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(template, "{{"), "}}")) return evalWikiTableExpression(expr, row, ctx, renderCtx) } if strings.Contains(template, "{{") { return nil, fmt.Errorf("mixed literal/expression templates are not supported") } return template, nil } func evalWikiTableExpression(expr string, row map[string]any, ctx *wikiContext, renderCtx wikiTableRenderContext) (any, error) { parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx} value, err := parser.parseComparison() if err != nil { return nil, err } if parser.peek().kind != wikiExprEOF { return nil, fmt.Errorf("unexpected token %q", parser.peek().text) } return value, nil } type wikiExprTokenKind int const ( wikiExprEOF wikiExprTokenKind = iota wikiExprIdent wikiExprString wikiExprNumber wikiExprLParen wikiExprRParen wikiExprComma wikiExprPlus wikiExprEq wikiExprNE wikiExprGT wikiExprGE wikiExprLT wikiExprLE wikiExprAnd wikiExprOr ) type wikiExprToken struct { kind wikiExprTokenKind text string } func tokenizeWikiExpr(expr string) []wikiExprToken { tokens := []wikiExprToken{} for i := 0; i < len(expr); { r := rune(expr[i]) if unicode.IsSpace(r) { i++ continue } switch expr[i] { case '(': tokens = append(tokens, wikiExprToken{kind: wikiExprLParen, text: "("}) i++ case ')': tokens = append(tokens, wikiExprToken{kind: wikiExprRParen, text: ")"}) i++ case ',': tokens = append(tokens, wikiExprToken{kind: wikiExprComma, text: ","}) i++ case '+': tokens = append(tokens, wikiExprToken{kind: wikiExprPlus, text: "+"}) i++ case '>': if i+1 < len(expr) && expr[i+1] == '=' { tokens = append(tokens, wikiExprToken{kind: wikiExprGE, text: ">="}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprGT, text: ">"}) i++ } case '<': if i+1 < len(expr) && expr[i+1] == '=' { tokens = append(tokens, wikiExprToken{kind: wikiExprLE, text: "<="}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprLT, text: "<"}) i++ } case '!': if i+1 < len(expr) && expr[i+1] == '=' { tokens = append(tokens, wikiExprToken{kind: wikiExprNE, text: "!="}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } case '&': if i+1 < len(expr) && expr[i+1] == '&' { tokens = append(tokens, wikiExprToken{kind: wikiExprAnd, text: "&&"}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } case '|': if i+1 < len(expr) && expr[i+1] == '|' { tokens = append(tokens, wikiExprToken{kind: wikiExprOr, text: "||"}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } case '=': if i+1 < len(expr) && expr[i+1] == '=' { tokens = append(tokens, wikiExprToken{kind: wikiExprEq, text: "=="}) i += 2 } else { tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } case '"': j := i + 1 var b strings.Builder for ; j < len(expr); j++ { if expr[j] == '\\' && j+1 < len(expr) { j++ b.WriteByte(expr[j]) continue } if expr[j] == '"' { break } b.WriteByte(expr[j]) } if j >= len(expr) { tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } tokens = append(tokens, wikiExprToken{kind: wikiExprString, text: b.String()}) i = j + 1 default: if unicode.IsDigit(r) || expr[i] == '-' { j := i + 1 for j < len(expr) && unicode.IsDigit(rune(expr[j])) { j++ } tokens = append(tokens, wikiExprToken{kind: wikiExprNumber, text: expr[i:j]}) i = j continue } if unicode.IsLetter(r) || expr[i] == '_' { j := i + 1 for j < len(expr) { rr := rune(expr[j]) if !unicode.IsLetter(rr) && !unicode.IsDigit(rr) && expr[j] != '_' && expr[j] != '.' { break } j++ } tokens = append(tokens, wikiExprToken{kind: wikiExprIdent, text: expr[i:j]}) i = j continue } tokens = append(tokens, wikiExprToken{kind: wikiExprEOF, text: expr[i:]}) return tokens } } tokens = append(tokens, wikiExprToken{kind: wikiExprEOF}) return tokens } type wikiExprParser struct { tokens []wikiExprToken pos int row map[string]any ctx *wikiContext renderCtx wikiTableRenderContext allowMissingFields bool } func (p *wikiExprParser) peek() wikiExprToken { if p.pos >= len(p.tokens) { return wikiExprToken{kind: wikiExprEOF} } return p.tokens[p.pos] } func (p *wikiExprParser) take(kind wikiExprTokenKind) (wikiExprToken, bool) { token := p.peek() if token.kind != kind { return token, false } p.pos++ return token, true } func (p *wikiExprParser) parseComparison() (any, error) { return p.parseOr() } func (p *wikiExprParser) parseOr() (any, error) { left, err := p.parseAnd() if err != nil { return nil, err } for p.peek().kind == wikiExprOr { p.pos++ right, err := p.parseAnd() if err != nil { return nil, err } left = truthyWikiTableValue(left) || truthyWikiTableValue(right) } return left, nil } func (p *wikiExprParser) parseAnd() (any, error) { left, err := p.parseRelational() if err != nil { return nil, err } for p.peek().kind == wikiExprAnd { p.pos++ right, err := p.parseRelational() if err != nil { return nil, err } left = truthyWikiTableValue(left) && truthyWikiTableValue(right) } return left, nil } func (p *wikiExprParser) parseRelational() (any, error) { left, err := p.parseConcat() if err != nil { return nil, err } switch p.peek().kind { case wikiExprEq, wikiExprNE, wikiExprGT, wikiExprGE, wikiExprLT, wikiExprLE: op := p.peek().kind p.pos++ right, err := p.parseConcat() if err != nil { return nil, err } switch op { case wikiExprEq: return stringifyWikiTableValue(left) == stringifyWikiTableValue(right), nil case wikiExprNE: return stringifyWikiTableValue(left) != stringifyWikiTableValue(right), nil case wikiExprGT: return numericWikiTableValue(left) > numericWikiTableValue(right), nil case wikiExprGE: return numericWikiTableValue(left) >= numericWikiTableValue(right), nil case wikiExprLT: return numericWikiTableValue(left) < numericWikiTableValue(right), nil case wikiExprLE: return numericWikiTableValue(left) <= numericWikiTableValue(right), nil } return false, nil default: return left, nil } } func (p *wikiExprParser) parseConcat() (any, error) { left, err := p.parsePrimary() if err != nil { return nil, err } for p.peek().kind == wikiExprPlus { p.pos++ right, err := p.parsePrimary() if err != nil { return nil, err } left = stringifyWikiTableValue(left) + stringifyWikiTableValue(right) } return left, nil } func (p *wikiExprParser) parsePrimary() (any, error) { token := p.peek() switch token.kind { case wikiExprString: p.pos++ return token.text, nil case wikiExprNumber: p.pos++ value, err := strconv.Atoi(token.text) if err != nil { return nil, fmt.Errorf("invalid number %q", token.text) } return value, nil case wikiExprIdent: p.pos++ if p.peek().kind == wikiExprLParen { p.pos++ args := []any{} if p.peek().kind != wikiExprRParen { previousAllowMissing := p.allowMissingFields if wikiExprHelperAllowsMissingArgs(token.text) { p.allowMissingFields = true } for { arg, err := p.parseComparison() if err != nil { p.allowMissingFields = previousAllowMissing return nil, err } args = append(args, arg) if _, ok := p.take(wikiExprComma); !ok { break } } p.allowMissingFields = previousAllowMissing } if _, ok := p.take(wikiExprRParen); !ok { return nil, fmt.Errorf("missing closing parenthesis for %s", token.text) } return p.callHelper(token.text, args) } return p.resolveField(token.text) case wikiExprLParen: p.pos++ value, err := p.parseComparison() if err != nil { return nil, err } if _, ok := p.take(wikiExprRParen); !ok { return nil, fmt.Errorf("missing closing parenthesis") } return value, nil default: return nil, fmt.Errorf("unexpected token %q", token.text) } } func wikiExprHelperAllowsMissingArgs(name string) bool { switch name { case "present", "all_present", "any_present", "alignments": return true default: return false } } func (p *wikiExprParser) resolveField(name string) (any, error) { name = strings.TrimPrefix(name, "row.") if name == "HasSpellProgression" { return hasSpellProgression(p.renderCtx.Page.Row), nil } if name == "HasSpellsPerDay" { return hasClassSpellProgressionTable(p.renderCtx.Page.Row, p.ctx.classSpellGainTables, "SpellGainTable"), nil } if name == "HasSpellsKnown" { return hasClassSpellProgressionTable(p.renderCtx.Page.Row, p.ctx.classSpellKnownTables, "SpellKnownTable"), nil } if name == "HasClassSpellbook" { return p.ctx.hasClassSpellbook(p.renderCtx.Page.Key, p.renderCtx.Page.Row), nil } switch name { case "true": return true, nil case "false": return false, nil } if value, ok := lookupField(p.row, name); ok { return value, nil } if value, ok := lookupField(p.renderCtx.Page.Row, name); ok { return value, nil } if isClassSpellProgressionFieldName(name) { return nil, nil } if p.allowMissingFields { return nil, nil } return nil, fmt.Errorf("missing field %q", name) } func isClassSpellProgressionFieldName(name string) bool { for _, prefix := range []string{"SpellsPerDay", "SpellsKnown"} { suffix := strings.TrimPrefix(name, prefix) if suffix == name || suffix == "ByLevel" || suffix == "" { continue } if _, err := strconv.Atoi(suffix); err == nil { return true } } return false } func (p *wikiExprParser) callHelper(name string, args []any) (any, error) { switch name { case "ordinal": if len(args) != 1 { return nil, fmt.Errorf("ordinal expects 1 argument") } return ordinalWikiTableValue(numericWikiTableValue(args[0])), nil case "range": if len(args) != 2 { return nil, fmt.Errorf("range expects 2 arguments") } left := stringifyWikiTableValue(args[0]) right := stringifyWikiTableValue(args[1]) if left == "" || right == "" { return "", nil } return left + "-" + right, nil case "link": if len(args) == 1 { key := stringifyWikiTableValue(args[0]) if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) { return "", nil } if p.ctx != nil { if target := p.ctx.publicWikiTargetForKey(key, ""); target != "" { key = target } } return "[[" + key + "]]", nil } if len(args) == 2 { key := stringifyWikiTableValue(args[0]) label := stringifyWikiTableValue(args[1]) if key == "" { return label, nil } if p.ctx != nil && !p.ctx.canReferenceWikiKey(key) { return "", nil } if p.ctx != nil { if target := p.ctx.publicWikiTargetForKey(key, label); target != "" { key = target } } return "[[" + key + "|" + label + "]]", nil } return nil, fmt.Errorf("link expects 1 or 2 arguments") case "if": if len(args) != 3 { return nil, fmt.Errorf("if expects 3 arguments") } if truthyWikiTableValue(args[0]) { return args[1], nil } return args[2], nil case "join": if len(args) != 2 { return nil, fmt.Errorf("join expects 2 arguments") } sep := stringifyWikiTableValue(args[1]) switch typed := args[0].(type) { case []string: return strings.Join(typed, sep), nil case []any: parts := make([]string, 0, len(typed)) for _, value := range typed { if text := stringifyWikiTableValue(value); text != "" { parts = append(parts, text) } } return strings.Join(parts, sep), nil default: return stringifyWikiTableValue(args[0]), nil } case "count": if len(args) != 1 { return nil, fmt.Errorf("count expects 1 argument") } return countWikiTemplateValue(args[0]), nil case "nonzero": if len(args) != 1 { return nil, fmt.Errorf("nonzero expects 1 argument") } if numericWikiTableValue(args[0]) == 0 { return "", nil } return args[0], nil case "eq": if len(args) != 2 { return nil, fmt.Errorf("eq expects 2 arguments") } return stringifyWikiTableValue(args[0]) == stringifyWikiTableValue(args[1]), nil case "field": if len(args) != 1 { return nil, fmt.Errorf("field expects 1 argument") } return p.resolveField(stringifyWikiTableValue(args[0])) case "default": if len(args) != 2 { return nil, fmt.Errorf("default expects 2 arguments") } if strings.TrimSpace(stringifyWikiTableValue(args[0])) == "" { return args[1], nil } return args[0], nil case "present": if len(args) != 1 { return nil, fmt.Errorf("present expects 1 argument") } return presentWikiValue(args[0]), nil case "all_present": if len(args) == 0 { return nil, fmt.Errorf("all_present expects at least 1 argument") } for _, value := range args { if !presentWikiValue(value) { return false, nil } } return true, nil case "any_present": if len(args) == 0 { return nil, fmt.Errorf("any_present expects at least 1 argument") } for _, value := range args { if presentWikiValue(value) { return true, nil } } return false, nil case "alignments": if len(args) != 1 { return nil, fmt.Errorf("alignments expects 1 argument") } return p.ctx.formatWikiTemplateAlignments(args[0]) case "yes_no": if len(args) != 1 { return nil, fmt.Errorf("yes_no expects 1 argument") } return yesNoValue(stringifyWikiTableValue(args[0])), nil case "text": if len(args) != 1 { return nil, fmt.Errorf("text expects 1 argument") } return p.ctx.resolveTextValue(args[0], nil), nil case "ref": if len(args) != 2 { return nil, fmt.Errorf("ref expects 2 arguments") } return p.ctx.renderReference(args[0], p.ctx.wikiIDMapForDataset(stringifyWikiTableValue(args[1])), p.ctx.wikiNameResolverForDataset(stringifyWikiTableValue(args[1]))), nil case "feat_prerequisites": if len(args) != 0 { return nil, fmt.Errorf("feat_prerequisites expects 0 arguments") } return p.ctx.renderFeatPrerequisites(p.renderCtx.Page.Row), nil case "required_feats": if len(args) != 0 { return nil, fmt.Errorf("required_feats expects 0 arguments") } return p.ctx.renderReferenceList(p.ctx.wikiReqFeats(p.renderCtx.Page.Row)), nil case "ability_adjustments": if len(args) != 0 { return nil, fmt.Errorf("ability_adjustments expects 0 arguments") } return formatAbilityAdjustments(p.renderCtx.Page.Row), nil case "hidden_ref": if len(args) != 2 { return nil, fmt.Errorf("hidden_ref expects 2 arguments") } hidden, err := p.hiddenWikiReference(args[0], stringifyWikiTableValue(args[1])) if err != nil { return nil, err } return hidden, nil case "all_hidden_refs": if len(args) < 2 { return nil, fmt.Errorf("all_hidden_refs expects at least 2 arguments") } targetDataset := stringifyWikiTableValue(args[len(args)-1]) checked := false for _, value := range args[:len(args)-1] { if !presentWikiValue(value) { continue } checked = true hidden, err := p.hiddenWikiReference(value, targetDataset) if err != nil { return nil, err } if !hidden { return false, nil } } return checked, nil default: return nil, fmt.Errorf("unknown helper %q", name) } } func countWikiTemplateValue(value any) int { switch typed := value.(type) { case nil: return 0 case []string: return len(typed) case []any: count := 0 for _, item := range typed { if presentWikiValue(item) { count++ } } return count case map[string]any: return len(typed) default: if presentWikiValue(value) { return 1 } return 0 } } func (p *wikiExprParser) hiddenWikiReference(value any, targetDataset string) (bool, error) { targetDataset = strings.TrimSpace(targetDataset) if !isWikiVisibilityDataset(targetDataset) { return false, fmt.Errorf("unknown reference dataset %q", targetDataset) } if !presentWikiValue(value) { return false, nil } if p.ctx == nil { return false, nil } key := resolveReferenceKey(value, p.ctx.wikiIDMapForDataset(targetDataset)) if key == "" { return true, nil } if !strings.HasPrefix(key, targetDataset+":") { return true, nil } return !p.ctx.canReferenceWikiKey(key), nil } func presentWikiValue(value any) bool { if isNullLike(value) { return false } if text, ok := value.(string); ok { return strings.TrimSpace(text) != "" } return value != nil } func ordinalWikiTableValue(value int) string { suffix := "th" if value%100 < 11 || value%100 > 13 { switch value % 10 { case 1: suffix = "st" case 2: suffix = "nd" case 3: suffix = "rd" } } return strconv.Itoa(value) + suffix } func stringifyWikiTableValue(value any) string { switch typed := value.(type) { case nil: return "" case string: if typed == nullValue { return "" } return typed case bool: if typed { return "true" } return "" case int: return strconv.Itoa(typed) case float64: return strconv.Itoa(int(typed)) case []string: return strings.Join(typed, ", ") case []any: parts := make([]string, 0, len(typed)) for _, value := range typed { if text := stringifyWikiTableValue(value); text != "" { parts = append(parts, text) } } return strings.Join(parts, ", ") default: return fmt.Sprint(typed) } } func numericWikiTableValue(value any) int { switch typed := value.(type) { case int: return typed case float64: return int(typed) case string: parsed, _ := strconv.Atoi(strings.TrimSpace(typed)) return parsed default: parsed, _ := strconv.Atoi(strings.TrimSpace(stringifyWikiTableValue(value))) return parsed } } func truthyWikiTableValue(value any) bool { switch typed := value.(type) { case bool: return typed case nil: return false case string: trimmed := strings.TrimSpace(strings.ToLower(typed)) return trimmed != "" && trimmed != "0" && trimmed != "false" && trimmed != nullValue case int: return typed != 0 case float64: return typed != 0 default: return strings.TrimSpace(stringifyWikiTableValue(value)) != "" } }