Skip to content
quarry Artifact [82ef2e424d]
Login

Artifact [82ef2e424d]

Artifact 82ef2e424de27d8a34073353ef785626b2c9e8789ee683bc015e3d9fcf3c63d9:


// 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 (
	"html"
	"slices"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/oltp"
)

type pollDef struct {
	Question string
	Options  []string
	Closes   string // YYYY-MM-DD (UTC); "" never closes
}

// parsePoll extracts the first ```poll block. rest is the body with the
// block removed (quarry renders the widget instead); 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
	}
	return d, strings.Join(append(append([]string{}, lines[:start]...), 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
}