// poll.go parses the ```poll fenced block — quarry's poll convention.
// The DEFINITION lives in the forum post body, so it syncs like any
// artifact and a fossil peer renders it as readable markdown; only the
// ballots (OLTP) are forge-local. Full compatibility BY degradation.
package api
import (
"fmt"
"html"
"regexp"
"slices"
"strings"
"time"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
type pollDef struct {
Question string
Options []string
Closes string // YYYY-MM-DD (UTC); "" never closes
}
// pollSlot stands where the poll's fence was, so the rendered body
// says where the widget goes: a poll draws in place, as Rich shows it,
// not after all of the post's text (#376). One word, so every markup
// renders it as plain text a renderer will not touch.
const pollSlot = "QUARRYPOLLSLOT"
var pollSlotRe = regexp.MustCompile(`<p>\s*` + pollSlot + `\s*</p>\n?|` + pollSlot)
// placePoll puts widget where the poll's fence was in a rendered body.
func placePoll(html, widget string) string {
done := false
return pollSlotRe.ReplaceAllStringFunc(html, func(string) string {
if done {
return ""
}
done = true
return widget
})
}
// pollSlotHTML is what a post's HTML carries where its poll goes, for a
// page that draws the live widget there itself.
const pollSlotHTML = `<div class="f-poll-slot" data-hook="poll-slot"></div>`
// pollFences counts a body's ```poll blocks: a post holds one poll,
// since its ballots belong to the post.
func pollFences(body string) int {
n := 0
for _, l := range strings.Split(body, "\n") {
if strings.TrimSpace(l) == "```poll" {
n++
}
}
return n
}
// refuseSecondPoll turns away a post with more than one poll: its
// ballots belong to the post, so only one could ever be voted on, and
// the second sat there as a code block (#376).
func refuseSecondPoll(body string) error {
if pollFences(body) > 1 {
return fmt.Errorf("a post holds one poll; this one has %d ```poll blocks, so split them across posts: %w", pollFences(body), ops.ErrInvalid)
}
return nil
}
// parsePoll extracts the first ```poll block. rest is the body with the
// block replaced by pollSlot (quarry renders the widget there); def is
// nil when no well-formed poll (a question and at least two options) is
// present — then the body renders untouched, exactly as a fossil peer
// would.
func parsePoll(body string) (def *pollDef, rest string) {
lines := strings.Split(body, "\n")
start := -1
for i, l := range lines {
if strings.TrimSpace(l) == "```poll" {
start = i
break
}
}
if start < 0 {
return nil, body
}
end := -1
for i := start + 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "```" {
end = i
break
}
}
if end < 0 {
return nil, body
}
d := &pollDef{}
for _, l := range lines[start+1 : end] {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "question:"):
d.Question = strings.TrimSpace(strings.TrimPrefix(l, "question:"))
case strings.HasPrefix(l, "closes:"):
d.Closes = strings.TrimSpace(strings.TrimPrefix(l, "closes:"))
case strings.HasPrefix(l, "- "):
d.Options = append(d.Options, strings.TrimSpace(strings.TrimPrefix(l, "- ")))
}
}
if d.Question == "" || len(d.Options) < 2 {
return nil, body
}
kept := append(append([]string{}, lines[:start]...), "", pollSlot, "")
return d, strings.Join(append(kept, lines[end+1:]...), "\n")
}
// pollPreviewHTML renders a ballot-less poll widget for the composer's
// preview: the same shape the thread page will draw, zero votes. All
// text is escaped — this is user draft content.
func pollPreviewHTML(d *pollDef) string {
var b strings.Builder
b.WriteString(`<div class="f-pollprev"><p><strong>` + html.EscapeString(d.Question) + `</strong> <small>0 ballots`)
if d.Closes != "" {
b.WriteString(` · closes ` + html.EscapeString(d.Closes))
}
b.WriteString(`</small></p>`)
for _, o := range d.Options {
b.WriteString(`<div class="f-pollprev-o"><small>○ ` + html.EscapeString(o) + ` · 0</small><div class="f-pollbar"></div></div>`)
}
b.WriteString(`</div>`)
return b.String()
}
// closed reports whether the poll has passed its close DATE: ballots
// are accepted through the named day (UTC), frozen after it.
func (d *pollDef) closed(now time.Time) bool {
if d.Closes == "" {
return false
}
t, err := time.Parse("2006-01-02", d.Closes)
if err != nil {
return false // an unreadable date never silently freezes a poll
}
return now.UTC().After(t.AddDate(0, 0, 1))
}
// pollTally counts the poll of the post whose newest version is post,
// as def says it reads now. Ballots belong to the post, not to the
// version they were cast on: an edit writes a new version, and a poll
// that started again from zero on every edit would lose its votes to a
// fixed typo. A ballot counts when the version it was cast on asked the
// same options, so an edit that changes the options starts a fresh
// poll rather than moving votes to answers nobody chose. Each account
// counts once, by its latest ballot.
func (d Deps) pollTally(ix *index.Index, repo, post string, def *pollDef, viewer string) (pollRendered, error) {
out := pollRendered{Present: true, Question: def.Question, Options: def.Options,
Counts: make([]int, len(def.Options)), Percents: make([]int, len(def.Options)),
YourChoice: -1, Closed: def.closed(time.Now()), Closes: def.Closes}
versions, _, err := ix.ForumPostVersions(post)
if err != nil {
return pollRendered{}, err
}
// order is each counted version's place in the chain, newest last,
// which breaks a tie between two ballots cast in the same second.
order := map[string]int{post: len(versions)}
for i, v := range versions {
if v.Hash == post {
continue
}
if vd, _ := parsePoll(v.Body); vd != nil && slices.Equal(vd.Options, def.Options) {
order[v.Hash] = i
}
}
hashes := make([]string, 0, len(order))
for h := range order {
hashes = append(hashes, h)
}
ballots, err := d.Forum.PollBallots(repo, hashes)
if err != nil {
return pollRendered{}, err
}
latest := map[string]oltp.PollBallot{}
for _, b := range ballots {
cur, seen := latest[b.Account]
if !seen || b.At > cur.At || (b.At == cur.At && order[b.Post] > order[cur.Post]) {
latest[b.Account] = b
}
}
for account, b := range latest {
if b.Choice < 0 || b.Choice >= len(def.Options) {
continue
}
out.Counts[b.Choice]++
out.Total++
if account == viewer && viewer != "" {
out.YourChoice = b.Choice
}
}
for i, n := range out.Counts {
if out.Total > 0 {
out.Percents[i] = n * 100 / out.Total
}
}
return out, nil
}