package calendar import ( "strings" "time" ) // Event is a single entry rendered into an ICS feed. Fields are already // resolved to display text; the renderer handles escaping and folding. type Event struct { UID string StartsAt time.Time EndsAt time.Time Stamp time.Time Summary string Description string Location string Status string // "confirmed" | "tentative" | "cancelled" Transparent bool // TRANSP:TRANSPARENT — does not block free/busy } const icsTimestampLayout = "20060102T150405Z" // RenderCalendar produces an RFC 5545 VCALENDAR body with CRLF line endings // and 75-octet folding. func RenderCalendar(name string, timezone string, events []Event) string { var b strings.Builder writeLine(&b, "BEGIN:VCALENDAR") writeLine(&b, "VERSION:2.0") writeLine(&b, "PRODID:-//Bookra//Bookra//EN") writeLine(&b, "CALSCALE:GREGORIAN") writeLine(&b, "METHOD:PUBLISH") writeLine(&b, "X-WR-CALNAME:"+escapeText(name)) if timezone != "" { writeLine(&b, "X-WR-TIMEZONE:"+timezone) } for _, event := range events { writeLine(&b, "BEGIN:VEVENT") writeLine(&b, "UID:"+event.UID) stamp := event.Stamp if stamp.IsZero() { stamp = event.StartsAt } writeLine(&b, "DTSTAMP:"+stamp.UTC().Format(icsTimestampLayout)) writeLine(&b, "DTSTART:"+event.StartsAt.UTC().Format(icsTimestampLayout)) writeLine(&b, "DTEND:"+event.EndsAt.UTC().Format(icsTimestampLayout)) writeLine(&b, "SUMMARY:"+escapeText(event.Summary)) if event.Description != "" { writeLine(&b, "DESCRIPTION:"+escapeText(event.Description)) } if event.Location != "" { writeLine(&b, "LOCATION:"+escapeText(event.Location)) } switch event.Status { case "cancelled": writeLine(&b, "STATUS:CANCELLED") case "tentative": writeLine(&b, "STATUS:TENTATIVE") } if event.Transparent { writeLine(&b, "TRANSP:TRANSPARENT") } writeLine(&b, "END:VEVENT") } writeLine(&b, "END:VCALENDAR") return b.String() } // escapeText applies RFC 5545 §3.3.11 TEXT escaping. func escapeText(value string) string { replacer := strings.NewReplacer( `\`, `\\`, ";", `\;`, ",", `\,`, "\n", `\n`, "\r", "", ) return replacer.Replace(value) } // writeLine emits one content line folded at 75 octets per RFC 5545 §3.1. // Continuation lines start with a single space; folds happen on rune // boundaries so multi-byte characters are never split. func writeLine(b *strings.Builder, line string) { const maxOctets = 75 octets := 0 first := true for _, r := range line { width := len(string(r)) limit := maxOctets if !first { limit-- // continuation lines carry a leading space } if octets+width > limit { b.WriteString("\r\n ") octets = 0 first = false limit = maxOctets - 1 } b.WriteRune(r) octets += width } b.WriteString("\r\n") }