mirror of
https://github.com/1f349/go-webdav.git
synced 2024-12-22 08:14:15 +00:00
caldav: implement filter function for queries
This is not yet complete (see TODOs in code), but basic filtering of a list of CaledarObjects works. Includes test data from the RFC, which allows to use the RFCs examples as test cases.
This commit is contained in:
parent
06ecb0e64c
commit
13fa812f94
198
caldav/match.go
Normal file
198
caldav/match.go
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
package caldav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter returns the filtered list of calendar objects matching the provided query.
|
||||||
|
// A nil query will return the full list of calendar objects.
|
||||||
|
func Filter(query *CalendarQuery, cos []CalendarObject) ([]CalendarObject, error) {
|
||||||
|
if query == nil {
|
||||||
|
// FIXME: should we always return a copy of the provided slice?
|
||||||
|
return cos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []CalendarObject
|
||||||
|
for _, co := range cos {
|
||||||
|
ok, err := Match(query.CompFilter, &co)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO properties are not currently filtered even if requested
|
||||||
|
out = append(out, co)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match reports whether the provided CalendarObject matches the query.
|
||||||
|
func Match(query CompFilter, co *CalendarObject) (matched bool, err error) {
|
||||||
|
if co.Data == nil || co.Data.Component == nil {
|
||||||
|
panic("request to process empty calendar object")
|
||||||
|
}
|
||||||
|
return match(query, co.Data.Component)
|
||||||
|
}
|
||||||
|
|
||||||
|
func match(filter CompFilter, comp *ical.Component) (bool, error) {
|
||||||
|
if comp.Name != filter.Name {
|
||||||
|
return filter.IsNotDefined, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDate time.Time
|
||||||
|
if filter.Start != zeroDate {
|
||||||
|
match, err := matchCompTimeRange(filter.Start, filter.End, comp)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, compFilter := range filter.Comps {
|
||||||
|
match, err := matchCompFilter(compFilter, comp)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, propFilter := range filter.Props {
|
||||||
|
match, err := matchPropFilter(propFilter, comp)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchCompFilter(filter CompFilter, comp *ical.Component) (bool, error) {
|
||||||
|
var matches []*ical.Component
|
||||||
|
|
||||||
|
for _, child := range comp.Children {
|
||||||
|
match, err := match(filter, child)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if match {
|
||||||
|
matches = append(matches, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return filter.IsNotDefined, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPropFilter(filter PropFilter, comp *ical.Component) (bool, error) {
|
||||||
|
// TODO: this only matches first field, there can be multiple
|
||||||
|
field := comp.Props.Get(filter.Name)
|
||||||
|
if field == nil {
|
||||||
|
return filter.IsNotDefined, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, paramFilter := range filter.ParamFilter {
|
||||||
|
if !matchParamFilter(paramFilter, field) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var zeroDate time.Time
|
||||||
|
if filter.Start != zeroDate {
|
||||||
|
match, err := matchPropTimeRange(filter.Start, filter.End, field)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
} else if filter.TextMatch != nil {
|
||||||
|
if !matchTextMatch(*filter.TextMatch, field.Value) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// empty prop-filter, property exists
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchCompTimeRange(start, end time.Time, comp *ical.Component) (bool, error) {
|
||||||
|
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
|
||||||
|
|
||||||
|
// TODO handle "infinity" values in query
|
||||||
|
// TODO handle recurring events
|
||||||
|
|
||||||
|
if comp.Name != ical.CompEvent {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
event := ical.Event{comp}
|
||||||
|
|
||||||
|
eventStart, err := event.DateTimeStart(start.Location())
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
eventEnd, err := event.DateTimeEnd(end.Location())
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event starts in time range
|
||||||
|
if eventStart.After(start) && eventStart.Before(end) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Event ends in time range
|
||||||
|
if eventEnd.After(start) && eventEnd.Before(end) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Event covers entire time range plus some
|
||||||
|
if eventStart.Before(start) && eventEnd.After(end) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPropTimeRange(start, end time.Time, field *ical.Prop) (bool, error) {
|
||||||
|
// See https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
|
||||||
|
|
||||||
|
// TODO handle "infinity" values in query
|
||||||
|
|
||||||
|
ptime, err := field.DateTime(start.Location())
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if ptime.After(start) && ptime.Before(end) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchParamFilter(filter ParamFilter, field *ical.Prop) bool {
|
||||||
|
// TODO there can be multiple values
|
||||||
|
value := field.Params.Get(filter.Name)
|
||||||
|
if value == "" {
|
||||||
|
return filter.IsNotDefined
|
||||||
|
} else if filter.IsNotDefined {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if filter.TextMatch != nil {
|
||||||
|
return matchTextMatch(*filter.TextMatch, value)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchTextMatch(txt TextMatch, value string) bool {
|
||||||
|
// TODO: handle text-match collation attribute
|
||||||
|
match := strings.Contains(value, txt.Text)
|
||||||
|
if txt.NegateCondition {
|
||||||
|
match = !match
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
268
caldav/match_test.go
Normal file
268
caldav/match_test.go
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
package caldav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-ical"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dateFormat = "20060102T150405Z"
|
||||||
|
|
||||||
|
func toDate(t *testing.T, date string) time.Time {
|
||||||
|
res, err := time.ParseInLocation(dateFormat, date, time.UTC)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data taken from https://datatracker.ietf.org/doc/html/rfc4791#appendix-B
|
||||||
|
// TODO add missing data
|
||||||
|
func TestFilter(t *testing.T) {
|
||||||
|
newCO := func(str string) CalendarObject {
|
||||||
|
cal, err := ical.NewDecoder(strings.NewReader(str)).Decode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return CalendarObject{
|
||||||
|
Data: cal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event1 := newCO(`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
LAST-MODIFIED:20040110T032845Z
|
||||||
|
TZID:US/Eastern
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:20000404T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
|
||||||
|
TZNAME:EDT
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0400
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:20001026T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
TZNAME:EST
|
||||||
|
TZOFFSETFROM:-0400
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTAMP:20060206T001102Z
|
||||||
|
DTSTART;TZID=US/Eastern:20060102T100000
|
||||||
|
DURATION:PT1H
|
||||||
|
SUMMARY:Event #1
|
||||||
|
Description:Go Steelers!
|
||||||
|
UID:74855313FA803DA593CD579A@example.com
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
event2 := newCO(`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
LAST-MODIFIED:20040110T032845Z
|
||||||
|
TZID:US/Eastern
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:20000404T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
|
||||||
|
TZNAME:EDT
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0400
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:20001026T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
TZNAME:EST
|
||||||
|
TZOFFSETFROM:-0400
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTAMP:20060206T001121Z
|
||||||
|
DTSTART;TZID=US/Eastern:20060102T120000
|
||||||
|
DURATION:PT1H
|
||||||
|
RRULE:FREQ=DAILY;COUNT=5
|
||||||
|
SUMMARY:Event #2
|
||||||
|
UID:00959BC664CA650E933C892C@example.com
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
DTSTAMP:20060206T001121Z
|
||||||
|
DTSTART;TZID=US/Eastern:20060104T140000
|
||||||
|
DURATION:PT1H
|
||||||
|
RECURRENCE-ID;TZID=US/Eastern:20060104T120000
|
||||||
|
SUMMARY:Event #2 bis
|
||||||
|
UID:00959BC664CA650E933C892C@example.com
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
event3 := newCO(`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
LAST-MODIFIED:20040110T032845Z
|
||||||
|
TZID:US/Eastern
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
DTSTART:20000404T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4
|
||||||
|
TZNAME:EDT
|
||||||
|
TZOFFSETFROM:-0500
|
||||||
|
TZOFFSETTO:-0400
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
DTSTART:20001026T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||||
|
TZNAME:EST
|
||||||
|
TZOFFSETFROM:-0400
|
||||||
|
TZOFFSETTO:-0500
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=CHAIR:mailto:cyrus@example.com
|
||||||
|
ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:lisa@example.com
|
||||||
|
DTSTAMP:20060206T001220Z
|
||||||
|
DTSTART;TZID=US/Eastern:20060104T100000
|
||||||
|
DURATION:PT1H
|
||||||
|
LAST-MODIFIED:20060206T001330Z
|
||||||
|
ORGANIZER:mailto:cyrus@example.com
|
||||||
|
SEQUENCE:1
|
||||||
|
STATUS:TENTATIVE
|
||||||
|
SUMMARY:Event #3
|
||||||
|
UID:DC6C50A017428C5216A2F1CD@example.com
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
todo1 := newCO(`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
|
BEGIN:VTODO
|
||||||
|
DTSTAMP:20060205T235335Z
|
||||||
|
DUE;VALUE=DATE:20060104
|
||||||
|
STATUS:NEEDS-ACTION
|
||||||
|
SUMMARY:Task #1
|
||||||
|
UID:DDDEEB7915FA61233B861457@example.com
|
||||||
|
BEGIN:VALARM
|
||||||
|
ACTION:AUDIO
|
||||||
|
TRIGGER;RELATED=START:-PT10M
|
||||||
|
END:VALARM
|
||||||
|
END:VTODO
|
||||||
|
END:VCALENDAR`)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
query *CalendarQuery
|
||||||
|
addrs []CalendarObject
|
||||||
|
want []CalendarObject
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil-query",
|
||||||
|
query: nil,
|
||||||
|
addrs: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
want: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.8
|
||||||
|
name: "events only",
|
||||||
|
query: &CalendarQuery{
|
||||||
|
CompFilter: CompFilter{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []CompFilter{
|
||||||
|
CompFilter{
|
||||||
|
Name: "VEVENT",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addrs: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
want: []CalendarObject{event1, event2, event3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.1
|
||||||
|
name: "events in time range",
|
||||||
|
query: &CalendarQuery{
|
||||||
|
CompFilter: CompFilter{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []CompFilter{
|
||||||
|
CompFilter{
|
||||||
|
Name: "VEVENT",
|
||||||
|
Start: toDate(t, "20060104T000000Z"),
|
||||||
|
End: toDate(t, "20060105T000000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addrs: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
want: []CalendarObject{event2, event3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
|
||||||
|
name: "events by UID",
|
||||||
|
query: &CalendarQuery{
|
||||||
|
CompFilter: CompFilter{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []CompFilter{
|
||||||
|
CompFilter{
|
||||||
|
Name: "VEVENT",
|
||||||
|
Props: []PropFilter{{
|
||||||
|
Name: "UID",
|
||||||
|
TextMatch: &TextMatch{
|
||||||
|
Text: "DC6C50A017428C5216A2F1CD@example.com",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addrs: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
want: []CalendarObject{event3},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc4791#section-7.8.6
|
||||||
|
name: "events by description substring",
|
||||||
|
query: &CalendarQuery{
|
||||||
|
CompFilter: CompFilter{
|
||||||
|
Name: "VCALENDAR",
|
||||||
|
Comps: []CompFilter{
|
||||||
|
CompFilter{
|
||||||
|
Name: "VEVENT",
|
||||||
|
Props: []PropFilter{{
|
||||||
|
Name: "Description",
|
||||||
|
TextMatch: &TextMatch{
|
||||||
|
Text: "Steelers",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addrs: []CalendarObject{event1, event2, event3, todo1},
|
||||||
|
want: []CalendarObject{event1},
|
||||||
|
},
|
||||||
|
// TODO add more examples
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := Filter(tc.query, tc.addrs)
|
||||||
|
switch {
|
||||||
|
case err != nil && tc.err == nil:
|
||||||
|
t.Fatalf("unexpected error: %+v", err)
|
||||||
|
case err != nil && tc.err != nil:
|
||||||
|
if got, want := err.Error(), tc.err.Error(); got != want {
|
||||||
|
t.Fatalf("invalid error:\ngot= %q\nwant=%q", got, want)
|
||||||
|
}
|
||||||
|
case err == nil && tc.err != nil:
|
||||||
|
t.Fatalf("expected an error:\ngot= %+v\nwant=%+v", err, tc.err)
|
||||||
|
case err == nil && tc.err == nil:
|
||||||
|
if got, want := got, tc.want; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("invalid filter values:\ngot= %+v\nwant=%+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user