Skip to content
quarry Artifact [545d607bf8]
Login

Artifact [545d607bf8]

Artifact 545d607bf818cb3042b56473c08ccfcfb528cd261322a0e0b403aa193d66de9f:


// 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
}