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:. func (w *ICS) Begin(component string) { w.Line("BEGIN:" + component) } // End writes END:. 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:, such as DTSTART:20261003T060000Z. func (w *ICS) UTC(name string, t time.Time) { w.Prop(name, FormatUTC(t)) } // Date writes name;VALUE=DATE: 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 }