Files
sow-tools/internal/topdata/wiki_template_expr.go
T

616 lines
16 KiB
Go

package topdata
import (
"fmt"
"html"
"strconv"
"strings"
"unicode"
)
type wikiTemplateHTML string
func extractWikiTemplateBlock(source string, offset int, blockName, path, pageID string) (string, int, error) {
depth := 1
bodyStart := offset
for offset < len(source) {
start := strings.Index(source[offset:], "{{")
if start < 0 {
break
}
start += offset
end := strings.Index(source[start+2:], "}}")
if end < 0 {
return "", offset, fmt.Errorf("render wiki template %s for %s: unclosed placeholder", path, pageID)
}
end += start + 2
token := strings.TrimSpace(source[start+2 : end])
if token == "#"+blockName || strings.HasPrefix(token, "#"+blockName+" ") {
depth++
} else if token == "/"+blockName {
depth--
if depth == 0 {
return source[bodyStart:start], end + 2, nil
}
}
offset = end + 2
}
return "", offset, fmt.Errorf("render wiki template %s for %s: missing {{/%s}}", path, pageID, blockName)
}
func (ctx *wikiContext) evalWikiTemplateExpression(expr string, state *wikiTemplateRenderState) (any, error) {
parts := splitWikiTemplatePipes(expr)
if len(parts) == 0 {
return "", nil
}
value, err := ctx.evalWikiTemplateBase(strings.TrimSpace(parts[0]), state)
if err != nil {
return nil, err
}
for _, rawFilter := range parts[1:] {
value, err = ctx.applyWikiTemplateFilter(value, strings.TrimSpace(rawFilter), state)
if err != nil {
return nil, err
}
}
return value, nil
}
func splitWikiTemplatePipes(expr string) []string {
parts := []string{}
start := 0
inQuote := false
escaped := false
for i := 0; i < len(expr); i++ {
switch {
case escaped:
escaped = false
case expr[i] == '\\' && inQuote:
escaped = true
case expr[i] == '"':
inQuote = !inQuote
case expr[i] == '|' && !inQuote:
parts = append(parts, expr[start:i])
start = i + 1
}
}
parts = append(parts, expr[start:])
return parts
}
func (ctx *wikiContext) evalWikiTemplateBase(expr string, state *wikiTemplateRenderState) (any, error) {
if expr == "." {
return state.dot, nil
}
if value, ok := ctx.resolveWikiTemplateScopedValue(expr, state); ok {
return value, nil
}
renderCtx := wikiTableRenderContext{Page: state.page, Path: state.path}
row := state.page.Row
if state.row != nil {
row = state.row
}
parser := wikiExprParser{tokens: tokenizeWikiExpr(expr), row: row, ctx: ctx, renderCtx: renderCtx}
value, err := parser.parseComparison()
if err != nil {
return nil, fmt.Errorf("render wiki template %s for %s expression %q: %w", state.path, state.page.PageID, expr, err)
}
if parser.peek().kind != wikiExprEOF {
return nil, fmt.Errorf("render wiki template %s for %s expression %q: unexpected token %q", state.path, state.page.PageID, expr, parser.peek().text)
}
return value, nil
}
func (ctx *wikiContext) resolveWikiTemplateScopedValue(name string, state *wikiTemplateRenderState) (any, bool) {
name = strings.TrimSpace(name)
if strings.HasPrefix(name, "page.") {
field := strings.TrimPrefix(name, "page.")
switch field {
case "title", "Title":
return state.page.Title, true
case "page_id", "PageID":
return state.page.PageID, true
case "status", "Status":
return state.page.Status, true
case "category", "Category":
return state.page.Category, true
case "key", "Key":
return state.page.Key, true
default:
if value, ok := lookupField(state.page.Row, field); ok {
return value, true
}
return nil, false
}
}
if state.row != nil {
if value, ok := lookupField(state.row, name); ok {
return value, true
}
}
if value, ok := lookupField(state.page.Row, name); ok {
return value, true
}
switch name {
case "title":
return state.page.Title, true
case "page_id":
return state.page.PageID, true
case "status":
return state.page.Status, true
}
return nil, false
}
func (ctx *wikiContext) applyWikiTemplateFilter(value any, filter string, state *wikiTemplateRenderState) (any, error) {
name, argText, hasArg := strings.Cut(filter, ":")
name = strings.TrimSpace(name)
args, err := parseWikiTemplateFilterArgs(argText)
if hasArg && err != nil {
return nil, fmt.Errorf("render wiki template %s for %s filter %q: %w", state.path, state.page.PageID, filter, err)
}
switch name {
case "text":
return ctx.resolveTextValue(value, nil), nil
case "trim":
return strings.TrimSpace(stringifyWikiTemplatePlain(value)), nil
case "after":
return afterFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "before":
return beforeFirstMarker(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "between":
text := stringifyWikiTemplatePlain(value)
return beforeFirstMarker(afterFirstMarker(text, firstArg(args)), secondArg(args)), nil
case "paragraph":
return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "paragraphs":
return selectWikiTemplateParagraphs(stringifyWikiTemplatePlain(value), firstArg(args)), nil
case "linebreaks":
return wikiTemplateHTML(strings.ReplaceAll(html.EscapeString(stringifyWikiTemplatePlain(value)), "\n", "<br>")), nil
case "html":
return wikiTemplateHTML(renderWikiTemplatePlainHTML(stringifyWikiTemplatePlain(value))), nil
case "wiki":
return wikiTemplateHTML(renderWikiLinks(stringifyWikiTemplatePlain(value))), nil
case "lower":
return strings.ToLower(stringifyWikiTemplatePlain(value)), nil
case "title":
return strings.Title(stringifyWikiTemplatePlain(value)), nil
case "sentence":
text := stringifyWikiTemplatePlain(value)
for i, r := range text {
if unicode.IsLetter(r) {
return text[:i] + string(unicode.ToUpper(r)) + text[i+len(string(r)):], nil
}
}
return text, nil
case "join":
return joinWikiTemplateValue(value, firstArg(args)), nil
case "count":
return countWikiTemplateValue(value), nil
case "alignments":
return ctx.formatWikiTemplateAlignments(value)
case "list":
return wikiTemplateHTML(renderWikiTemplateList(value, firstArg(args))), nil
case "ordinal":
return ordinalWikiTableValue(numericWikiTableValue(value)), nil
case "default":
if strings.TrimSpace(stringifyWikiTemplatePlain(value)) == "" {
return firstArg(args), nil
}
return value, nil
case "ref":
return ctx.renderTemplateReference(value, firstArg(args)), nil
case "link":
return ctx.renderTemplateLink(value, firstArg(args), state), nil
default:
return nil, fmt.Errorf("render wiki template %s for %s: unknown filter %q", state.path, state.page.PageID, name)
}
}
func parseWikiTemplateFilterArgs(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
args := []string{}
for len(raw) > 0 {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, `"`) {
if idx := strings.Index(raw, ","); idx >= 0 {
args = append(args, strings.TrimSpace(raw[:idx]))
raw = raw[idx+1:]
continue
}
args = append(args, strings.TrimSpace(raw))
break
}
var b strings.Builder
escaped := false
i := 1
for ; i < len(raw); i++ {
if escaped {
b.WriteByte(raw[i])
escaped = false
continue
}
if raw[i] == '\\' {
escaped = true
continue
}
if raw[i] == '"' {
break
}
b.WriteByte(raw[i])
}
if i >= len(raw) {
return nil, fmt.Errorf("unterminated quoted argument")
}
args = append(args, b.String())
raw = strings.TrimSpace(raw[i+1:])
if strings.HasPrefix(raw, ",") {
raw = raw[1:]
} else if raw != "" {
return nil, fmt.Errorf("unexpected argument text %q", raw)
}
}
return args, nil
}
func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
func secondArg(args []string) string {
if len(args) < 2 {
return ""
}
return args[1]
}
func afterFirstMarker(text, marker string) string {
if marker == "" {
return text
}
idx := strings.Index(text, marker)
if idx < 0 {
return ""
}
return text[idx+len(marker):]
}
func beforeFirstMarker(text, marker string) string {
if marker == "" {
return text
}
idx := strings.Index(text, marker)
if idx < 0 {
return text
}
return text[:idx]
}
func selectWikiTemplateParagraphs(text, selector string) string {
paragraphs := splitWikiTemplateParagraphs(text)
if len(paragraphs) == 0 {
return ""
}
switch strings.TrimSpace(selector) {
case "first":
return paragraphs[0]
case "last":
return paragraphs[len(paragraphs)-1]
case "all", "":
return strings.Join(paragraphs, "\n\n")
default:
selected := []string{}
for _, part := range strings.Split(selector, ",") {
switch strings.TrimSpace(part) {
case "first":
selected = append(selected, paragraphs[0])
case "last":
selected = append(selected, paragraphs[len(paragraphs)-1])
default:
if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && idx > 0 && idx <= len(paragraphs) {
selected = append(selected, paragraphs[idx-1])
}
}
}
return strings.Join(selected, "\n\n")
}
}
func splitWikiTemplateParagraphs(text string) []string {
normalized := strings.ReplaceAll(text, "\r\n", "\n")
chunks := strings.Split(normalized, "\n\n")
out := []string{}
for _, chunk := range chunks {
if trimmed := strings.TrimSpace(chunk); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func renderWikiTemplatePlainHTML(text string) string {
paragraphs := splitWikiTemplateParagraphs(text)
if len(paragraphs) == 0 {
return ""
}
out := strings.Builder{}
for _, paragraph := range paragraphs {
escaped := html.EscapeString(paragraph)
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
out.WriteString("<p>")
out.WriteString(escaped)
out.WriteString("</p>")
}
return out.String()
}
func joinWikiTemplateValue(value any, sep string) string {
switch typed := value.(type) {
case []string:
return strings.Join(typed, sep)
case []any:
parts := make([]string, 0, len(typed))
for _, item := range typed {
if text := stringifyWikiTemplatePlain(item); text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, sep)
default:
return stringifyWikiTemplatePlain(value)
}
}
func (ctx *wikiContext) formatWikiTemplateAlignments(value any) (any, error) {
values, err := wikiTemplateAlignmentValues(value)
if err != nil {
return "", err
}
if len(values) == 0 || (len(values) == 1 && values[0] == 0) {
return "Any", nil
}
labels := make([]string, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
label, ok := wikiTemplateAlignmentLabel(value)
if !ok {
return "", fmt.Errorf("unknown alignment value 0x%02X", value)
}
labels = append(labels, ctx.renderWikiTemplateAlignmentLabel(label))
}
if len(labels) == 0 {
return "Any", nil
}
rendered := strings.Join(labels, ", ")
if ctx != nil && strings.TrimSpace(ctx.alignmentLinkFormat) == "html_anchor" {
return wikiTemplateHTML(rendered), nil
}
return rendered, nil
}
func (ctx *wikiContext) renderWikiTemplateAlignmentLabel(label string) string {
pattern := ""
format := "wiki_markup"
if ctx != nil {
pattern = strings.TrimSpace(ctx.alignmentLinkPattern)
if strings.TrimSpace(ctx.alignmentLinkFormat) != "" {
format = strings.TrimSpace(ctx.alignmentLinkFormat)
}
}
if pattern == "" {
return label
}
target := strings.ReplaceAll(pattern, "{alignment}", wikiTemplateAlignmentTargetSegment(label))
target = strings.ReplaceAll(target, "{label}", label)
if strings.TrimSpace(target) == "" {
return label
}
if format == "html_anchor" {
return `<a class="wiki-internal-link" href="` + html.EscapeString(target) + `">` + html.EscapeString(label) + `</a>`
}
return "[[" + target + "|" + label + "]]"
}
func wikiTemplateAlignmentTargetSegment(label string) string {
return strings.ReplaceAll(strings.TrimSpace(label), " ", "_")
}
func wikiTemplateAlignmentValues(value any) ([]int, error) {
if isNullLike(value) {
return []int{0}, nil
}
switch typed := value.(type) {
case map[string]any:
for key := range typed {
if key != "list" {
return nil, fmt.Errorf("alignment list object contains unsupported key %q", key)
}
}
return wikiTemplateAlignmentValues(typed["list"])
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" || trimmed == "0" || strings.EqualFold(trimmed, "0x00") {
return []int{0}, nil
}
if strings.HasPrefix(strings.ToLower(trimmed), "0x") {
hexText := trimmed[2:]
if len(hexText)%2 != 0 {
return nil, fmt.Errorf("alignment hex list %q must contain two-character chunks", typed)
}
values := make([]int, 0, len(hexText)/2)
for index := 0; index < len(hexText); index += 2 {
parsed, err := strconv.ParseInt(hexText[index:index+2], 16, 0)
if err != nil {
return nil, fmt.Errorf("alignment hex list %q contains invalid chunk %q", typed, hexText[index:index+2])
}
values = append(values, int(parsed))
}
return values, nil
}
if strings.Contains(trimmed, ",") {
parts := strings.Split(trimmed, ",")
values := make([]int, 0, len(parts))
for _, part := range parts {
parsed, err := parseConfiguredAlignmentValue(part)
if err != nil {
return nil, err
}
values = append(values, parsed)
}
return values, nil
}
parsed, err := parseConfiguredAlignmentValue(trimmed)
if err != nil {
return nil, err
}
return []int{parsed}, nil
case []any:
values := make([]int, 0, len(typed))
for _, item := range typed {
parsed, err := parseConfiguredAlignmentValue(item)
if err != nil {
return nil, err
}
values = append(values, parsed)
}
return values, nil
default:
parsed, err := parseConfiguredAlignmentValue(value)
if err != nil {
return nil, err
}
return []int{parsed}, nil
}
}
func wikiTemplateAlignmentLabel(value int) (string, bool) {
switch value {
case 0x0A:
return "Lawful Good", true
case 0x09:
return "Neutral Good", true
case 0x0C:
return "Chaotic Good", true
case 0x03:
return "Lawful Neutral", true
case 0x01:
return "True Neutral", true
case 0x05:
return "Chaotic Neutral", true
case 0x12:
return "Lawful Evil", true
case 0x11:
return "Neutral Evil", true
case 0x14:
return "Chaotic Evil", true
default:
return "", false
}
}
func renderWikiTemplateList(value any, class string) string {
items := wikiTemplateListItems(value)
if len(items) == 0 {
return ""
}
var out strings.Builder
out.WriteString("<ul")
out.WriteString(attrClass(class))
out.WriteString(">")
for _, item := range items {
out.WriteString("<li>")
out.WriteString(renderWikiLinks(item))
out.WriteString("</li>")
}
out.WriteString("</ul>")
return out.String()
}
func wikiTemplateListItems(value any) []string {
switch typed := value.(type) {
case []string:
out := make([]string, 0, len(typed))
for _, item := range typed {
if text := strings.TrimSpace(item); text != "" {
out = append(out, text)
}
}
return out
case []any:
out := make([]string, 0, len(typed))
for _, item := range typed {
if text := strings.TrimSpace(stringifyWikiTemplatePlain(item)); text != "" {
out = append(out, text)
}
}
return out
default:
if text := strings.TrimSpace(stringifyWikiTemplatePlain(value)); text != "" {
return []string{text}
}
return nil
}
}
func (ctx *wikiContext) renderTemplateReference(value any, dataset string) string {
return ctx.renderReference(value, ctx.wikiIDMapForDataset(dataset), ctx.wikiNameResolverForDataset(dataset))
}
func (ctx *wikiContext) wikiNameResolverForDataset(dataset string) func(string) string {
switch dataset {
case "classes":
return ctx.resolveClassName
case "feat":
return ctx.resolveFeatName
case "skills":
return ctx.resolveSkillName
default:
return func(string) string { return "" }
}
}
func (ctx *wikiContext) renderTemplateLink(value any, labelField string, state *wikiTemplateRenderState) string {
key := stringifyWikiTemplatePlain(value)
label := labelField
if state.row != nil {
if raw, ok := lookupField(state.row, labelField); ok {
label = stringifyWikiTemplatePlain(raw)
}
}
if label == "" {
label = key
}
if key == "" {
return label
}
if !ctx.canReferenceWikiKey(key) {
return ""
}
if target := ctx.publicWikiTargetForKey(key, label); target != "" {
key = target
}
return "[[" + key + "|" + label + "]]"
}
func stringifyWikiTemplateOutput(value any) string {
if htmlValue, ok := value.(wikiTemplateHTML); ok {
return string(htmlValue)
}
return html.EscapeString(stringifyWikiTemplatePlain(value))
}
func stringifyWikiTemplatePlain(value any) string {
if htmlValue, ok := value.(wikiTemplateHTML); ok {
return string(htmlValue)
}
return stringifyWikiTableValue(value)
}