Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/platform/icalendar/icalendar.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/platform/icalendar/icalendar.go |
| Original SHA-256 | 90b3ea104552ae5f5a42c6f5f6e47a9387ed9c973fef9a762f7c3ed34625d672 |
| Git blob SHA | ec86ec0e7c7a43ac314a1376e583e96625b2dcc4 |
| Reading-copy SHA-256 | 90b3ea104552ae5f5a42c6f5f6e47a9387ed9c973fef9a762f7c3ed34625d672 |
Recovered source transcript
Download complete source text ↓173 lines · 6.0 KB
// Package icalendar produces RFC 5545 calendar objects.
//
// WHY THIS RATHER THAN A CALENDAR INTEGRATION. Integrating with Google Calendar and Microsoft Graph means two
// vendor specific OAuth applications, two consent screens, two credential sets to hold, and a permanent
// dependency on two APIs. That cuts directly against the both clouds no lock in mandate, and it does not
// help a candidate whose calendar is neither. An iCalendar REQUEST attachment is the interoperable standard
// that every one of those calendars already accepts, needs no credentials at all, and is a few hundred lines
// of text generation with no dependency.
//
// EVERY VALUE IS ESCAPED AND EVERY LINE IS FOLDED. Both matter more than they look. An unescaped comma or
// semicolon in a summary silently truncates the field in the recipient's calendar; an unfolded line over 75
// octets is a protocol violation that some clients reject outright and others silently mangle. A calendar
// invite that arrives corrupted is worse than none, because the recruiter believes it was sent.
package icalendar
import (
"fmt"
"strings"
"time"
)
// Event is one meeting.
type Event struct {
// UID must be globally unique and STABLE across updates to the same meeting, because that is how a
// calendar client knows an update is an update rather than a second meeting.
UID string
Summary string
Description string
Location string
Start time.Time
End time.Time
// Organizer is who the invite comes from, and Attendee is who it goes to. Both are bare addresses.
OrganizerName string
OrganizerEmail string
AttendeeName string
AttendeeEmail string
// Sequence increments on each revision of the same UID. A client ignores an update whose sequence is not
// higher than the one it already has, so a reschedule that forgets to increment simply does not appear.
Sequence int
// Cancelled produces a CANCEL rather than a REQUEST, which is how a calendar removes the entry instead
// of leaving a meeting nobody is attending.
Cancelled bool
}
// Render produces the calendar object. The output is CRLF terminated, as the specification requires: LF only
// is the single most common reason an otherwise correct invite is rejected.
func Render(e Event) string {
method := "REQUEST"
status := "CONFIRMED"
if e.Cancelled {
method, status = "CANCEL", "CANCELLED"
}
var b strings.Builder
w := func(s string) { b.WriteString(fold(s)); b.WriteString("\r\n") }
w("BEGIN:VCALENDAR")
w("VERSION:2.0")
w("PRODID:-//missionMATCH//Interview Scheduling//EN")
w("CALSCALE:GREGORIAN")
w("METHOD:" + method)
w("BEGIN:VEVENT")
w("UID:" + escape(e.UID))
w("SEQUENCE:" + fmt.Sprint(e.Sequence))
// DTSTAMP is when this OBJECT was produced, distinct from when the meeting starts. Clients use it to
// order revisions that share a sequence number.
w("DTSTAMP:" + stamp(time.Now()))
w("DTSTART:" + stamp(e.Start))
w("DTEND:" + stamp(e.End))
w("SUMMARY:" + escape(e.Summary))
if e.Description != "" {
w("DESCRIPTION:" + escape(e.Description))
}
if e.Location != "" {
w("LOCATION:" + escape(e.Location))
}
if e.OrganizerEmail != "" {
w(fmt.Sprintf("ORGANIZER;CN=%s:mailto:%s", escapeParam(e.OrganizerName), e.OrganizerEmail))
}
if e.AttendeeEmail != "" {
// PARTSTAT NEEDS-ACTION and RSVP TRUE are what make a calendar client show the accept and decline
// buttons. Without them the entry appears with no way to respond, and the organiser never learns
// whether the candidate is coming.
w(fmt.Sprintf(
"ATTENDEE;CN=%s;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:%s",
escapeParam(e.AttendeeName), e.AttendeeEmail))
}
w("STATUS:" + status)
if !e.Cancelled {
// A reminder fifteen minutes before. Calendars that support it show it; those that do not ignore the
// block, so it costs nothing to include.
w("BEGIN:VALARM")
w("TRIGGER:-PT15M")
w("ACTION:DISPLAY")
w("DESCRIPTION:" + escape(e.Summary))
w("END:VALARM")
}
w("END:VEVENT")
w("END:VCALENDAR")
return b.String()
}
// stamp formats a time as UTC in the basic format the specification requires. ALWAYS UTC, with the trailing
// Z, so no recipient has to agree with us about a time zone database. A meeting rendered in local time
// without a VTIMEZONE block is the other classic way an invite lands an hour out.
func stamp(t time.Time) string {
return t.UTC().Format("20060102T150405Z")
}
// escape handles the four characters RFC 5545 reserves in a text value. An unescaped comma or semicolon
// silently truncates the field in the recipient's calendar, which is how a location becomes "Building 4"
// instead of "Building 4, third floor, ask for Dana".
func escape(s string) string {
r := strings.NewReplacer(
"\\", "\\\\",
";", "\\;",
",", "\\,",
"\r\n", "\\n",
"\n", "\\n",
"\r", "\\n",
)
return r.Replace(s)
}
// escapeParam handles a parameter value, where the rules differ: a quote cannot be escaped at all, so it is
// removed, and a value containing a comma, semicolon or colon must be quoted.
func escapeParam(s string) string {
s = strings.Map(func(ch rune) rune {
if ch == '"' || ch < 0x20 {
return -1
}
return ch
}, s)
if strings.ContainsAny(s, ",;:") {
return `"` + s + `"`
}
return s
}
// fold wraps a content line at 75 octets, continuing with a single leading space, as the specification
// requires. It counts BYTES rather than runes and never splits a multi byte character, because a fold in the
// middle of a UTF-8 sequence produces a line no client can decode.
func fold(line string) string {
const limit = 75
if len(line) <= limit {
return line
}
var b strings.Builder
b.Grow(len(line) + len(line)/limit*3)
i := 0
for i < len(line) {
end := i + limit
if i > 0 {
// A continuation line carries a leading space, which counts toward the limit.
end = i + limit - 1
}
if end >= len(line) {
end = len(line)
} else {
// Walk back off a continuation byte so a multi byte character is never split.
for end > i && line[end]&0xC0 == 0x80 {
end--
}
}
if i > 0 {
b.WriteString("\r\n ")
}
b.WriteString(line[i:end])
i = end
}
return b.String()
}