package nextcloud
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type Calendar struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Color string `json:"color"`
Path string `json:"path"`
}
type Event struct {
UID string `json:"uid"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start string `json:"start"`
End string `json:"end"`
AllDay bool `json:"all_day"`
Path string `json:"path,omitempty"`
ETag string `json:"etag,omitempty"`
Organizer string `json:"organizer,omitempty"`
Attendees []EventAttendee `json:"attendees,omitempty"`
MeetURL string `json:"meet_url,omitempty"`
Color string `json:"color,omitempty"`
Sequence int `json:"sequence,omitempty"`
RRule string `json:"rrule,omitempty"`
ExDates []string `json:"exdates,omitempty"`
RawICS string `json:"raw_ics,omitempty"`
}
type EventAttendee struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"`
Role string `json:"role,omitempty"`
}
var (
ErrETagMismatch = errors.New("nextcloud calendar etag mismatch")
ErrAttendeeNotFound = errors.New("nextcloud calendar attendee not found")
)
type FreeBusyRequest struct {
Start time.Time
End time.Time
Attendees []string
}
type FreeBusyPeriod struct {
Start string `json:"start"`
End string `json:"end"`
Type string `json:"type,omitempty"`
}
type AttendeeFreeBusy struct {
Email string `json:"email"`
Periods []FreeBusyPeriod `json:"periods"`
}
type FreeBusyResponse struct {
Attendees []AttendeeFreeBusy `json:"attendees"`
}
func (c *Client) ListCalendars(ctx context.Context, userID string) ([]Calendar, error) {
path := fmt.Sprintf("/remote.php/dav/calendars/%s/", userID)
body := `
`
resp, err := c.DoAsUser(ctx, "PROPFIND", path, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseCalendarList(resp.Body, path)
}
// CreateCalendar provisions a new calendar collection via MKCALENDAR.
func (c *Client) CreateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
var props strings.Builder
props.WriteString(fmt.Sprintf("%s", xmlEscape(displayName)))
if strings.TrimSpace(color) != "" {
props.WriteString(fmt.Sprintf("%s", xmlEscape(color)))
}
body := fmt.Sprintf(`
%s
`, props.String())
resp, err := c.DoAsUser(ctx, "MKCALENDAR", path, strings.NewReader(body), userID, map[string]string{
"Content-Type": "application/xml",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return fmt.Errorf("create calendar failed: %d", resp.StatusCode)
}
return nil
}
// UpdateCalendar patches display name and/or color via PROPPATCH.
func (c *Client) UpdateCalendar(ctx context.Context, userID, calID, displayName, color string) error {
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
var props strings.Builder
if strings.TrimSpace(displayName) != "" {
props.WriteString(fmt.Sprintf("%s", xmlEscape(displayName)))
}
if strings.TrimSpace(color) != "" {
props.WriteString(fmt.Sprintf("%s", xmlEscape(color)))
}
if props.Len() == 0 {
return nil
}
body := fmt.Sprintf(`
%s
`, props.String())
resp, err := c.DoAsUser(ctx, "PROPPATCH", path, strings.NewReader(body), userID, map[string]string{
"Content-Type": "application/xml",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return fmt.Errorf("update calendar failed: %d", resp.StatusCode)
}
return nil
}
// DeleteCalendar removes a calendar collection and all of its events.
func (c *Client) DeleteCalendar(ctx context.Context, userID, calID string) error {
path := fmt.Sprintf("/remote.php/dav/calendars/%s/%s/", userID, calID)
resp, err := c.DoAsUser(ctx, "DELETE", path, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("delete calendar failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) ListEvents(ctx context.Context, userID, calendarPath string, from, to time.Time) ([]Event, error) {
body := fmt.Sprintf(`
`, from.Format("20060102T150405Z"), to.Format("20060102T150405Z"))
resp, err := c.DoAsUser(ctx, "REPORT", calendarPath, strings.NewReader(body), userID, map[string]string{
"Depth": "1",
"Content-Type": "application/xml",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
return parseEventList(resp.Body)
}
func (c *Client) CreateEvent(ctx context.Context, userID, calendarPath string, event *Event) error {
ics := buildICS(event)
uid := event.UID
if uid == "" {
uid = fmt.Sprintf("%d@ulti", time.Now().UnixNano())
}
eventPath := fmt.Sprintf("%s%s.ics", calendarPath, uid)
resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 201 && resp.StatusCode != 204 {
return fmt.Errorf("create event failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) GetEvent(ctx context.Context, userID, eventPath string) (*Event, error) {
eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "GET", eventPath, nil, userID, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get event failed: %d", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
event := parseICS(string(raw))
event.RawICS = string(raw)
event.Path = eventPath
event.ETag = strings.TrimSpace(resp.Header.Get("ETag"))
return &event, nil
}
func uidFromEventPath(eventPath string) string {
eventPath = strings.TrimSuffix(strings.TrimSpace(eventPath), "/")
if idx := strings.LastIndex(eventPath, "/"); idx >= 0 {
eventPath = eventPath[idx+1:]
}
return strings.TrimSuffix(eventPath, ".ics")
}
// MergeEvent overlays patch onto existing. Patch wins for editable fields; UID and
// exdates fall back to existing when absent so CalDAV PUT keeps a valid master.
func MergeEvent(existing, patch *Event) *Event {
if existing == nil {
return patch
}
if patch == nil {
out := *existing
return &out
}
merged := *existing
merged.Summary = patch.Summary
merged.Description = patch.Description
merged.Location = patch.Location
if strings.TrimSpace(patch.Start) != "" {
merged.Start = patch.Start
merged.AllDay = patch.AllDay
}
if strings.TrimSpace(patch.End) != "" {
merged.End = patch.End
}
if strings.TrimSpace(patch.UID) != "" {
merged.UID = patch.UID
}
if len(patch.Attendees) > 0 {
merged.Attendees = patch.Attendees
}
if strings.TrimSpace(patch.Organizer) != "" {
merged.Organizer = patch.Organizer
}
if strings.TrimSpace(patch.MeetURL) != "" {
merged.MeetURL = patch.MeetURL
}
if strings.TrimSpace(patch.Color) != "" {
merged.Color = patch.Color
}
if strings.TrimSpace(patch.RRule) != "" {
merged.RRule = patch.RRule
}
if patch.ExDates != nil {
merged.ExDates = patch.ExDates
}
if strings.TrimSpace(merged.UID) == "" {
merged.UID = uidFromEventPath(existing.Path)
}
return &merged
}
func (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) {
eventPath = normalizeDAVHref(eventPath)
if strings.TrimSpace(event.UID) == "" {
event.UID = uidFromEventPath(eventPath)
}
ics := buildICS(event)
headers := map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
}
if strings.TrimSpace(ifMatch) != "" {
headers["If-Match"] = strings.TrimSpace(ifMatch)
}
resp, err := c.DoAsUser(ctx, "PUT", eventPath, strings.NewReader(ics), userID, headers)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusPreconditionFailed {
return "", ErrETagMismatch
}
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("update event failed: %d", resp.StatusCode)
}
return strings.TrimSpace(resp.Header.Get("ETag")), nil
}
func (c *Client) DeleteEvent(ctx context.Context, userID, eventPath string) error {
eventPath = normalizeDAVHref(eventPath)
resp, err := c.DoAsUser(ctx, "DELETE", eventPath, nil, userID, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 204 {
return fmt.Errorf("delete event failed: %d", resp.StatusCode)
}
return nil
}
func (c *Client) FreeBusy(ctx context.Context, userID string, req *FreeBusyRequest) (*FreeBusyResponse, error) {
path := fmt.Sprintf("/remote.php/dav/calendars/%s/outbox/", userID)
body := buildFreeBusyRequestICS(userID, req)
resp, err := c.DoAsUser(ctx, "POST", path, strings.NewReader(body), userID, map[string]string{
"Content-Type": "text/calendar; charset=utf-8",
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMultiStatus {
return nil, fmt.Errorf("free/busy request failed: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return parseFreeBusyResponse(raw)
}
func buildFreeBusyRequestICS(userID string, req *FreeBusyRequest) string {
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//Ulti Suite//EN\r\n")
b.WriteString("METHOD:REQUEST\r\n")
b.WriteString("BEGIN:VFREEBUSY\r\n")
b.WriteString(fmt.Sprintf("UID:fb-%d@ulti\r\n", time.Now().UnixNano()))
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", userID))
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", req.Start.UTC().Format("20060102T150405Z")))
b.WriteString(fmt.Sprintf("DTEND:%s\r\n", req.End.UTC().Format("20060102T150405Z")))
for _, email := range req.Attendees {
addr := strings.TrimSpace(email)
if !strings.HasPrefix(strings.ToLower(addr), "mailto:") {
addr = "mailto:" + addr
}
b.WriteString(fmt.Sprintf("ATTENDEE:%s\r\n", addr))
}
b.WriteString("END:VFREEBUSY\r\n")
b.WriteString("END:VCALENDAR\r\n")
return b.String()
}
func parseFreeBusyResponse(raw []byte) (*FreeBusyResponse, error) {
text := strings.TrimSpace(string(raw))
if text == "" {
return &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}}, nil
}
if strings.HasPrefix(text, " 0 {
attendees := make([]AttendeeFreeBusy, 0, len(ms.Responses))
for _, r := range ms.Responses {
if r.Propstat.Prop.CalendarData != "" {
parsed := parseFreeBusyICS(r.Propstat.Prop.CalendarData)
attendees = append(attendees, parsed.Attendees...)
}
}
if len(attendees) > 0 {
return &FreeBusyResponse{Attendees: attendees}, nil
}
}
}
return parseFreeBusyICS(text), nil
}
func parseFreeBusyICS(ics string) *FreeBusyResponse {
resp := &FreeBusyResponse{Attendees: []AttendeeFreeBusy{}}
inFreeBusy := false
curIdx := -1
for _, line := range strings.Split(ics, "\n") {
line = strings.TrimSpace(strings.TrimSuffix(line, "\r"))
switch {
case line == "BEGIN:VFREEBUSY":
inFreeBusy = true
curIdx = -1
case line == "END:VFREEBUSY":
inFreeBusy = false
curIdx = -1
case inFreeBusy && strings.HasPrefix(line, "ATTENDEE"):
resp.Attendees = append(resp.Attendees, AttendeeFreeBusy{
Email: extractMailto(extractValue(line)),
})
curIdx = len(resp.Attendees) - 1
case inFreeBusy && strings.HasPrefix(line, "FREEBUSY") && curIdx >= 0:
period := parseFreeBusyLine(line)
resp.Attendees[curIdx].Periods = append(resp.Attendees[curIdx].Periods, period)
}
}
return resp
}
func parseFreeBusyLine(line string) FreeBusyPeriod {
period := FreeBusyPeriod{Type: "BUSY"}
value := extractValue(line)
if idx := strings.Index(value, "/"); idx >= 0 {
period.Start = value[:idx]
period.End = value[idx+1:]
}
if paramPart, _, ok := strings.Cut(line, ":"); ok {
for _, param := range strings.Split(paramPart, ";")[1:] {
if strings.HasPrefix(strings.ToUpper(param), "FBTYPE=") {
period.Type = strings.TrimPrefix(strings.ToUpper(param), "FBTYPE=")
}
}
}
return period
}
func extractMailto(value string) string {
value = strings.TrimSpace(value)
if idx := strings.Index(strings.ToLower(value), "mailto:"); idx >= 0 {
return value[idx+7:]
}
return value
}
// escapeICSText escapes TEXT property values per RFC 5545 §3.3.11.
func escapeICSText(value string) string {
value = strings.ReplaceAll(value, "\\", "\\\\")
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
value = strings.ReplaceAll(value, "\n", "\\n")
value = strings.ReplaceAll(value, ";", "\\;")
value = strings.ReplaceAll(value, ",", "\\,")
return value
}
func unescapeICSText(value string) string {
var b strings.Builder
b.Grow(len(value))
for i := 0; i < len(value); i++ {
if value[i] == '\\' && i+1 < len(value) {
switch value[i+1] {
case 'n', 'N':
b.WriteByte('\n')
i++
continue
case '\\', ';', ',':
b.WriteByte(value[i+1])
i++
continue
}
}
b.WriteByte(value[i])
}
return b.String()
}
func isDateOnlyValue(value string) bool {
if len(value) != 8 {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func writeDateProp(b *strings.Builder, name, value string, allDay bool) {
value = strings.TrimSpace(value)
if allDay || isDateOnlyValue(value) {
fmt.Fprintf(b, "%s;VALUE=DATE:%s\r\n", name, value)
return
}
fmt.Fprintf(b, "%s:%s\r\n", name, value)
}
func buildICS(event *Event) string {
var b strings.Builder
b.WriteString("BEGIN:VCALENDAR\r\n")
b.WriteString("VERSION:2.0\r\n")
b.WriteString("PRODID:-//Ulti Suite//EN\r\n")
b.WriteString("BEGIN:VEVENT\r\n")
if event.UID != "" {
b.WriteString(fmt.Sprintf("UID:%s\r\n", event.UID))
}
b.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICSText(event.Summary)))
if event.Description != "" {
b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICSText(event.Description)))
}
if event.Location != "" {
b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICSText(event.Location)))
}
if event.Organizer != "" {
b.WriteString(fmt.Sprintf("ORGANIZER:mailto:%s\r\n", event.Organizer))
}
for _, attendee := range event.Attendees {
email := strings.TrimSpace(attendee.Email)
if email == "" {
continue
}
status := normalizeAttendeeStatus(attendee.Status)
role := strings.ToUpper(strings.TrimSpace(attendee.Role))
if role == "" {
role = "REQ-PARTICIPANT"
}
line := fmt.Sprintf("ATTENDEE;PARTSTAT=%s;ROLE=%s", status, role)
if strings.TrimSpace(attendee.Name) != "" {
line += fmt.Sprintf(";CN=\"%s\"", strings.TrimSpace(attendee.Name))
}
line += fmt.Sprintf(":mailto:%s\r\n", email)
b.WriteString(line)
}
if strings.TrimSpace(event.MeetURL) != "" {
b.WriteString(fmt.Sprintf("URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
b.WriteString(fmt.Sprintf("X-ULTI-MEET-URL:%s\r\n", strings.TrimSpace(event.MeetURL)))
}
if strings.TrimSpace(event.RRule) != "" {
b.WriteString(fmt.Sprintf("RRULE:%s\r\n", strings.TrimSpace(event.RRule)))
}
for _, exDate := range event.ExDates {
exDate = strings.TrimSpace(exDate)
if exDate == "" {
continue
}
if isDateOnlyValue(exDate) {
b.WriteString(fmt.Sprintf("EXDATE;VALUE=DATE:%s\r\n", exDate))
} else {
b.WriteString(fmt.Sprintf("EXDATE:%s\r\n", exDate))
}
}
writeDateProp(&b, "DTSTART", event.Start, event.AllDay)
writeDateProp(&b, "DTEND", event.End, event.AllDay)
if event.Sequence > 0 {
b.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", event.Sequence))
}
b.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", time.Now().UTC().Format("20060102T150405Z")))
b.WriteString("END:VEVENT\r\n")
b.WriteString("END:VCALENDAR\r\n")
return b.String()
}
func parseCalendarList(body io.Reader, basePath string) ([]Calendar, error) {
var ms multistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
basePath = normalizeDAVHref(basePath)
calendars := make([]Calendar, 0)
for _, r := range ms.Responses {
href := normalizeDAVHref(r.Href)
if href == basePath {
continue
}
name := r.Propstat.Prop.DisplayName
if name == "" {
continue
}
calendars = append(calendars, Calendar{
ID: strings.TrimSuffix(strings.TrimPrefix(href, basePath), "/"),
DisplayName: name,
Color: r.Propstat.Prop.CalendarColor,
Path: href,
})
}
return calendars, nil
}
func parseEventList(body io.Reader) ([]Event, error) {
var ms calMultistatus
if err := xml.NewDecoder(body).Decode(&ms); err != nil {
return nil, err
}
events := make([]Event, 0, len(ms.Responses))
for _, r := range ms.Responses {
ics := r.Propstat.Prop.CalendarData
event := parseICS(ics)
event.RawICS = ics
event.Path = normalizeDAVHref(r.Href)
event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag)
events = append(events, event)
}
return events, nil
}
// unfoldICSLines splits raw ICS into logical lines, joining folded
// continuation lines (RFC 5545 §3.1: lines starting with space or tab).
func unfoldICSLines(ics string) []string {
raw := strings.Split(strings.ReplaceAll(ics, "\r\n", "\n"), "\n")
lines := make([]string, 0, len(raw))
for _, l := range raw {
if (strings.HasPrefix(l, " ") || strings.HasPrefix(l, "\t")) && len(lines) > 0 {
lines[len(lines)-1] += strings.TrimLeft(l, " \t")
continue
}
l = strings.TrimRight(l, "\r")
if l != "" {
lines = append(lines, l)
}
}
return lines
}
// parsePropLine splits "NAME;PARAM=V;PARAM2=V2:value" into name, params, value.
func parsePropLine(line string) (name string, params map[string]string, value string) {
head, val, ok := strings.Cut(line, ":")
if !ok {
return strings.ToUpper(line), nil, ""
}
parts := strings.Split(head, ";")
name = strings.ToUpper(strings.TrimSpace(parts[0]))
if len(parts) > 1 {
params = make(map[string]string, len(parts)-1)
for _, p := range parts[1:] {
k, v, found := strings.Cut(p, "=")
if !found {
continue
}
params[strings.ToUpper(strings.TrimSpace(k))] = strings.Trim(strings.TrimSpace(v), "\"")
}
}
return name, params, val
}
// normalizeICSDate converts a DTSTART/DTEND value to either a date-only value
// (YYYYMMDD) or a UTC datetime (YYYYMMDDTHHMMSSZ) when a TZID is provided.
// Floating times without TZID are returned unchanged.
func normalizeICSDate(value string, params map[string]string) (normalized string, allDay bool) {
value = strings.TrimSpace(value)
if params["VALUE"] == "DATE" || isDateOnlyValue(value) {
return value, true
}
if strings.HasSuffix(value, "Z") {
return value, false
}
if tzid := params["TZID"]; tzid != "" {
if loc, err := time.LoadLocation(tzid); err == nil {
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
return t.UTC().Format("20060102T150405Z"), false
}
}
}
return value, false
}
// extractVEventBlock returns the logical lines of the primary VEVENT in an ICS
// payload: the master component (no RECURRENCE-ID) when present, else the first.
func extractVEventBlock(lines []string) []string {
var blocks [][]string
var current []string
depth := 0
for _, line := range lines {
upper := strings.ToUpper(line)
switch {
case upper == "BEGIN:VEVENT":
depth++
current = nil
case upper == "END:VEVENT":
if depth > 0 {
blocks = append(blocks, current)
depth = 0
}
case depth > 0:
current = append(current, line)
}
}
if len(blocks) == 0 {
return nil
}
for _, b := range blocks {
master := true
for _, line := range b {
if strings.HasPrefix(strings.ToUpper(line), "RECURRENCE-ID") {
master = false
break
}
}
if master {
return b
}
}
return blocks[0]
}
func parseICS(ics string) Event {
var e Event
block := extractVEventBlock(unfoldICSLines(ics))
for _, line := range block {
name, params, value := parsePropLine(line)
switch name {
case "UID":
e.UID = value
case "SUMMARY":
e.Summary = unescapeICSText(value)
case "DESCRIPTION":
e.Description = unescapeICSText(value)
case "LOCATION":
e.Location = unescapeICSText(value)
case "ORGANIZER":
e.Organizer = extractMailto(value)
case "ATTENDEE":
e.Attendees = append(e.Attendees, parseAttendeeLine(line))
case "X-ULTI-MEET-URL":
e.MeetURL = value
case "URL":
if e.MeetURL == "" {
e.MeetURL = value
}
case "COLOR":
e.Color = value
case "RRULE":
e.RRule = value
case "SEQUENCE":
if seq, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
e.Sequence = seq
}
case "EXDATE":
for _, ex := range strings.Split(value, ",") {
normalized, _ := normalizeICSDate(ex, params)
if normalized != "" {
e.ExDates = append(e.ExDates, normalized)
}
}
case "DTSTART":
e.Start, e.AllDay = normalizeICSDate(value, params)
case "DTEND":
e.End, _ = normalizeICSDate(value, params)
}
}
return e
}
func extractValue(line string) string {
if idx := strings.LastIndex(line, ":"); idx >= 0 {
return line[idx+1:]
}
return line
}
func parseAttendeeLine(line string) EventAttendee {
attendee := EventAttendee{Status: "NEEDS-ACTION"}
paramPart, value, ok := strings.Cut(line, ":")
if !ok {
return attendee
}
attendee.Email = extractMailto(value)
parts := strings.Split(paramPart, ";")
for _, p := range parts[1:] {
key, val, found := strings.Cut(p, "=")
if !found {
continue
}
switch strings.ToUpper(strings.TrimSpace(key)) {
case "PARTSTAT":
attendee.Status = strings.ToUpper(strings.TrimSpace(val))
case "CN":
attendee.Name = strings.Trim(strings.TrimSpace(val), "\"")
case "ROLE":
attendee.Role = strings.ToUpper(strings.TrimSpace(val))
}
}
return attendee
}
func normalizeAttendeeStatus(status string) string {
status = strings.ToUpper(strings.TrimSpace(status))
switch status {
case "ACCEPTED", "DECLINED", "TENTATIVE", "NEEDS-ACTION":
return status
default:
return "NEEDS-ACTION"
}
}
type calMultistatus struct {
XMLName xml.Name `xml:"multistatus"`
Responses []calResponse `xml:"response"`
}
type calResponse struct {
Href string `xml:"href"`
Propstat calPropstat `xml:"propstat"`
}
type calPropstat struct {
Prop calProp `xml:"prop"`
}
type calProp struct {
ETag string `xml:"getetag"`
CalendarData string `xml:"calendar-data"`
}