package topdata import ( "fmt" "html" "os" "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"` 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"` HeaderClass string `json:"header_class" yaml:"header_class"` 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 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, map[string]any{}, 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 } 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) 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 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, 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(def wikiTableDefinition, classRow map[string]any) ([]map[string]any, error) { start, end, err := parseLevelRange(def.Rows.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 } } } granted := 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 != "" { granted[level] = append(granted[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")) out := []map[string]any{} for level := start; level <= end; level++ { row := cloneRowMap(classRow) row["Level"] = level row["BAB"] = formatSignedInt(classBABAtLevel(stringValue(classRow, "AttackBonusTable"), level)) if saveRow := saveRows[strconv.Itoa(level)]; saveRow != nil { for key, value := range saveRow { row[key] = value } } row["GrantedFeats"] = granted[level] row["BonusFeat"] = bonusByLevel[level] row["HPMin"] = level if hitDie > 0 { row["HPMax"] = level * hitDie } row["Notes"] = "" row["HasSpellProgression"] = hasSpellProgression(classRow) out = append(out, row) } return out, nil } 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 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 hasSpellProgression(row map[string]any) bool { return stringValue(row, "SpellGainTable") != "" || stringValue(row, "SpellKnownTable") != "" } 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 { for { arg, err := p.parseComparison() if err != nil { return nil, err } args = append(args, arg) if _, ok := p.take(wikiExprComma); !ok { break } } } 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 (p *wikiExprParser) resolveField(name string) (any, error) { name = strings.TrimPrefix(name, "row.") if name == "HasSpellProgression" { return hasSpellProgression(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 p.allowMissingFields { return nil, nil } return nil, fmt.Errorf("missing field %q", name) } 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 pageID := wikiPageIDForKey(key); pageID != "" { key = pageID } 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 pageID := wikiPageIDForKey(key); pageID != "" { key = pageID } 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 "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 "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 (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)) != "" } }