package calfeed import ( "strconv" "strings" "time" ) // 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") 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") }