Files
sm-bm-plugin/classes/ics.go
2026-10-02 17:38:48 +02:00

106 lines
2.9 KiB
Go

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
}