aboutsummaryrefslogtreecommitdiff
path: root/internal/calfeed/ical.go
blob: 810847ddc7068ea906785d084dacacfaa9a1295e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
package calfeed

import (
	"strconv"
	"strings"
	"time"

	"github.com/lukaszkasprzak/lectio/internal/config"
)

// icalEscape neutralises RFC-5545 TEXT specials AND all CR/LF, so untrusted
// celebration names / citations cannot inject iCal lines or properties.
func icalEscape(s string) string {
	s = strings.ReplaceAll(s, "\\", "\\\\")
	s = strings.ReplaceAll(s, ";", "\\;")
	s = strings.ReplaceAll(s, ",", "\\,")
	s = strings.ReplaceAll(s, "\r\n", "\\n")
	s = strings.ReplaceAll(s, "\r", "\\n")
	s = strings.ReplaceAll(s, "\n", "\\n")
	return s
}

// foldLine folds a content line at 75 octets with a leading space on
// continuations (RFC 5545 §3.1). Counts bytes; folding runs after escaping.
func foldLine(line string) string {
	if len(line) <= 75 {
		return line
	}
	var b strings.Builder
	for i := 0; i < len(line); {
		end := i + 75
		if i > 0 {
			end = i + 74 // account for the leading space
		}
		if end > len(line) {
			end = len(line)
		}
		if i > 0 {
			b.WriteString("\r\n ")
		}
		b.WriteString(line[i:end])
		i = end
	}
	return b.String()
}

func calName(form string) string {
	if form == "old" {
		return "Lectio — Extraordinary Form"
	}
	return "Lectio — Ordinary Form"
}

// ICal renders days as an RFC-5545 VCALENDAR, one all-day VEVENT per day.
func ICal(form string, days []DayView, stamp time.Time) []byte {
	var lines []string
	add := func(s string) { lines = append(lines, foldLine(s)) }
	add("BEGIN:VCALENDAR")
	add("VERSION:2.0")
	add("PRODID:-//lectio//calendar//EN")
	add("CALSCALE:GREGORIAN")
	add("METHOD:PUBLISH")
	// AGPL-3.0 §13 offer for subscribers who only ever see the .ics feed.
	// X- properties are the RFC-5545 extension point; unknown ones are
	// ignored by clients, so this is inert for consumers and present for
	// anyone who looks.
	add("X-LECTIO-SOURCE:" + icalEscape(config.SourceURL))
	add("X-LECTIO-LICENSE:" + icalEscape(config.License))
	add("X-WR-CALNAME:" + icalEscape(calName(form)))
	ds := stamp.UTC().Format("20060102T150405Z")
	for _, d := range days {
		date := strings.ReplaceAll(d.Date, "-", "") // YYYYMMDD
		next, _ := time.Parse("2006-01-02", d.Date)
		end := next.AddDate(0, 0, 1).Format("20060102")
		var desc []string
		desc = append(desc, "Season: "+d.Season+" (week "+strconv.Itoa(d.Week)+")")
		if d.Observed.Rank != "" {
			desc = append(desc, "Rank: "+d.Observed.Rank)
		}
		desc = append(desc, "Colour: "+d.Colour)
		for _, r := range d.Readings {
			desc = append(desc, r.Part+": "+r.Citation)
		}
		add("BEGIN:VEVENT")
		add("UID:" + d.Date + "-" + form + "@lectio") // input-free, stable
		add("DTSTAMP:" + ds)
		add("DTSTART;VALUE=DATE:" + date)
		add("DTEND;VALUE=DATE:" + end)
		add("SUMMARY:" + icalEscape(d.Observed.Name))
		// join with real \n, then escape the whole string so \n -> \\n and every
		// TEXT special is neutralised in one pass.
		add("DESCRIPTION:" + icalEscape(strings.Join(desc, "\n")))
		if d.Colour != "" {
			add("CATEGORIES:" + icalEscape(strings.ToUpper(d.Colour)))
		}
		add("END:VEVENT")
	}
	add("END:VCALENDAR")
	return []byte(strings.Join(lines, "\r\n") + "\r\n")
}