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