Files
hotdog-cms/internal/build/events.go
T

270 lines
7.5 KiB
Go

package build
import (
"crypto/sha256"
"encoding/hex"
"html/template"
"mime"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode/utf8"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/site"
)
// Events: pages with a start: (site/event.go). A collection with
// calendar: true gets a feed at /<collection>/calendar.ics that calendar
// apps subscribe to, and every event page gets its own event.ics for "Add to
// calendar". Times are written in UTC, which every calendar reads the same
// way; all-day events are dates.
func eventFuncs(s *site.Site) map[string]any {
limit := func(pages []*site.Page, n []int) []*site.Page {
if len(n) > 0 && n[0] >= 0 && n[0] < len(pages) {
return pages[:n[0]]
}
return pages
}
return map[string]any{
// upcoming "events" [n]: events that haven't ended, soonest first.
"upcoming": func(collection string, n ...int) []*site.Page {
return limit(upcoming(s, collection), n)
},
// pastEvents "events" [n]: events that have ended, latest first.
"pastEvents": func(collection string, n ...int) []*site.Page {
var out []*site.Page
for _, p := range s.Collection(collection) {
if p.Event != nil && !p.Event.Ends().After(s.Now) {
out = append(out, p)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Event.Start.After(out[j].Event.Start) })
return limit(out, n)
},
// calendarURL "events": the collection's feed, https://…/events/calendar.ics.
"calendarURL": func(collection string) string { return s.Config.URL + calendarPath(collection) },
// webcal "events": the same feed as a webcal:// link, which opens the
// visitor's calendar app to subscribe. html/template allows only web
// and mail links in href; this one is built here from the site's own
// address, never from page text, so it's marked as a safe URL.
"webcal": func(collection string) template.URL {
if !nameRe.MatchString(collection) {
return ""
}
u, err := url.Parse(s.Config.URL + calendarPath(collection))
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return ""
}
u.Scheme = "webcal"
return template.URL(u.String())
},
// banners .: the notices showing on this page now (data/banners.yaml).
"banners": func(p *site.Page) []site.Banner { return s.BannersFor(p) },
// eventData .: the event as schema.org data, for a JSON-LD script tag.
"eventData": func(p *site.Page) map[string]any { return eventData(p) },
}
}
func init() {
// For every server that's Go: serve, previews, the editor's live preview.
_ = mime.AddExtensionType(".ics", "text/calendar; charset=utf-8")
}
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
func calendarPath(collection string) string { return "/" + collection + "/calendar.ics" }
func upcoming(s *site.Site, collection string) []*site.Page {
var out []*site.Page
for _, p := range s.Collection(collection) {
if p.Event != nil && p.Event.Ends().After(s.Now) {
out = append(out, p)
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Event.Start.Before(out[j].Event.Start) })
return out
}
func eventData(p *site.Page) map[string]any {
e := p.Event
if e == nil {
return nil
}
d := map[string]any{"@context": "https://schema.org", "@type": "Event", "name": p.Title, "url": p.Permalink}
if e.AllDay {
d["startDate"] = e.Start.Format("2006-01-02")
if !e.End.IsZero() {
d["endDate"] = e.End.Format("2006-01-02")
}
} else {
d["startDate"] = e.Start.Format(time.RFC3339)
if !e.End.IsZero() {
d["endDate"] = e.End.Format(time.RFC3339)
}
}
if p.Summary != "" {
d["description"] = p.Summary
}
if e.Location != "" {
d["location"] = map[string]any{"@type": "Place", "name": e.Location}
}
switch e.Status {
case "cancelled":
d["eventStatus"] = "https://schema.org/EventCancelled"
case "postponed":
d["eventStatus"] = "https://schema.org/EventPostponed"
default:
d["eventStatus"] = "https://schema.org/EventScheduled"
}
return d
}
// writeCalendars writes every calendar collection's feed and every event's
// own file.
func writeCalendars(s *site.Site, out string) error {
host := ""
if u, err := url.Parse(s.Config.URL); err == nil {
host = u.Hostname()
}
for name, cc := range s.Config.Collections {
if !cc.Calendar {
continue
}
var events []*site.Page
for _, p := range s.Collection(name) {
if p.Event != nil {
events = append(events, p)
}
}
title := cc.CalendarName
if title == "" {
list := s.Lists[name]
coll := name
if list != nil && list.Title != "" {
coll = list.Title
}
title = s.Config.Name + ": " + coll
}
if err := writeICS(filepath.Join(out, name, "calendar.ics"), ics(s, host, title, events)); err != nil {
return err
}
}
for _, p := range s.Pages {
if f := p.EventICS(); f != "" {
if err := writeICS(filepath.Join(out, filepath.FromSlash(strings.TrimPrefix(f, "/"))), ics(s, host, p.Title, []*site.Page{p})); err != nil {
return err
}
}
}
return nil
}
func writeICS(file, body string) error {
if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil {
return err
}
return os.WriteFile(file, []byte(body), 0o644)
}
// ics writes an iCalendar (RFC 5545) calendar of events.
func ics(s *site.Site, host, name string, events []*site.Page) string {
var b strings.Builder
line := func(l string) { b.WriteString(fold(l)) }
line("BEGIN:VCALENDAR")
line("VERSION:2.0")
line("PRODID:-//Coffey Labs//HotDog CMS//EN")
line("CALSCALE:GREGORIAN")
line("METHOD:PUBLISH")
line("X-WR-CALNAME:" + text(name))
line("REFRESH-INTERVAL;VALUE=DURATION:PT6H")
line("X-PUBLISHED-TTL:PT6H")
for _, p := range events {
e := p.Event
sum := sha256.Sum256([]byte(p.Path))
line("BEGIN:VEVENT")
line("UID:" + hex.EncodeToString(sum[:])[:20] + "@" + host)
stamp := p.Updated
if stamp.IsZero() {
stamp = p.Date
}
if stamp.IsZero() {
stamp = e.Start
}
line("DTSTAMP:" + utc(stamp))
if e.AllDay {
last := e.End
if last.IsZero() {
last = e.Start
}
y, m, d := last.Date()
line("DTSTART;VALUE=DATE:" + e.Start.Format("20060102"))
line("DTEND;VALUE=DATE:" + time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC).Format("20060102"))
} else {
line("DTSTART:" + utc(e.Start))
if !e.End.IsZero() {
line("DTEND:" + utc(e.End))
}
}
line("SUMMARY:" + text(p.Title))
desc := p.Summary
if e.Link != "" {
desc = strings.TrimSpace(desc + "\n\n" + absolute(s, e.Link))
}
desc = strings.TrimSpace(desc + "\n\n" + p.Permalink)
line("DESCRIPTION:" + text(desc))
if e.Location != "" {
line("LOCATION:" + text(e.Location))
}
line("URL:" + p.Permalink)
switch e.Status {
case "cancelled":
line("STATUS:CANCELLED")
case "postponed":
line("STATUS:TENTATIVE")
default:
line("STATUS:CONFIRMED")
}
line("END:VEVENT")
}
line("END:VCALENDAR")
return b.String()
}
func absolute(s *site.Site, link string) string {
if strings.HasPrefix(link, "/") {
return s.Config.URL + link
}
return link
}
func utc(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
// text escapes a TEXT value: backslash, semicolon, comma and line breaks.
func text(v string) string {
r := strings.NewReplacer(`\`, `\\`, ";", `\;`, ",", `\,`, "\r\n", `\n`, "\n", `\n`, "\r", `\n`)
return r.Replace(v)
}
// fold ends a content line with CRLF, folding it at 75 octets without
// splitting a character.
func fold(l string) string {
var b strings.Builder
n := 0
for _, r := range l {
w := utf8.RuneLen(r)
if n+w > 75 {
b.WriteString("\r\n ")
n = 1
}
b.WriteRune(r)
n += w
}
b.WriteString("\r\n")
return b.String()
}