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")
}
|