package nextcloud import ( "context" "encoding/xml" "errors" "fmt" "io" "net/http" "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"` 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) } 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) { 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 (c *Client) UpdateEvent(ctx context.Context, userID, eventPath, ifMatch string, event *Event) (string, error) { 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 { 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 } 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", event.Summary)) if event.Description != "" { b.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", event.Description)) } if event.Location != "" { b.WriteString(fmt.Sprintf("LOCATION:%s\r\n", 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))) } b.WriteString(fmt.Sprintf("DTSTART:%s\r\n", event.Start)) b.WriteString(fmt.Sprintf("DTEND:%s\r\n", event.End)) 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 } calendars := make([]Calendar, 0) for _, r := range ms.Responses { if r.Href == basePath { continue } name := r.Propstat.Prop.DisplayName if name == "" { continue } calendars = append(calendars, Calendar{ ID: strings.TrimSuffix(strings.TrimPrefix(r.Href, basePath), "/"), DisplayName: name, Color: r.Propstat.Prop.CalendarColor, Path: r.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 = r.Href event.ETag = strings.TrimSpace(r.Propstat.Prop.ETag) events = append(events, event) } return events, nil } func parseICS(ics string) Event { var e Event for _, line := range strings.Split(ics, "\n") { line = strings.TrimSpace(line) switch { case strings.HasPrefix(line, "UID:"): e.UID = strings.TrimPrefix(line, "UID:") case strings.HasPrefix(line, "SUMMARY:"): e.Summary = strings.TrimPrefix(line, "SUMMARY:") case strings.HasPrefix(line, "DESCRIPTION:"): e.Description = strings.TrimPrefix(line, "DESCRIPTION:") case strings.HasPrefix(line, "LOCATION:"): e.Location = strings.TrimPrefix(line, "LOCATION:") case strings.HasPrefix(line, "ORGANIZER"): e.Organizer = extractMailto(extractValue(line)) case strings.HasPrefix(line, "ATTENDEE"): e.Attendees = append(e.Attendees, parseAttendeeLine(line)) case strings.HasPrefix(line, "X-ULTI-MEET-URL:"): e.MeetURL = strings.TrimPrefix(line, "X-ULTI-MEET-URL:") case strings.HasPrefix(line, "URL:") && e.MeetURL == "": e.MeetURL = strings.TrimPrefix(line, "URL:") case strings.HasPrefix(line, "DTSTART"): e.Start = extractValue(line) case strings.HasPrefix(line, "DTEND"): e.End = extractValue(line) } } 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"` }