diff --git a/README.md b/README.md index 5c4122d..9798ed0 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ A ICS / ICal parser and serialiser for Golang. Because the other libraries didn't quite do what I needed. Usage, parsing: -``` +```golang cal, err := ParseCalendar(strings.NewReader(input)) ``` -Creating: +Usage, parsing from a URL : +```golang + cal, err := ParseCalendar("an-ics-url") ``` + +Creating: +```golang cal := ics.NewCalendar() cal.SetMethod(ics.MethodRequest) event := cal.AddEvent(fmt.Sprintf("id@domain", p.SessionKey.IntID())) diff --git a/calendar.go b/calendar.go index ce4deb7..57bd14f 100644 --- a/calendar.go +++ b/calendar.go @@ -3,9 +3,12 @@ package ics import ( "bufio" "bytes" + "context" "errors" "fmt" "io" + "net/http" + "reflect" "time" ) @@ -61,9 +64,111 @@ const ( ComponentPropertyTzid = ComponentProperty(PropertyTzid) ComponentPropertyComment = ComponentProperty(PropertyComment) ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo) + ComponentPropertyMethod = ComponentProperty(PropertyMethod) + ComponentPropertyRecurrenceId = ComponentProperty(PropertyRecurrenceId) ComponentPropertyDuration = ComponentProperty(PropertyDuration) + ComponentPropertyContact = ComponentProperty(PropertyContact) + ComponentPropertyRequestStatus = ComponentProperty(PropertyRequestStatus) + ComponentPropertyRDate = ComponentProperty(PropertyRdate) ) +// Required returns the rules from the RFC as to if they are required or not for any particular component type +// If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference +// iana-prop and x-props are not covered as it would always be true and require an exhaustive list. +func (cp ComponentProperty) Required(c Component) bool { + // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1 + switch cp { + case ComponentPropertyDtstamp, ComponentPropertyUniqueId: + switch c.(type) { + case *VEvent: + return true + } + case ComponentPropertyDtStart: + switch c := c.(type) { + case *VEvent: + return !c.HasProperty(ComponentPropertyMethod) + } + } + return false +} + +// Exclusive returns the ComponentProperty's using the rules from the RFC as to if one or more existing properties are prohibiting this one +// If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference +// iana-prop and x-props are not covered as it would always be true and require an exhaustive list. +func (cp ComponentProperty) Exclusive(c Component) []ComponentProperty { + // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1 + switch cp { + case ComponentPropertyDtEnd: + switch c := c.(type) { + case *VEvent: + if c.HasProperty(ComponentPropertyDuration) { + return []ComponentProperty{ComponentPropertyDuration} + } + } + case ComponentPropertyDuration: + switch c := c.(type) { + case *VEvent: + if c.HasProperty(ComponentPropertyDtEnd) { + return []ComponentProperty{ComponentPropertyDtEnd} + } + } + } + return nil +} + +// Singular returns the rules from the RFC as to if the spec states that if "Must not occur more than once" +// iana-prop and x-props are not covered as it would always be true and require an exhaustive list. +func (cp ComponentProperty) Singular(c Component) bool { + // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1 + switch cp { + case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo, + ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority, + ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp, + ComponentPropertyUrl, ComponentPropertyRecurrenceId: + switch c.(type) { + case *VEvent: + return true + } + } + return false +} + +// Optional returns the rules from the RFC as to if the spec states that if these are optional +// iana-prop and x-props are not covered as it would always be true and require an exhaustive list. +func (cp ComponentProperty) Optional(c Component) bool { + // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1 + switch cp { + case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo, + ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority, + ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp, + ComponentPropertyUrl, ComponentPropertyRecurrenceId, ComponentPropertyRrule, ComponentPropertyAttach, + ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment, + ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo, + ComponentPropertyResources, ComponentPropertyRDate: + switch c.(type) { + case *VEvent: + return true + } + } + return false +} + +// Multiple returns the rules from the RFC as to if the spec states explicitly if multiple are allowed +// iana-prop and x-props are not covered as it would always be true and require an exhaustive list. +func (cp ComponentProperty) Multiple(c Component) bool { + // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1 + switch cp { + case ComponentPropertyAttach, ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment, + ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo, + ComponentPropertyResources, ComponentPropertyRDate: + switch c.(type) { + case *VEvent: + return true + } + } + return false +} + type Property string const ( @@ -413,6 +518,108 @@ func (cal *Calendar) setProperty(property Property, value string, params ...Prop cal.CalendarProperties = append(cal.CalendarProperties, r) } +func (calendar *Calendar) AddEvent(id string) *VEvent { + e := NewEvent(id) + calendar.Components = append(calendar.Components, e) + return e +} + +func (calendar *Calendar) AddVEvent(e *VEvent) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Events() (r []*VEvent) { + r = []*VEvent{} + for i := range calendar.Components { + switch event := calendar.Components[i].(type) { + case *VEvent: + r = append(r, event) + } + } + return +} + +func (calendar *Calendar) RemoveEvent(id string) { + for i := range calendar.Components { + switch event := calendar.Components[i].(type) { + case *VEvent: + if event.Id() == id { + if len(calendar.Components) > i+1 { + calendar.Components = append(calendar.Components[:i], calendar.Components[i+1:]...) + } else { + calendar.Components = calendar.Components[:i] + } + return + } + } + } +} + +func WithCustomClient(client *http.Client) *http.Client { + return client +} + +func WithCustomRequest(request *http.Request) *http.Request { + return request +} + +func ParseCalendarFromUrl(url string, opts ...any) (*Calendar, error) { + var ctx context.Context + var req *http.Request + var client HttpClientLike = http.DefaultClient + for opti, opt := range opts { + switch opt := opt.(type) { + case *http.Client: + client = opt + case HttpClientLike: + client = opt + case func() *http.Client: + client = opt() + case *http.Request: + req = opt + case func() *http.Request: + req = opt() + case context.Context: + ctx = opt + case func() context.Context: + ctx = opt() + default: + return nil, fmt.Errorf("unknown optional argument %d on ParseCalendarFromUrl: %s", opti, reflect.TypeOf(opt)) + } + } + if ctx == nil { + ctx = context.Background() + } + if req == nil { + var err error + req, err = http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating http request: %w", err) + } + } + return parseCalendarFromHttpRequest(client, req) +} + +type HttpClientLike interface { + Do(req *http.Request) (*http.Response, error) +} + +func parseCalendarFromHttpRequest(client HttpClientLike, request *http.Request) (*Calendar, error) { + resp, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("http request: %w", err) + } + defer func(closer io.ReadCloser) { + if derr := closer.Close(); derr != nil && err == nil { + err = fmt.Errorf("http request close: %w", derr) + } + }(resp.Body) + var cal *Calendar + cal, err = ParseCalendar(resp.Body) + // This allows the defer func to change the error + return cal, err +} + func ParseCalendar(r io.Reader) (*Calendar, error) { state := "begin" c := &Calendar{} diff --git a/calendar_test.go b/calendar_test.go index 38cad85..6ddd606 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -1,7 +1,10 @@ package ics import ( + "bytes" + _ "embed" "io" + "net/http" "os" "path/filepath" "regexp" @@ -410,3 +413,33 @@ func TestIssue52(t *testing.T) { t.Fatalf("cannot read test directory: %v", err) } } + +type MockHttpClient struct { + Response *http.Response + Error error +} + +func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) { + return m.Response, m.Error +} + +var ( + _ HttpClientLike = &MockHttpClient{} + //go:embed "testdata/rfc5545sec4/input1.ics" + input1TestData []byte +) + +func TestIssue77(t *testing.T) { + url := "https://proseconsult.umontpellier.fr/jsp/custom/modules/plannings/direct_cal.jsp?data=58c99062bab31d256bee14356aca3f2423c0f022cb9660eba051b2653be722c4c7f281e4e3ad06b85d3374100ac416a4dc5c094f7d1a811b903031bde802c7f50e0bd1077f9461bed8f9a32b516a3c63525f110c026ed6da86f487dd451ca812c1c60bb40b1502b6511435cf9908feb2166c54e36382c1aa3eb0ff5cb8980cdb,1" + + _, err := ParseCalendarFromUrl(url, &MockHttpClient{ + Response: &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(input1TestData)), + }, + }) + + if err != nil { + t.Fatalf("Error reading file: %s", err) + } +} diff --git a/components.go b/components.go index 8cc6e09..8f7c637 100644 --- a/components.go +++ b/components.go @@ -62,6 +62,8 @@ func NewComponent(uniqueId string) ComponentBase { } } +// GetProperty returns the first match for the particular property you're after. Please consider using: +// ComponentProperty.Required to determine if GetProperty or GetProperties is more appropriate. func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAProperty { for i := range cb.Properties { if cb.Properties[i].IANAToken == string(componentProperty) { @@ -71,6 +73,31 @@ func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAP return nil } +// GetProperties returns all matches for the particular property you're after. Please consider using: +// ComponentProperty.Singular/ComponentProperty.Multiple to determine if GetProperty or GetProperties is more appropriate. +func (cb *ComponentBase) GetProperties(componentProperty ComponentProperty) []*IANAProperty { + var result []*IANAProperty + for i := range cb.Properties { + if cb.Properties[i].IANAToken == string(componentProperty) { + result = append(result, &cb.Properties[i]) + } + } + return result +} + +// HasProperty returns true if a component property is in the component. +func (cb *ComponentBase) HasProperty(componentProperty ComponentProperty) bool { + for i := range cb.Properties { + if cb.Properties[i].IANAToken == string(componentProperty) { + return true + } + } + return false +} + +// SetProperty replaces the first match for the particular property you're setting, otherwise adds it. Please consider using: +// ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is +// more appropriate. func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, params ...PropertyParameter) { for i := range cb.Properties { if cb.Properties[i].IANAToken == string(property) { @@ -86,6 +113,17 @@ func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, p cb.AddProperty(property, value, params...) } +// ReplaceProperty replaces all matches of the particular property you're setting, otherwise adds it. Returns a slice +// of removed properties. Please consider using: +// ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is +// more appropriate. +func (cb *ComponentBase) ReplaceProperty(property ComponentProperty, value string, params ...PropertyParameter) []IANAProperty { + removed := cb.RemoveProperty(property) + cb.AddProperty(property, value, params...) + return removed +} + +// AddProperty appends a property func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, params ...PropertyParameter) { r := IANAProperty{ BaseProperty{ @@ -101,16 +139,44 @@ func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, p cb.Properties = append(cb.Properties, r) } -// RemoveProperty removes from the component all properties that has -// the name passed in removeProp. -func (cb *ComponentBase) RemoveProperty(removeProp ComponentProperty) { +// RemoveProperty removes from the component all properties that is of a particular property type, returning an slice of +// removed entities +func (cb *ComponentBase) RemoveProperty(removeProp ComponentProperty) []IANAProperty { var keptProperties []IANAProperty + var removedProperties []IANAProperty for i := range cb.Properties { if cb.Properties[i].IANAToken != string(removeProp) { keptProperties = append(keptProperties, cb.Properties[i]) + } else { + removedProperties = append(removedProperties, cb.Properties[i]) + } + } + cb.Properties = keptProperties + return removedProperties +} + +// RemovePropertyByValue removes from the component all properties that has a particular property type and value, +// return a count of removed properties +func (cb *ComponentBase) RemovePropertyByValue(removeProp ComponentProperty, value string) []IANAProperty { + return cb.RemovePropertyByFunc(removeProp, func(p IANAProperty) bool { + return p.Value == value + }) +} + +// RemovePropertyByFunc removes from the component all properties that has a particular property type and the function +// remove returns true for +func (cb *ComponentBase) RemovePropertyByFunc(removeProp ComponentProperty, remove func(p IANAProperty) bool) []IANAProperty { + var keptProperties []IANAProperty + var removedProperties []IANAProperty + for i := range cb.Properties { + if cb.Properties[i].IANAToken != string(removeProp) && remove(cb.Properties[i]) { + keptProperties = append(keptProperties, cb.Properties[i]) + } else { + removedProperties = append(removedProperties, cb.Properties[i]) } } cb.Properties = keptProperties + return removedProperties } const ( @@ -480,13 +546,13 @@ type VEvent struct { ComponentBase } -func (c *VEvent) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VEVENT") +func (event *VEvent) SerializeTo(w io.Writer) { + event.ComponentBase.serializeThis(w, "VEVENT") } -func (c *VEvent) Serialize() string { +func (event *VEvent) Serialize() string { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VEVENT") + event.ComponentBase.serializeThis(b, "VEVENT") return b.String() } @@ -497,77 +563,40 @@ func NewEvent(uniqueId string) *VEvent { return e } -func (cal *Calendar) AddEvent(id string) *VEvent { - e := NewEvent(id) - cal.Components = append(cal.Components, e) - return e +func (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (cal *Calendar) AddVEvent(e *VEvent) { - cal.Components = append(cal.Components, e) +func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (cal *Calendar) RemoveEvent(id string) { - for i := range cal.Components { - switch event := cal.Components[i].(type) { - case *VEvent: - if event.Id() == id { - if len(cal.Components) > i+1 { - cal.Components = append(cal.Components[:i], cal.Components[i+1:]...) - } else { - cal.Components = cal.Components[:i] - } - return - } - } - } +func (event *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { + event.setGeo(lat, lng, params...) } -func (cal *Calendar) Events() []*VEvent { - var r []*VEvent - for i := range cal.Components { - switch event := cal.Components[i].(type) { - case *VEvent: - r = append(r, event) - } - } - return r +func (event *VEvent) SetPriority(p int, params ...PropertyParameter) { + event.setPriority(p, params...) } -func (c *VEvent) SetEndAt(t time.Time, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), params...) +func (event *VEvent) SetResources(r string, params ...PropertyParameter) { + event.setResources(r, params...) } -func (c *VEvent) SetLastModifiedAt(t time.Time, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...) +func (event *VEvent) AddAlarm() *VAlarm { + return event.addAlarm() } -func (c *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { - c.setGeo(lat, lng, params...) +func (event *VEvent) AddVAlarm(a *VAlarm) { + event.addVAlarm(a) } -func (c *VEvent) SetPriority(p int, params ...PropertyParameter) { - c.setPriority(p, params...) -} - -func (c *VEvent) SetResources(r string, params ...PropertyParameter) { - c.setResources(r, params...) -} - -func (c *VEvent) AddAlarm() *VAlarm { - return c.addAlarm() -} - -func (c *VEvent) AddVAlarm(a *VAlarm) { - c.addVAlarm(a) -} - -func (c *VEvent) Alarms() []*VAlarm { - return c.alarms() +func (event *VEvent) Alarms() []*VAlarm { + return event.alarms() } -func (c *VEvent) GetAllDayEndAt() (time.Time, error) { - return c.getTimeProp(ComponentPropertyDtEnd, true) +func (event *VEvent) GetAllDayEndAt() (time.Time, error) { + return event.getTimeProp(ComponentPropertyDtEnd, true) } type TimeTransparency string @@ -577,8 +606,8 @@ const ( TransparencyTransparent TimeTransparency = "TRANSPARENT" ) -func (c *VEvent) SetTimeTransparency(v TimeTransparency, params ...PropertyParameter) { - c.SetProperty(ComponentPropertyTransp, string(v), params...) +func (event *VEvent) SetTimeTransparency(v TimeTransparency, params ...PropertyParameter) { + event.SetProperty(ComponentPropertyTransp, string(v), params...) } type VTodo struct {