feat: serve a subscribable ICS calendar feed
This commit is contained in:
105
classes/ics.go
Normal file
105
classes/ics.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ICS writes an iCalendar (RFC 5545) stream: CRLF line endings, content
|
||||
// lines folded at 75 octets without splitting a UTF-8 sequence, and TEXT
|
||||
// values escaped. The zero value is ready to use.
|
||||
type ICS struct {
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
// icsLineOctets is the longest content line RFC 5545 allows, not counting
|
||||
// the CRLF.
|
||||
const icsLineOctets = 75
|
||||
|
||||
// Begin writes BEGIN:<component>.
|
||||
func (w *ICS) Begin(component string) { w.Line("BEGIN:" + component) }
|
||||
|
||||
// End writes END:<component>.
|
||||
func (w *ICS) End(component string) { w.Line("END:" + component) }
|
||||
|
||||
// Prop writes name:value with the value as it is (a value of a type that
|
||||
// needs no escaping, such as a date, a duration or a URI).
|
||||
func (w *ICS) Prop(name, value string) { w.Line(name + ":" + value) }
|
||||
|
||||
// Text writes name:value with the value escaped as TEXT.
|
||||
func (w *ICS) Text(name, value string) { w.Line(name + ":" + EscapeText(value)) }
|
||||
|
||||
// UTC writes name:<t in UTC>, such as DTSTART:20261003T060000Z.
|
||||
func (w *ICS) UTC(name string, t time.Time) { w.Prop(name, FormatUTC(t)) }
|
||||
|
||||
// Date writes name;VALUE=DATE:<YYYYMMDD> for an all-day value.
|
||||
func (w *ICS) Date(name string, t time.Time) { w.Prop(name+";VALUE=DATE", t.Format("20060102")) }
|
||||
|
||||
// Line writes one content line, folded.
|
||||
func (w *ICS) Line(line string) {
|
||||
line = strings.NewReplacer("\r", "", "\n", "").Replace(line)
|
||||
w.buf.WriteString(FoldLine(line))
|
||||
w.buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
// Bytes returns the stream written so far.
|
||||
func (w *ICS) Bytes() []byte { return w.buf.Bytes() }
|
||||
|
||||
// FormatUTC formats t as an RFC 5545 UTC date-time (20261003T060000Z).
|
||||
func FormatUTC(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
|
||||
|
||||
// EscapeText escapes a TEXT value: backslash, semicolon, comma and line
|
||||
// breaks (as \n). A lone CR is dropped.
|
||||
func EscapeText(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case ';':
|
||||
b.WriteString(`\;`)
|
||||
case ',':
|
||||
b.WriteString(`\,`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FoldLine folds a content line (without its CRLF) so that no physical line
|
||||
// is longer than 75 octets. Continuation lines start with one space, which
|
||||
// counts towards their 75 octets; a multi-octet UTF-8 character is never
|
||||
// split.
|
||||
func FoldLine(line string) string {
|
||||
if len(line) <= icsLineOctets {
|
||||
return line
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for i := 0; i < len(line); {
|
||||
size := runeBytes(line[i:])
|
||||
if n+size > icsLineOctets {
|
||||
b.WriteString("\r\n ")
|
||||
n = 1
|
||||
}
|
||||
b.WriteString(line[i : i+size])
|
||||
n += size
|
||||
i += size
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runeBytes is the length in bytes of the first character of s, also for an
|
||||
// invalid sequence (one byte).
|
||||
func runeBytes(s string) int {
|
||||
_, size := utf8.DecodeRuneInString(s)
|
||||
return size
|
||||
}
|
||||
Reference in New Issue
Block a user