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"` }