Artifact 89ddf3b6805585dbbe8ce58984bfe53244019c394b54701498d4f5663cad5247:
- File
internal/api/poll.go
— part of check-in
[1b34fa7b8e]
at
2026-08-21 23:12:54
on branch trunk
— live rendered authoring: split pane renders as you type; poll builder edits the source
Production renderer feeds the pane (plain fragment endpoint); a fenced poll grows structured inputs that rewrite the block. Raw markdown stays the source of truth; no-JS is still a plain textarea. (user: dmestas, size: 2845) [annotate] [blame] [check-ins using]
// 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" "strings" "time" ) 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)) }