- Added new endpoints for listing calendars, events, creating/updating/deleting events, and handling free/busy requests. - Implemented ETag/If-Match support for event updates to ensure data integrity. - Introduced functionality for responding to invitations and creating Meet links from events. - Enhanced validation for event creation and updates, including attendee email checks. - Updated README documentation to reflect the new Calendar API features and usage examples. - Revised project checklist to indicate completion of Calendar API enhancements.
501 lines
15 KiB
Go
501 lines
15 KiB
Go
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 := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<d:propfind xmlns:d="DAV:" xmlns:cs="urn:ietf:params:xml:ns:caldav" xmlns:apple="http://apple.com/ns/ical/">
|
|
<d:prop>
|
|
<d:displayname/>
|
|
<apple:calendar-color/>
|
|
<d:resourcetype/>
|
|
</d:prop>
|
|
</d:propfind>`
|
|
|
|
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(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
<d:prop>
|
|
<d:getetag/>
|
|
<c:calendar-data/>
|
|
</d:prop>
|
|
<c:filter>
|
|
<c:comp-filter name="VCALENDAR">
|
|
<c:comp-filter name="VEVENT">
|
|
<c:time-range start="%s" end="%s"/>
|
|
</c:comp-filter>
|
|
</c:comp-filter>
|
|
</c:filter>
|
|
</c:calendar-query>`, 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, "<?xml") || strings.HasPrefix(text, "<") {
|
|
var ms calMultistatus
|
|
if err := xml.Unmarshal(raw, &ms); err == nil && len(ms.Responses) > 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"`
|
|
}
|