From 13fa812f94c19e869d39b3610b06bfe9f42fda35 Mon Sep 17 00:00:00 2001 From: Conrad Hoffmann Date: Thu, 5 May 2022 14:17:09 +0200 Subject: [PATCH] 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. --- caldav/match.go | 198 ++++++++++++++++++++++++++++++++ caldav/match_test.go | 268 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 caldav/match.go create mode 100644 caldav/match_test.go diff --git a/caldav/match.go b/caldav/match.go new file mode 100644 index 0000000..b881183 --- /dev/null +++ b/caldav/match.go @@ -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 +} diff --git a/caldav/match_test.go b/caldav/match_test.go new file mode 100644 index 0000000..c06743b --- /dev/null +++ b/caldav/match_test.go @@ -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) + } + } + }) + } +}