ultisuite-backend/internal/nextcloud/calendar.go

227 lines
6.0 KiB
Go

package nextcloud
import (
"context"
"encoding/xml"
"fmt"
"io"
"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"`
RawICS string `json:"raw_ics,omitempty"`
}
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) 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 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))
}
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
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, "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
}
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"`
}