From 46d5e862788ebf670809d2dabe7b3db5e3f3d85a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Thomas=20M=C3=BCller?=
<1005065+DeepDiver1975@users.noreply.github.com>
Date: Thu, 22 Feb 2024 21:19:58 +0100
Subject: [PATCH] caldav: add support for more props on MKCOL and PROPFIND
---
caldav/caldav.go | 2 +
caldav/elements.go | 33 ++++-
caldav/server.go | 23 +++-
caldav/server_test.go | 311 +++++++++++++++++++++++++++++++++++++++---
go.mod | 1 +
go.sum | 16 +++
6 files changed, 361 insertions(+), 25 deletions(-)
diff --git a/caldav/caldav.go b/caldav/caldav.go
index 02705ef..21040c9 100644
--- a/caldav/caldav.go
+++ b/caldav/caldav.go
@@ -67,8 +67,10 @@ type Calendar struct {
Path string
Name string
Description string
+ Color string
MaxResourceSize int64
SupportedComponentSet []string
+ Timezone string
}
type CalendarCompRequest struct {
diff --git a/caldav/elements.go b/caldav/elements.go
index 5759f31..cf0c5e8 100644
--- a/caldav/elements.go
+++ b/caldav/elements.go
@@ -21,8 +21,16 @@ var (
calendarQueryName = xml.Name{namespace, "calendar-query"}
calendarMultigetName = xml.Name{namespace, "calendar-multiget"}
- calendarName = xml.Name{namespace, "calendar"}
- calendarDataName = xml.Name{namespace, "calendar-data"}
+ calendarName = xml.Name{namespace, "calendar"}
+ calendarDataName = xml.Name{namespace, "calendar-data"}
+ calendarColorName = xml.Name{
+ Space: "http://apple.com/ns/ical/",
+ Local: "calendar-color",
+ }
+ calendarTimezoneName = xml.Name{
+ Space: namespace,
+ Local: "calendar-timezone",
+ }
)
// https://tools.ietf.org/html/rfc4791#section-6.2.1
@@ -41,6 +49,16 @@ type calendarDescription struct {
Description string `xml:",chardata"`
}
+type calendarColor struct {
+ XMLName xml.Name `xml:"http://apple.com/ns/ical/ calendar-color"`
+ Color string `xml:",chardata"`
+}
+
+type calendarTimezone struct {
+ XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav calendar-timezone"`
+ Timezone string `xml:",chardata"`
+}
+
// https://tools.ietf.org/html/rfc4791#section-5.2.4
type supportedCalendarData struct {
XMLName xml.Name `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-data"`
@@ -230,8 +248,11 @@ func (r *reportReq) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
}
type mkcolReq struct {
- XMLName xml.Name `xml:"DAV: mkcol"`
- ResourceType internal.ResourceType `xml:"set>prop>resourcetype"`
- DisplayName string `xml:"set>prop>displayname"`
- // TODO this could theoretically contain all addressbook properties?
+ XMLName xml.Name `xml:"DAV: mkcol"`
+ ResourceType internal.ResourceType `xml:"set>prop>resourcetype"`
+ DisplayName string `xml:"set>prop>displayname"`
+ Description string `xml:"set>prop>calendar-description"`
+ CalendarColor string `xml:"set>prop>calendar-color"`
+ CalemdarTimeZone string `xml:"set>prop>calendar-timezone"`
+ SupportedCalendarComponentSet supportedCalendarComponentSet `xml:"set>prop>supported-calendar-component-set"`
}
diff --git a/caldav/server.go b/caldav/server.go
index d3e83b8..d92eb37 100644
--- a/caldav/server.go
+++ b/caldav/server.go
@@ -568,6 +568,20 @@ func (b *backend) propFindCalendar(ctx context.Context, propfind *internal.PropF
return &calendarDescription{Description: cal.Description}, nil
}
}
+ if cal.Color != "" {
+ props[calendarColorName] = func(*internal.RawXMLValue) (interface{}, error) {
+ return &calendarColor{
+ Color: cal.Color,
+ }, nil
+ }
+ }
+ if cal.Timezone != "" {
+ props[calendarTimezoneName] = func(*internal.RawXMLValue) (interface{}, error) {
+ return &calendarTimezone{
+ Timezone: cal.Timezone,
+ }, nil
+ }
+ }
if cal.MaxResourceSize > 0 {
props[maxResourceSizeName] = func(*internal.RawXMLValue) (interface{}, error) {
return &maxResourceSize{Size: cal.MaxResourceSize}, nil
@@ -737,7 +751,14 @@ func (b *backend) Mkcol(r *http.Request) error {
return internal.HTTPErrorf(http.StatusBadRequest, "carddav: unexpected resource type")
}
cal.Name = m.DisplayName
- // TODO ...
+ cal.Description = m.Description
+ cal.Color = strings.TrimSpace(m.CalendarColor)
+ cal.Timezone = strings.TrimSpace(m.CalemdarTimeZone)
+
+ cal.SupportedComponentSet = make([]string, len(m.SupportedCalendarComponentSet.Comp))
+ for i, v := range m.SupportedCalendarComponentSet.Comp {
+ cal.SupportedComponentSet[i] = v.Name
+ }
}
return b.Backend.CreateCalendar(r.Context(), &cal)
diff --git a/caldav/server_test.go b/caldav/server_test.go
index 594b87b..6fa0751 100644
--- a/caldav/server_test.go
+++ b/caldav/server_test.go
@@ -2,7 +2,10 @@ package caldav
import (
"context"
+ "encoding/xml"
"fmt"
+ "github.com/emersion/go-webdav/internal"
+ "github.com/stretchr/testify/assert"
"io"
"io/ioutil"
"net/http/httptest"
@@ -22,9 +25,9 @@ var propFindSupportedCalendarComponentRequest = `
`
var testPropFindSupportedCalendarComponentCases = map[*Calendar][]string{
- &Calendar{Path: "/user/calendars/cal"}: []string{"VEVENT"},
- &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VTODO"}}: []string{"VTODO"},
- &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VEVENT", "VTODO"}}: []string{"VEVENT", "VTODO"},
+ &Calendar{Path: "/user/calendars/cal"}: {"VEVENT"},
+ &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VTODO"}}: {"VTODO"},
+ &Calendar{Path: "/user/calendars/cal", SupportedComponentSet: []string{"VEVENT", "VTODO"}}: {"VEVENT", "VTODO"},
}
func TestPropFindSupportedCalendarComponent(t *testing.T) {
@@ -33,7 +36,7 @@ func TestPropFindSupportedCalendarComponent(t *testing.T) {
req.Body = io.NopCloser(strings.NewReader(propFindSupportedCalendarComponentRequest))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
- handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}}
+ handler := Handler{Backend: &testBackend{calendars: []Calendar{*calendar}}}
handler.ServeHTTP(w, req)
res := w.Result()
@@ -52,6 +55,74 @@ func TestPropFindSupportedCalendarComponent(t *testing.T) {
}
}
+var propFindCalendarRequest = `
+
+
+
+
+
+
+
+
+`
+
+func TestPropFindCalendar(t *testing.T) {
+ calendar := Calendar{
+ Path: "/user/calendars/cal",
+ Name: "Test Calendar",
+ Description: "This is a test calendar",
+ Timezone: "BEGIN:VCALENDARfoo",
+ Color: "#DEADBEEF",
+ }
+
+ req := httptest.NewRequest("PROPFIND", calendar.Path, nil)
+ req.Body = io.NopCloser(strings.NewReader(propFindCalendarRequest))
+ req.Header.Set("Content-Type", "application/xml")
+ w := httptest.NewRecorder()
+ handler := Handler{Backend: &testBackend{calendars: []Calendar{calendar}}}
+ handler.ServeHTTP(w, req)
+
+ resp := w.Result()
+
+ var ms internal.MultiStatus
+ err := xml.NewDecoder(resp.Body).Decode(&ms)
+ assert.NoError(t, err)
+ assert.Len(t, ms.Responses, 1)
+ assert.Len(t, ms.Responses[0].PropStats, 1)
+ assert.Equal(t, 200, ms.Responses[0].PropStats[0].Status.Code)
+ assert.Len(t, ms.Responses[0].PropStats[0].Prop.Raw, 4)
+
+ rawDisplayName := ms.Responses[0].PropStats[0].Prop.Get(internal.DisplayNameName)
+ rawCalendarDescription := ms.Responses[0].PropStats[0].Prop.Get(calendarDescriptionName)
+ rawTimezone := ms.Responses[0].PropStats[0].Prop.Get(calendarTimezoneName)
+ rawColor := ms.Responses[0].PropStats[0].Prop.Get(calendarColorName)
+ assert.NotNil(t, rawDisplayName)
+ assert.NotNil(t, rawCalendarDescription)
+ assert.NotNil(t, rawTimezone)
+ assert.NotNil(t, rawColor)
+
+ v0 := internal.DisplayName{}
+ err = rawDisplayName.Decode(&v0)
+ assert.NoError(t, err)
+ assert.Equal(t, calendar.Name, v0.Name)
+
+ v1 := calendarDescription{}
+ err = rawCalendarDescription.Decode(&v1)
+ assert.NoError(t, err)
+ assert.Equal(t, calendar.Description, v1.Description)
+
+ v2 := calendarTimezone{}
+ err = rawTimezone.Decode(&v2)
+ assert.NoError(t, err)
+ assert.Equal(t, calendar.Timezone, v2.Timezone)
+
+ v3 := calendarColor{}
+ err = rawColor.Decode(&v3)
+ assert.NoError(t, err)
+ assert.Equal(t, calendar.Color, v3.Color)
+}
+
var propFindUserPrincipal = `
@@ -68,7 +139,7 @@ func TestPropFindRoot(t *testing.T) {
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
calendar := &Calendar{}
- handler := Handler{Backend: testBackend{calendars: []Calendar{*calendar}}}
+ handler := Handler{Backend: &testBackend{calendars: []Calendar{*calendar}}}
handler.ServeHTTP(w, req)
res := w.Result()
@@ -118,7 +189,7 @@ func TestMultiCalendarBackend(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/user/calendars/", strings.NewReader(propFindUserPrincipal))
req.Header.Set("Content-Type", "application/xml")
w := httptest.NewRecorder()
- handler := Handler{Backend: testBackend{
+ handler := Handler{Backend: &testBackend{
calendars: calendars,
objectMap: map[string][]CalendarObject{
calendarB.Path: []CalendarObject{object},
@@ -177,41 +248,245 @@ func TestMultiCalendarBackend(t *testing.T) {
}
}
+var mkcolRequestData = `
+
+
+
+
+
+
+
+
+ Test calendar
+ A calendar for testing
+ #009688FF
+
+
+
+
+
+
+
+
+
+
+
+`
+
+func TestCreateCalendar(t *testing.T) {
+ tb := testBackend{
+ calendars: nil,
+ objectMap: nil,
+ }
+ b := backend{
+ Backend: &tb,
+ Prefix: "/dav",
+ }
+ req := httptest.NewRequest("MKCOL", "/dav/calendars/user0/test-calendar", strings.NewReader(mkcolRequestData))
+ req.Header.Set("Content-Type", "application/xml")
+
+ err := b.Mkcol(req)
+ assert.NoError(t, err)
+ assert.Len(t, tb.calendars, 1)
+ c := tb.calendars[0]
+ assert.Equal(t, "Test calendar", c.Name)
+ assert.Equal(t, "/dav/calendars/user0/test-calendar", c.Path)
+ assert.Equal(t, "A calendar for testing", c.Description)
+ assert.Equal(t, "#009688FF", c.Color)
+ assert.Equal(t, []string{"VEVENT", "VTODO", "VJOURNAL"}, c.SupportedComponentSet)
+ assert.Contains(t, c.Timezone, "BEGIN:VCALENDAR")
+}
+
+var mkcolRequestDataMinimalBody = `
+
+
+
+
+
+
+
+
+ Test calendar
+
+
+`
+
+func TestCreateCalendarMinimalBody(t *testing.T) {
+ tb := testBackend{
+ calendars: nil,
+ objectMap: nil,
+ }
+ b := backend{
+ Backend: &tb,
+ Prefix: "/dav",
+ }
+ req := httptest.NewRequest("MKCOL", "/dav/calendars/user0/test-calendar", strings.NewReader(mkcolRequestDataMinimalBody))
+ req.Header.Set("Content-Type", "application/xml")
+
+ err := b.Mkcol(req)
+ assert.NoError(t, err)
+ assert.Len(t, tb.calendars, 1)
+ c := tb.calendars[0]
+ assert.Equal(t, "Test calendar", c.Name)
+ assert.Equal(t, "/dav/calendars/user0/test-calendar", c.Path)
+ assert.Equal(t, "", c.Description)
+ assert.Equal(t, "", c.Color)
+ assert.Equal(t, []string{}, c.SupportedComponentSet)
+ assert.Equal(t, "", c.Timezone)
+}
+
type testBackend struct {
calendars []Calendar
objectMap map[string][]CalendarObject
}
-func (t testBackend) CreateCalendar(ctx context.Context, calendar *Calendar) error {
+func (t *testBackend) CreateCalendar(ctx context.Context, calendar *Calendar) error {
+ t.calendars = append(t.calendars, *calendar)
return nil
}
-func (t testBackend) ListCalendars(ctx context.Context) ([]Calendar, error) {
+func (t *testBackend) ListCalendars(ctx context.Context) ([]Calendar, error) {
return t.calendars, nil
}
-func (t testBackend) GetCalendar(ctx context.Context, path string) (*Calendar, error) {
+func (t *testBackend) GetCalendar(ctx context.Context, path string) (*Calendar, error) {
for _, cal := range t.calendars {
if cal.Path == path {
return &cal, nil
}
}
- return nil, fmt.Errorf("Calendar for path: %s not found", path)
+ return nil, fmt.Errorf("calendar for path: %s not found", path)
}
-func (t testBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
+func (t *testBackend) CalendarHomeSetPath(ctx context.Context) (string, error) {
return "/user/calendars/", nil
}
-func (t testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
+func (t *testBackend) CurrentUserPrincipal(ctx context.Context) (string, error) {
return "/user/", nil
}
-func (t testBackend) DeleteCalendarObject(ctx context.Context, path string) error {
+func (t *testBackend) DeleteCalendarObject(ctx context.Context, path string) error {
return nil
}
-func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) {
+func (t *testBackend) GetCalendarObject(ctx context.Context, path string, req *CalendarCompRequest) (*CalendarObject, error) {
for _, objs := range t.objectMap {
for _, obj := range objs {
if obj.Path == path {
@@ -219,17 +494,17 @@ func (t testBackend) GetCalendarObject(ctx context.Context, path string, req *Ca
}
}
}
- return nil, fmt.Errorf("Couldn't find calendar object at: %s", path)
+ return nil, fmt.Errorf("couldn't find calendar object at: %s", path)
}
-func (t testBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) {
+func (t *testBackend) PutCalendarObject(ctx context.Context, path string, calendar *ical.Calendar, opts *PutCalendarObjectOptions) (*CalendarObject, error) {
return nil, nil
}
-func (t testBackend) ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) {
+func (t *testBackend) ListCalendarObjects(ctx context.Context, path string, req *CalendarCompRequest) ([]CalendarObject, error) {
return t.objectMap[path], nil
}
-func (t testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) {
+func (t *testBackend) QueryCalendarObjects(ctx context.Context, path string, query *CalendarQuery) ([]CalendarObject, error) {
return nil, nil
}
diff --git a/go.mod b/go.mod
index 7c5445a..78d380d 100644
--- a/go.mod
+++ b/go.mod
@@ -5,5 +5,6 @@ go 1.13
require (
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
+ github.com/stretchr/testify v1.8.4 // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
)
diff --git a/go.sum b/go.sum
index 472fda6..777af55 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,23 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=