From 08adbdaf1fede8906b433f9bb842d71fe7ee5c3c Mon Sep 17 00:00:00 2001 From: Sean Slater Date: Sat, 1 Apr 2023 08:58:07 -0400 Subject: [PATCH 01/50] Add Todo and Journal Property Methods - Add methods for VTodo and VJournal - Changed the shared properties to be attached to the ComponentBase so those methods don't need to be repeated across all attributes that share them - If a property is shared across some, but not all components, then it's a private method on the ComponentBase and public method on the Components (eg LOCATION for Event and Todo but not Journal) - Shifted position of some Event methods in the file to live after the VEvent struct --- calendar.go | 91 ++++------- components.go | 414 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 332 insertions(+), 173 deletions(-) diff --git a/calendar.go b/calendar.go index 5b3d7c4..a68bee8 100644 --- a/calendar.go +++ b/calendar.go @@ -26,33 +26,38 @@ const ( type ComponentProperty Property const ( - ComponentPropertyUniqueId = ComponentProperty(PropertyUid) // TEXT - ComponentPropertyDtstamp = ComponentProperty(PropertyDtstamp) - ComponentPropertyOrganizer = ComponentProperty(PropertyOrganizer) - ComponentPropertyAttendee = ComponentProperty(PropertyAttendee) - ComponentPropertyAttach = ComponentProperty(PropertyAttach) - ComponentPropertyDescription = ComponentProperty(PropertyDescription) // TEXT - ComponentPropertyCategories = ComponentProperty(PropertyCategories) // TEXT - ComponentPropertyClass = ComponentProperty(PropertyClass) // TEXT - ComponentPropertyColor = ComponentProperty(PropertyColor) // TEXT - ComponentPropertyCreated = ComponentProperty(PropertyCreated) - ComponentPropertySummary = ComponentProperty(PropertySummary) // TEXT - ComponentPropertyDtStart = ComponentProperty(PropertyDtstart) - ComponentPropertyDtEnd = ComponentProperty(PropertyDtend) - ComponentPropertyLocation = ComponentProperty(PropertyLocation) // TEXT - ComponentPropertyStatus = ComponentProperty(PropertyStatus) // TEXT - ComponentPropertyFreebusy = ComponentProperty(PropertyFreebusy) - ComponentPropertyLastModified = ComponentProperty(PropertyLastModified) - ComponentPropertyUrl = ComponentProperty(PropertyUrl) - ComponentPropertyGeo = ComponentProperty(PropertyGeo) - ComponentPropertyTransp = ComponentProperty(PropertyTransp) - ComponentPropertySequence = ComponentProperty(PropertySequence) - ComponentPropertyExdate = ComponentProperty(PropertyExdate) - ComponentPropertyExrule = ComponentProperty(PropertyExrule) - ComponentPropertyRdate = ComponentProperty(PropertyRdate) - ComponentPropertyRrule = ComponentProperty(PropertyRrule) - ComponentPropertyAction = ComponentProperty(PropertyAction) - ComponentPropertyTrigger = ComponentProperty(PropertyTrigger) + ComponentPropertyUniqueId = ComponentProperty(PropertyUid) // TEXT + ComponentPropertyDtstamp = ComponentProperty(PropertyDtstamp) + ComponentPropertyOrganizer = ComponentProperty(PropertyOrganizer) + ComponentPropertyAttendee = ComponentProperty(PropertyAttendee) + ComponentPropertyAttach = ComponentProperty(PropertyAttach) + ComponentPropertyDescription = ComponentProperty(PropertyDescription) // TEXT + ComponentPropertyCategories = ComponentProperty(PropertyCategories) // TEXT + ComponentPropertyClass = ComponentProperty(PropertyClass) // TEXT + ComponentPropertyColor = ComponentProperty(PropertyColor) // TEXT + ComponentPropertyCreated = ComponentProperty(PropertyCreated) + ComponentPropertySummary = ComponentProperty(PropertySummary) // TEXT + ComponentPropertyDtStart = ComponentProperty(PropertyDtstart) + ComponentPropertyDtEnd = ComponentProperty(PropertyDtend) + ComponentPropertyLocation = ComponentProperty(PropertyLocation) // TEXT + ComponentPropertyStatus = ComponentProperty(PropertyStatus) // TEXT + ComponentPropertyFreebusy = ComponentProperty(PropertyFreebusy) + ComponentPropertyLastModified = ComponentProperty(PropertyLastModified) + ComponentPropertyUrl = ComponentProperty(PropertyUrl) + ComponentPropertyGeo = ComponentProperty(PropertyGeo) + ComponentPropertyTransp = ComponentProperty(PropertyTransp) + ComponentPropertySequence = ComponentProperty(PropertySequence) + ComponentPropertyExdate = ComponentProperty(PropertyExdate) + ComponentPropertyExrule = ComponentProperty(PropertyExrule) + ComponentPropertyRdate = ComponentProperty(PropertyRdate) + ComponentPropertyRrule = ComponentProperty(PropertyRrule) + ComponentPropertyAction = ComponentProperty(PropertyAction) + ComponentPropertyTrigger = ComponentProperty(PropertyTrigger) + ComponentPropertyPriority = ComponentProperty(PropertyPriority) + ComponentPropertyResources = ComponentProperty(PropertyResources) + ComponentPropertyCompleted = ComponentProperty(PropertyCompleted) + ComponentPropertyDue = ComponentProperty(PropertyDue) + ComponentPropertyPercentComplete = ComponentProperty(PropertyPercentComplete) ) type Property string @@ -404,38 +409,6 @@ func (calendar *Calendar) setProperty(property Property, value string, props ... calendar.CalendarProperties = append(calendar.CalendarProperties, r) } -func NewEvent(uniqueId string) *VEvent { - e := &VEvent{ - ComponentBase{ - Properties: []IANAProperty{ - {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, - }, - }, - } - return e -} - -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 ParseCalendar(r io.Reader) (*Calendar, error) { state := "begin" c := &Calendar{} diff --git a/components.go b/components.go index cb88376..f356071 100644 --- a/components.go +++ b/components.go @@ -30,6 +30,7 @@ func (cb *ComponentBase) UnknownPropertiesIANAProperties() []IANAProperty { func (cb *ComponentBase) SubComponents() []Component { return cb.Components } + func (base ComponentBase) serializeThis(writer io.Writer, componentType string) { fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") for _, p := range base.Properties { @@ -41,6 +42,14 @@ func (base ComponentBase) serializeThis(writer io.Writer, componentType string) fmt.Fprint(writer, "END:"+componentType, "\r\n") } +func NewComponent(uniqueId string) ComponentBase { + return ComponentBase{ + Properties: []IANAProperty{ + {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, + }, + } +} + func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAProperty { for i := range cb.Properties { if cb.Properties[i].IANAToken == string(componentProperty) { @@ -80,20 +89,6 @@ func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, p cb.Properties = append(cb.Properties, r) } -type VEvent struct { - ComponentBase -} - -func (c *VEvent) serialize(w io.Writer) { - c.ComponentBase.serializeThis(w, "VEVENT") -} - -func (c *VEvent) Serialize() string { - b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VEVENT") - return b.String() -} - const ( icalTimestampFormatUtc = "20060102T150405Z" icalTimestampFormatLocal = "20060102T150405" @@ -105,60 +100,32 @@ var ( timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$") ) -func (event *VEvent) SetCreatedTime(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), props...) -} - -func (event *VEvent) SetDtStampTime(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetCreatedTime(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetModifiedAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetDtStampTime(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetSequence(seq int, props ...PropertyParameter) { - event.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...) +func (cb *ComponentBase) SetModifiedAt(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetStartAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetSequence(seq int, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...) } -func (event *VEvent) SetAllDayStartAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalDateFormatUtc), props...) +func (cb *ComponentBase) SetStartAt(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...) -} - -func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalDateFormatUtc), props...) +func (cb *ComponentBase) SetAllDayStartAt(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalDateFormatUtc), props...) } -// SetDuration updates the duration of an event. -// This function will set either the end or start time of an event depending what is already given. -// The duration defines the length of a event relative to start or end time. -// -// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. -func (event *VEvent) SetDuration(d time.Duration) error { - t, err := event.GetStartAt() - if err == nil { - event.SetEndAt(t.Add(d)) - return nil - } else { - t, err = event.GetEndAt() - if err == nil { - event.SetStartAt(t.Add(-d)) - return nil - } - } - return errors.New("start or end not yet defined") -} - -func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { - timeProp := event.GetProperty(componentProperty) +func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { + timeProp := cb.GetProperty(componentProperty) if timeProp == nil { return time.Time{}, errors.New("property not found") } @@ -224,99 +191,89 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal) } -func (event *VEvent) GetStartAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtStart, false) +func (cb *ComponentBase) GetStartAt() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyDtStart, false) } -func (event *VEvent) GetEndAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtEnd, false) +func (cb *ComponentBase) GetAllDayStartAt() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyDtStart, true) } -func (event *VEvent) GetAllDayStartAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtStart, true) +func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertySummary, ToText(s), props...) } -func (event *VEvent) GetAllDayEndAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtEnd, true) +func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) } -type TimeTransparency string - -const ( - TransparencyOpaque TimeTransparency = "OPAQUE" // default - TransparencyTransparent TimeTransparency = "TRANSPARENT" -) - -func (event *VEvent) SetTimeTransparency(v TimeTransparency, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyTransp, string(v), props...) +func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDescription, ToText(s), props...) } -func (event *VEvent) SetSummary(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertySummary, ToText(s), props...) +func (cb *ComponentBase) setLocation(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLocation, ToText(s), props...) } -func (event *VEvent) SetStatus(s ObjectStatus, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) +func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), props...) } -func (event *VEvent) SetDescription(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDescription, ToText(s), props...) +func (cb *ComponentBase) SetURL(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyUrl, s, props...) } -func (event *VEvent) SetLocation(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyLocation, ToText(s), props...) +func (cb *ComponentBase) SetOrganizer(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyOrganizer, s, props...) } -func (event *VEvent) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), props...) +func (cb *ComponentBase) SetColor(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyColor, s, props...) } -func (event *VEvent) SetURL(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyUrl, s, props...) +func (cb *ComponentBase) SetClass(c Classification, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyClass, string(c), props...) } -func (event *VEvent) SetOrganizer(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyOrganizer, s, props...) +func (cb *ComponentBase) setPriority(p int, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), props...) } -func (event *VEvent) SetColor(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyColor, s, props...) +func (cb *ComponentBase) setResources(r string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyResources, r, props...) } -func (event *VEvent) SetClass(c Classification, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyClass, string(c), props...) -} -func (event *VEvent) AddAttendee(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyAttendee, "mailto:"+s, props...) +func (cb *ComponentBase) AddAttendee(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyAttendee, "mailto:"+s, props...) } -func (event *VEvent) AddExdate(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyExdate, s, props...) +func (cb *ComponentBase) AddExdate(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyExdate, s, props...) } -func (event *VEvent) AddExrule(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyExrule, s, props...) +func (cb *ComponentBase) AddExrule(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyExrule, s, props...) } -func (event *VEvent) AddRdate(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyRdate, s, props...) +func (cb *ComponentBase) AddRdate(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyRdate, s, props...) } -func (event *VEvent) AddRrule(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyRrule, s, props...) +func (cb *ComponentBase) AddRrule(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyRrule, s, props...) } -func (event *VEvent) AddAttachment(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyAttach, s, props...) +func (cb *ComponentBase) AddAttachment(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyAttach, s, props...) } -func (event *VEvent) AddAttachmentURL(uri string, contentType string) { - event.AddAttachment(uri, WithFmtType(contentType)) +func (cb *ComponentBase) AddAttachmentURL(uri string, contentType string) { + cb.AddAttachment(uri, WithFmtType(contentType)) } -func (event *VEvent) AddAttachmentBinary(binary []byte, contentType string) { - event.AddAttachment(base64.StdEncoding.EncodeToString(binary), +func (cb *ComponentBase) AddAttachmentBinary(binary []byte, contentType string) { + cb.AddAttachment(base64.StdEncoding.EncodeToString(binary), WithFmtType(contentType), WithEncoding("base64"), WithValue("binary"), ) } @@ -351,13 +308,13 @@ func (attendee *Attendee) getProperty(parameter Parameter) []string { return nil } -func (event *VEvent) Attendees() (r []*Attendee) { +func (cb *ComponentBase) Attendees() (r []*Attendee) { r = []*Attendee{} - for i := range event.Properties { - switch event.Properties[i].IANAToken { + for i := range cb.Properties { + switch cb.Properties[i].IANAToken { case string(ComponentPropertyAttendee): a := &Attendee{ - event.Properties[i], + cb.Properties[i], } r = append(r, a) } @@ -365,8 +322,8 @@ func (event *VEvent) Attendees() (r []*Attendee) { return } -func (event *VEvent) Id() string { - p := event.GetProperty(ComponentPropertyUniqueId) +func (cb *ComponentBase) Id() string { + p := cb.GetProperty(ComponentPropertyUniqueId) if p != nil { return FromText(p.Value) } @@ -392,6 +349,113 @@ func (event *VEvent) Alarms() (r []*VAlarm) { return } + +type VEvent struct { + ComponentBase +} + +func (c *VEvent) serialize(w io.Writer) { + c.ComponentBase.serializeThis(w, "VEVENT") +} + +func (c *VEvent) Serialize() string { + b := &bytes.Buffer{} + c.ComponentBase.serializeThis(b, "VEVENT") + return b.String() +} + +func NewEvent(uniqueId string) *VEvent { + e := &VEvent{ + NewComponent(uniqueId), + } + return e +} + +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 (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...) +} + +func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalDateFormatUtc), props...) +} + +func (event *VEvent) SetLocation(s string, props ...PropertyParameter) { + event.setLocation(s, props...); +} + +func (event *VEvent) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { + event.setGeo(lat, lng, props...); +} + +func (event *VEvent) SetPriority(p int, props ...PropertyParameter) { + event.setPriority(p, props...); +} + +func (event *VEvent) SetResources(r string, props ...PropertyParameter) { + event.setResources(r, props...) +} + +// SetDuration updates the duration of an event. +// This function will set either the end or start time of an event depending what is already given. +// The duration defines the length of a event relative to start or end time. +// +// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. +func (event *VEvent) SetDuration(d time.Duration) error { + t, err := event.GetStartAt() + if err == nil { + event.SetEndAt(t.Add(d)) + return nil + } else { + t, err = event.GetEndAt() + if err == nil { + event.SetStartAt(t.Add(-d)) + return nil + } + } + return errors.New("start or end not yet defined") +} + +func (event *VEvent) GetEndAt() (time.Time, error) { + return event.getTimeProp(ComponentPropertyDtEnd, false) +} + +func (event *VEvent) GetAllDayEndAt() (time.Time, error) { + return event.getTimeProp(ComponentPropertyDtEnd, true) +} + +type TimeTransparency string + +const ( + TransparencyOpaque TimeTransparency = "OPAQUE" // default + TransparencyTransparent TimeTransparency = "TRANSPARENT" +) + +func (event *VEvent) SetTimeTransparency(v TimeTransparency, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyTransp, string(v), props...) +} + + type VTodo struct { ComponentBase } @@ -406,6 +470,100 @@ func (c *VTodo) Serialize() string { return b.String() } +func NewTodo(uniqueId string) *VTodo { + e := &VTodo{ + NewComponent(uniqueId), + } + return e +} + +func (calendar *Calendar) AddTodo(id string) *VTodo { + e := NewTodo(id) + calendar.Components = append(calendar.Components, e) + return e +} + +func (calendar *Calendar) AddVTodo(e *VTodo) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Todos() (r []*VTodo) { + r = []*VTodo{} + for i := range calendar.Components { + switch todo := calendar.Components[i].(type) { + case *VTodo: + r = append(r, todo) + } + } + return +} + +func (todo *VTodo) SetCompletedAt(t time.Time, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), props...) +} + +func (todo *VTodo) SetAllDayCompletedAt(t time.Time, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalDateFormatUtc), props...) +} + +func (todo *VTodo) SetDueAt(t time.Time, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), props...) +} + +func (todo *VTodo) SetAllDayDueAt(t time.Time, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalDateFormatUtc), props...) +} + +func (todo *VTodo) SetPercentComplete(p int, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), props...) +} + + +func (todo *VTodo) SetLocation(s string, props ...PropertyParameter) { + todo.setLocation(s, props...); +} + +func (todo *VTodo) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { + todo.setGeo(lat, lng, props...); +} + +func (todo *VTodo) SetPriority(p int, props ...PropertyParameter) { + todo.setPriority(p, props...); +} + +func (todo *VTodo) SetResources(r string, props ...PropertyParameter) { + todo.setResources(r, props...) +} + +// SetDuration updates the duration of an event. +// This function will set either the end or start time of an event depending what is already given. +// The duration defines the length of a event relative to start or end time. +// +// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. +func (todo *VTodo) SetDuration(d time.Duration) error { + t, err := todo.GetStartAt() + if err == nil { + todo.SetDueAt(t.Add(d)) + return nil + } else { + t, err = todo.GetDueAt() + if err == nil { + todo.SetStartAt(t.Add(-d)) + return nil + } + } + return errors.New("start or end not yet defined") +} + +func (todo *VTodo) GetDueAt() (time.Time, error) { + return todo.getTimeProp(ComponentPropertyDue, false) +} + +func (todo *VTodo) GetAllDayDueAt() (time.Time, error) { + return todo.getTimeProp(ComponentPropertyDue, true) +} + + type VJournal struct { ComponentBase } @@ -420,6 +578,34 @@ func (c *VJournal) Serialize() string { return b.String() } +func NewJournal(uniqueId string) *VJournal { + e := &VJournal{ + NewComponent(uniqueId), + } + return e +} + +func (calendar *Calendar) AddJournal(id string) *VJournal { + e := NewJournal(id) + calendar.Components = append(calendar.Components, e) + return e +} + +func (calendar *Calendar) AddVJournal(e *VJournal) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Journals() (r []*VJournal) { + r = []*VJournal{} + for i := range calendar.Components { + switch journal := calendar.Components[i].(type) { + case *VJournal: + r = append(r, journal) + } + } + return +} + type VBusy struct { ComponentBase } From 14095cc944ebfb3c7fbb883cf7490d38c8c47610 Mon Sep 17 00:00:00 2001 From: Sean Slater Date: Sun, 2 Apr 2023 16:26:42 -0400 Subject: [PATCH 02/50] Add Alarm, Timezone, and FreeBusy Property Methods - Add methods for Alarm, Timezone, and FreeBusy - Only methods for creating and adding right now, will add methods for the properties unique to them later --- components.go | 118 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 5 deletions(-) diff --git a/components.go b/components.go index f356071..ec5637d 100644 --- a/components.go +++ b/components.go @@ -330,18 +330,22 @@ func (cb *ComponentBase) Id() string { return "" } -func (event *VEvent) AddAlarm() *VAlarm { +func (cb *ComponentBase) addAlarm() *VAlarm { a := &VAlarm{ ComponentBase: ComponentBase{}, } - event.Components = append(event.Components, a) + cb.Components = append(cb.Components, a) return a } -func (event *VEvent) Alarms() (r []*VAlarm) { +func (cb *ComponentBase) addVAlarm(a *VAlarm) { + event.Components = append(cb.Components, a) +} + +func (cb *ComponentBase) alarms() (r []*VAlarm) { r = []*VAlarm{} - for i := range event.Components { - switch alarm := event.Components[i].(type) { + for i := range cb.Components { + switch alarm := cb.Components[i].(type) { case *VAlarm: r = append(r, alarm) } @@ -436,6 +440,18 @@ func (event *VEvent) SetDuration(d time.Duration) error { return errors.New("start or end not yet defined") } +func (event *VEvent) AddAlarm() *VAlarm { + return event.addAlarm() +} + +func (event *VEvent) AddVAlarm(a *VAlarm) { + event.addVAlarm(a) +} + +func (event *VEvent) Alarms() (r []*VAlarm) { + return event.Alarms() +} + func (event *VEvent) GetEndAt() (time.Time, error) { return event.getTimeProp(ComponentPropertyDtEnd, false) } @@ -555,6 +571,18 @@ func (todo *VTodo) SetDuration(d time.Duration) error { return errors.New("start or end not yet defined") } +func (todo *VTodo) AddAlarm() *VAlarm { + return todo.addAlarm() +} + +func (todo *VTodo) AddVAlarm(a *VAlarm) { + todo.addVAlarm(a) +} + +func (todo *VTodo) Alarms() (r []*VAlarm) { + return todo.Alarms() +} + func (todo *VTodo) GetDueAt() (time.Time, error) { return todo.getTimeProp(ComponentPropertyDue, false) } @@ -620,6 +648,34 @@ func (c *VBusy) serialize(w io.Writer) { c.ComponentBase.serializeThis(w, "VBUSY") } +func NewBusy(uniqueId string) *VBusy { + e := &VBusy{ + NewComponent(uniqueId), + } + return e +} + +func (calendar *Calendar) AddBusy(id string) *VBusy { + e := NewBusy(id) + calendar.Components = append(calendar.Components, e) + return e +} + +func (calendar *Calendar) AddVBusy(e *VBusy) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Busys() (r []*VBusy) { + r = []*VBusy{} + for i := range calendar.Components { + switch busy := calendar.Components[i].(type) { + case *VBusy: + r = append(r, busy) + } + } + return +} + type VTimezone struct { ComponentBase } @@ -634,6 +690,38 @@ func (c *VTimezone) serialize(w io.Writer) { c.ComponentBase.serializeThis(w, "VTIMEZONE") } +func NewTimezone(tzId string) *VTimezone { + e := &VTimezone{ + ComponentBase{ + Properties: []IANAProperty{ + {BaseProperty{IANAToken: ToText(string(ComponentPropertyTzid)), Value: tzId}}, + }, + } + } + return e +} + +func (calendar *Calendar) AddTimezone(id string) *VTimezone { + e := NewTimezone(id) + calendar.Components = append(calendar.Components, e) + return e +} + +func (calendar *Calendar) AddVTimezone(e *VTimezone) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Timezones() (r []*VTimezone) { + r = []*VTimezone{} + for i := range calendar.Components { + switch timezone := calendar.Components[i].(type) { + case *VTimezone: + r = append(r, timezone) + } + } + return +} + type VAlarm struct { ComponentBase } @@ -648,6 +736,26 @@ func (c *VAlarm) serialize(w io.Writer) { c.ComponentBase.serializeThis(w, "VALARM") } +func NewAlarm(tzId string) *VAlarm { + e := &VAlarm{} + return e +} + +func (calendar *Calendar) AddVAlarm(e *VAlarm) { + calendar.Components = append(calendar.Components, e) +} + +func (calendar *Calendar) Alarms() (r []*VAlarm) { + r = []*VAlarm{} + for i := range calendar.Components { + switch alarm := calendar.Components[i].(type) { + case *VAlarm: + r = append(r, alarm) + } + } + return +} + func (alarm *VAlarm) SetAction(a Action, props ...PropertyParameter) { alarm.SetProperty(ComponentPropertyAction, string(a), props...) } From 67727dda1ccad4d94e050383e9a4b8c9ceff15c5 Mon Sep 17 00:00:00 2001 From: Sean Slater Date: Thu, 6 Apr 2023 12:29:40 -0400 Subject: [PATCH 03/50] Fix Typos in a couple places - Recursive call for Alarms - SetGeo was not a public method - Missing comma in struct creation --- calendar.go | 1 + components.go | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/calendar.go b/calendar.go index a68bee8..3ee16a8 100644 --- a/calendar.go +++ b/calendar.go @@ -58,6 +58,7 @@ const ( ComponentPropertyCompleted = ComponentProperty(PropertyCompleted) ComponentPropertyDue = ComponentProperty(PropertyDue) ComponentPropertyPercentComplete = ComponentProperty(PropertyPercentComplete) + ComponentPropertyTzid = ComponentProperty(PropertyTzid) ) type Property string diff --git a/components.go b/components.go index ec5637d..234356a 100644 --- a/components.go +++ b/components.go @@ -339,7 +339,7 @@ func (cb *ComponentBase) addAlarm() *VAlarm { } func (cb *ComponentBase) addVAlarm(a *VAlarm) { - event.Components = append(cb.Components, a) + cb.Components = append(cb.Components, a) } func (cb *ComponentBase) alarms() (r []*VAlarm) { @@ -449,7 +449,7 @@ func (event *VEvent) AddVAlarm(a *VAlarm) { } func (event *VEvent) Alarms() (r []*VAlarm) { - return event.Alarms() + return event.alarms() } func (event *VEvent) GetEndAt() (time.Time, error) { @@ -539,7 +539,7 @@ func (todo *VTodo) SetLocation(s string, props ...PropertyParameter) { todo.setLocation(s, props...); } -func (todo *VTodo) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { +func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { todo.setGeo(lat, lng, props...); } @@ -580,7 +580,7 @@ func (todo *VTodo) AddVAlarm(a *VAlarm) { } func (todo *VTodo) Alarms() (r []*VAlarm) { - return todo.Alarms() + return todo.alarms() } func (todo *VTodo) GetDueAt() (time.Time, error) { @@ -696,7 +696,7 @@ func NewTimezone(tzId string) *VTimezone { Properties: []IANAProperty{ {BaseProperty{IANAToken: ToText(string(ComponentPropertyTzid)), Value: tzId}}, }, - } + }, } return e } From 6d4f14f630b870fe44df70148192d6d0f6d945b7 Mon Sep 17 00:00:00 2001 From: Sean Slater Date: Thu, 27 Apr 2023 19:29:33 -0400 Subject: [PATCH 04/50] Add Comment and Category Properties to Components --- calendar.go | 1 + components.go | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/calendar.go b/calendar.go index 3ee16a8..7f21a86 100644 --- a/calendar.go +++ b/calendar.go @@ -59,6 +59,7 @@ const ( ComponentPropertyDue = ComponentProperty(PropertyDue) ComponentPropertyPercentComplete = ComponentProperty(PropertyPercentComplete) ComponentPropertyTzid = ComponentProperty(PropertyTzid) + ComponentPropertyComment = ComponentProperty(PropertyComment) ) type Property string diff --git a/components.go b/components.go index 234356a..02c8eeb 100644 --- a/components.go +++ b/components.go @@ -278,6 +278,14 @@ func (cb *ComponentBase) AddAttachmentBinary(binary []byte, contentType string) ) } +func (cb *ComponentBase) AddComment(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyComment, s, props...) +} + +func (cb *ComponentBase) AddCategory(s string, props ...PropertyParameter) { + cb.AddProperty(ComponentPropertyCategories, s, props...) +} + type Attendee struct { IANAProperty } From 4b2d6a18937033ca1da5af964b71ce498271d351 Mon Sep 17 00:00:00 2001 From: Sean Slater Date: Thu, 27 Apr 2023 22:09:44 -0400 Subject: [PATCH 05/50] Add changes for All Day Events to All Day Todos --- components.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components.go b/components.go index 87f093e..73a9f84 100644 --- a/components.go +++ b/components.go @@ -529,7 +529,8 @@ func (todo *VTodo) SetCompletedAt(t time.Time, props ...PropertyParameter) { } func (todo *VTodo) SetAllDayCompletedAt(t time.Time, props ...PropertyParameter) { - todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalDateFormatUtc), props...) + props = append(props, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), props...) } func (todo *VTodo) SetDueAt(t time.Time, props ...PropertyParameter) { @@ -537,7 +538,8 @@ func (todo *VTodo) SetDueAt(t time.Time, props ...PropertyParameter) { } func (todo *VTodo) SetAllDayDueAt(t time.Time, props ...PropertyParameter) { - todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalDateFormatUtc), props...) + props = append(props, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), props...) } func (todo *VTodo) SetPercentComplete(p int, props ...PropertyParameter) { From 23c47144679e54aa599f3eccbc6d974c301472e9 Mon Sep 17 00:00:00 2001 From: Manuel Lopez <74105210+ManoloTonto1@users.noreply.github.com> Date: Wed, 12 Jul 2023 19:25:10 +0200 Subject: [PATCH 06/50] added errors.go and converted errors to constants for better error handling --- calendar.go | 18 +++++++++--------- components.go | 22 +++++++++++----------- errors.go | 35 +++++++++++++++++++++++++++++++++++ property.go | 13 +++++++------ 4 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 errors.go diff --git a/calendar.go b/calendar.go index 5b3d7c4..d0a08b8 100644 --- a/calendar.go +++ b/calendar.go @@ -456,10 +456,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { } line, err := ParseProperty(*l) if err != nil { - return nil, fmt.Errorf("parsing line %d: %w", ln, err) + return nil, fmt.Errorf("%s %d: %w", ParsingLineError, ln, err) } if line == nil { - return nil, fmt.Errorf("parsing calendar line %d", ln) + return nil, fmt.Errorf("%s %d", ParsingCalendarLineError, ln) } switch state { case "begin": @@ -469,10 +469,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "properties" default: - return nil, errors.New("malformed calendar; expected a vcalendar") + return nil, errors.New(MalformedCalendarExpectedVCalendarError) } default: - return nil, errors.New("malformed calendar; expected begin") + return nil, errors.New(MalformedCalendarExpectedBeginError) } case "properties": switch line.IANAToken { @@ -481,7 +481,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New("malformed calendar; expected end") + return nil, errors.New(MalformedCalendarExpectedEndError) } case "BEGIN": state = "components" @@ -499,7 +499,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New("malformed calendar; expected end") + return nil, errors.New(MalformedCalendarExpectedEndError) } case "BEGIN": co, err := GeneralParseComponent(cs, line) @@ -510,12 +510,12 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { c.Components = append(c.Components, co) } default: - return nil, errors.New("malformed calendar; expected begin or end") + return nil, errors.New(MalformedCalendarExpectedBeginOrEnd) } case "end": - return nil, errors.New("malformed calendar; unexpected end") + return nil, errors.New(MalformedCalendarUnexpectedEndError) default: - return nil, errors.New("malformed calendar; bad state") + return nil, errors.New(MalformedCalendarBadStateError) } } return c, nil diff --git a/components.go b/components.go index b038448..2db96cd 100644 --- a/components.go +++ b/components.go @@ -156,19 +156,19 @@ func (event *VEvent) SetDuration(d time.Duration) error { return nil } } - return errors.New("start or end not yet defined") + return errors.New(StartOrEndNotYetDefinedError) } func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { timeProp := event.GetProperty(componentProperty) if timeProp == nil { - return time.Time{}, errors.New("property not found") + return time.Time{}, errors.New(PropertyNotFoundError) } timeVal := timeProp.BaseProperty.Value matched := timeStampVariations.FindStringSubmatch(timeVal) if matched == nil { - return time.Time{}, fmt.Errorf("time value not matched, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueNotMatchedError, timeVal) } tOrZGrp := matched[2] zGrp := matched[4] @@ -179,7 +179,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD var propLoc *time.Location if tzIdOk { if len(tzId) != 1 { - return time.Time{}, errors.New("expected only one TZID") + return time.Time{}, errors.New(ExpectedOneTZIDError) } var tzErr error propLoc, tzErr = time.LoadLocation(tzId[0]) @@ -202,7 +202,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButUnsupportedAllDayTimeStampError, timeVal) } if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" { @@ -223,7 +223,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupported, timeVal) } func (event *VEvent) GetStartAt() (time.Time, error) { @@ -519,7 +519,7 @@ func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Compone var co Component switch startLine.Value { case "VCALENDAR": - return nil, errors.New("malformed calendar; vcalendar not where expected") + return nil, errors.New(MalformedCalendarVCalendarNotWhereExpected) case "VEVENT": co = ParseVEvent(cs, startLine) case "VTODO": @@ -660,10 +660,10 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, } line, err := ParseProperty(*l) if err != nil { - return cb, fmt.Errorf("parsing component property %d: %w", ln, err) + return cb, fmt.Errorf("%s %d: %w", ParsingComponentPropertyError, ln, err) } if line == nil { - return cb, errors.New("parsing component line") + return cb, errors.New(ParsingComponentLineError) } switch line.IANAToken { case "END": @@ -671,7 +671,7 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, case startLine.Value: return cb, nil default: - return cb, errors.New("unbalanced end") + return cb, errors.New(UnbalancedEndError) } case "BEGIN": co, err := GeneralParseComponent(cs, line) @@ -685,5 +685,5 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, cb.Properties = append(cb.Properties, IANAProperty{*line}) } } - return cb, errors.New("ran out of lines") + return cb, errors.New(OutOfLinesError) } diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..99bff37 --- /dev/null +++ b/errors.go @@ -0,0 +1,35 @@ +package ics + +const ( + MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" + MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" + MalformedCalendarExpectedEndError = "malformed calendar; expected a end" + MalformedCalendarExpectedBeginOrEnd = "malformed calendar; expected begin or end" + + MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" + MalformedCalendarBadStateError = "malformed calendar; bad state" + MalformedCalendarVCalendarNotWhereExpected = "malformed calendar; vcalendar not where expected" + + StartOrEndNotYetDefinedError = "start or end not yet defined" + PropertyNotFoundError = "property not found" + ExpectedOneTZIDError = "expected one TZID" + + TimeValueNotMatchedError = "time value not matched" + TimeValueMatchedButUnsupportedAllDayTimeStampError = "time value matched but unsupported all-day timestamp" + TimeValueMatchedButNotSupported = "time value matched but not supported" + + ParsingComponentPropertyError = "parsing component property" + ParsingComponentLineError = "parsing component line" + ParsingLineError = "parsing line" + ParsingCalendarLineError = "parsing calendar line" + ParsingPropertyError = "parsing property" + ParseError = "parse error" + + MissingPropertyValueError = "missing property value" + + UnexpectedASCIIChar = "unexpected char ascii" + UnexpectedDoubleQuoteInPropertyParamValue = "unexpected double quote in property param value" + + UnbalancedEndError = "unbalanced end" + OutOfLinesError = "ran out of lines" +) diff --git a/property.go b/property.go index 62418ad..d3c19ab 100644 --- a/property.go +++ b/property.go @@ -2,6 +2,7 @@ package ics import ( "bytes" + "errors" "fmt" "io" "log" @@ -174,7 +175,7 @@ func ParseProperty(contentLine ContentLine) (*BaseProperty, error) { t := r.IANAToken r, np, err = parsePropertyParam(r, string(contentLine), p+1) if err != nil { - return nil, fmt.Errorf("parsing property %s: %w", t, err) + return nil, fmt.Errorf("%s %s: %w", ParsingPropertyError, t, err) } if r == nil { return nil, nil @@ -198,7 +199,7 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper case '=': p += 1 default: - return nil, p, fmt.Errorf("missing property value for %s in %s", k, r.IANAToken) + return nil, p, fmt.Errorf("%s for %s in %s", MissingPropertyValueError, k, r.IANAToken) } for { if p >= len(contentLine) { @@ -207,7 +208,7 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper var err error v, p, err = parsePropertyParamValue(contentLine, p) if err != nil { - return nil, 0, fmt.Errorf("parse error: %w %s in %s", err, k, r.IANAToken) + return nil, 0, fmt.Errorf("%s: %w %s in %s", ParseError, err, k, r.IANAToken) } r.ICalParameters[k] = append(r.ICalParameters[k], v) switch rune(contentLine[p]) { @@ -253,10 +254,10 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { for ; p < len(s) && !done; p++ { switch s[p] { case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08: - return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F: - return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) case '\\': r = append(r, []byte(FromText(string(s[p+1:p+2])))...) p++ @@ -276,7 +277,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { done = true continue } - return "", 0, fmt.Errorf("unexpected double quote in property param value") + return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValue) } r = append(r, s[p]) } From b69874f876295aa0faa2a508b9c6a689071ade72 Mon Sep 17 00:00:00 2001 From: Manuel Lopez <74105210+ManoloTonto1@users.noreply.github.com> Date: Wed, 12 Jul 2023 19:26:46 +0200 Subject: [PATCH 07/50] fixed some namings --- calendar.go | 2 +- components.go | 4 ++-- errors.go | 20 ++++++++++---------- property.go | 6 +++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/calendar.go b/calendar.go index d0a08b8..d91219e 100644 --- a/calendar.go +++ b/calendar.go @@ -510,7 +510,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { c.Components = append(c.Components, co) } default: - return nil, errors.New(MalformedCalendarExpectedBeginOrEnd) + return nil, errors.New(MalformedCalendarExpectedBeginOrEndError) } case "end": return nil, errors.New(MalformedCalendarUnexpectedEndError) diff --git a/components.go b/components.go index 2db96cd..c588adc 100644 --- a/components.go +++ b/components.go @@ -223,7 +223,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupported, timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupportedError, timeVal) } func (event *VEvent) GetStartAt() (time.Time, error) { @@ -519,7 +519,7 @@ func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Compone var co Component switch startLine.Value { case "VCALENDAR": - return nil, errors.New(MalformedCalendarVCalendarNotWhereExpected) + return nil, errors.New(MalformedCalendarVCalendarNotWhereExpectedError) case "VEVENT": co = ParseVEvent(cs, startLine) case "VTODO": diff --git a/errors.go b/errors.go index 99bff37..4a74708 100644 --- a/errors.go +++ b/errors.go @@ -1,14 +1,14 @@ package ics const ( - MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" - MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" - MalformedCalendarExpectedEndError = "malformed calendar; expected a end" - MalformedCalendarExpectedBeginOrEnd = "malformed calendar; expected begin or end" + MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" + MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" + MalformedCalendarExpectedEndError = "malformed calendar; expected a end" + MalformedCalendarExpectedBeginOrEndError = "malformed calendar; expected begin or end" - MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" - MalformedCalendarBadStateError = "malformed calendar; bad state" - MalformedCalendarVCalendarNotWhereExpected = "malformed calendar; vcalendar not where expected" + MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" + MalformedCalendarBadStateError = "malformed calendar; bad state" + MalformedCalendarVCalendarNotWhereExpectedError = "malformed calendar; vcalendar not where expected" StartOrEndNotYetDefinedError = "start or end not yet defined" PropertyNotFoundError = "property not found" @@ -16,7 +16,7 @@ const ( TimeValueNotMatchedError = "time value not matched" TimeValueMatchedButUnsupportedAllDayTimeStampError = "time value matched but unsupported all-day timestamp" - TimeValueMatchedButNotSupported = "time value matched but not supported" + TimeValueMatchedButNotSupportedError = "time value matched but not supported" ParsingComponentPropertyError = "parsing component property" ParsingComponentLineError = "parsing component line" @@ -27,8 +27,8 @@ const ( MissingPropertyValueError = "missing property value" - UnexpectedASCIIChar = "unexpected char ascii" - UnexpectedDoubleQuoteInPropertyParamValue = "unexpected double quote in property param value" + UnexpectedASCIICharError = "unexpected char ascii" + UnexpectedDoubleQuoteInPropertyParamValueError = "unexpected double quote in property param value" UnbalancedEndError = "unbalanced end" OutOfLinesError = "ran out of lines" diff --git a/property.go b/property.go index d3c19ab..0bb1c03 100644 --- a/property.go +++ b/property.go @@ -254,10 +254,10 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { for ; p < len(s) && !done; p++ { switch s[p] { case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) case '\\': r = append(r, []byte(FromText(string(s[p+1:p+2])))...) p++ @@ -277,7 +277,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { done = true continue } - return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValue) + return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValueError) } r = append(r, s[p]) } From 7d97e962e6c4d19b260ef48a3af5c3b0d482f573 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Sep 2023 23:40:32 +0000 Subject: [PATCH 08/50] Bump gopkg.in/yaml.v3 from 3.0.0-20210107192922-496545a6307b to 3.0.0 Bumps gopkg.in/yaml.v3 from 3.0.0-20210107192922-496545a6307b to 3.0.0. --- updated-dependencies: - dependency-name: gopkg.in/yaml.v3 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f2e229b..8daa44b 100644 --- a/go.mod +++ b/go.mod @@ -8,5 +8,5 @@ require ( github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/stretchr/testify v1.7.0 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + gopkg.in/yaml.v3 v3.0.0 // indirect ) diff --git a/go.sum b/go.sum index 6f1958c..a945f38 100644 --- a/go.sum +++ b/go.sum @@ -17,5 +17,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 0abebc2aaab5000641780ab3642496d8425cb74d Mon Sep 17 00:00:00 2001 From: Galen Warren <784517+galenwarren@users.noreply.github.com> Date: Wed, 8 Nov 2023 10:15:51 -0500 Subject: [PATCH 09/50] Add VEvent.GetDtStampTime --- components.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components.go b/components.go index b038448..f7dc05f 100644 --- a/components.go +++ b/components.go @@ -242,6 +242,10 @@ func (event *VEvent) GetAllDayEndAt() (time.Time, error) { return event.getTimeProp(ComponentPropertyDtEnd, true) } +func (event *VEvent) GetDtStampTime() (time.Time, error) { + return event.getTimeProp(ComponentPropertyDtstamp, false) +} + type TimeTransparency string const ( From a0f11e39256c9450c8bdec1540a5bc4393848eda Mon Sep 17 00:00:00 2001 From: zachmann Date: Thu, 23 Nov 2023 15:13:07 +0100 Subject: [PATCH 10/50] add possibility to remove VEvent from calendar by id --- calendar.go | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/calendar.go b/calendar.go index 5b3d7c4..acb2b7f 100644 --- a/calendar.go +++ b/calendar.go @@ -408,7 +408,12 @@ func NewEvent(uniqueId string) *VEvent { e := &VEvent{ ComponentBase{ Properties: []IANAProperty{ - {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, + { + BaseProperty{ + IANAToken: ToText(string(ComponentPropertyUniqueId)), + Value: uniqueId, + }, + }, }, }, } @@ -425,6 +430,23 @@ func (calendar *Calendar) AddVEvent(e *VEvent) { calendar.Components = append(calendar.Components, e) } +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 + } + } + } + return +} + func (calendar *Calendar) Events() (r []*VEvent) { r = []*VEvent{} for i := range calendar.Components { From 70f69c83b7c6c6ceb811518b84d11d50e9074b1f Mon Sep 17 00:00:00 2001 From: zachmann Date: Thu, 23 Nov 2023 15:22:40 +0100 Subject: [PATCH 11/50] undo formatting change --- calendar.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/calendar.go b/calendar.go index acb2b7f..1edd85d 100644 --- a/calendar.go +++ b/calendar.go @@ -408,12 +408,7 @@ func NewEvent(uniqueId string) *VEvent { e := &VEvent{ ComponentBase{ Properties: []IANAProperty{ - { - BaseProperty{ - IANAToken: ToText(string(ComponentPropertyUniqueId)), - Value: uniqueId, - }, - }, + {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, }, }, } From 1dfe73fc2580f930a90bc9f4bc0063061b02dc07 Mon Sep 17 00:00:00 2001 From: zachmann Date: Fri, 24 Nov 2023 09:00:34 +0100 Subject: [PATCH 12/50] remove redundant return statement --- calendar.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/calendar.go b/calendar.go index 1edd85d..9ab15d3 100644 --- a/calendar.go +++ b/calendar.go @@ -408,7 +408,12 @@ func NewEvent(uniqueId string) *VEvent { e := &VEvent{ ComponentBase{ Properties: []IANAProperty{ - {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, + { + BaseProperty{ + IANAToken: ToText(string(ComponentPropertyUniqueId)), + Value: uniqueId, + }, + }, }, }, } @@ -439,7 +444,6 @@ func (calendar *Calendar) RemoveEvent(id string) { } } } - return } func (calendar *Calendar) Events() (r []*VEvent) { From e69ce9c63269e207775f9831994d08fd3505d84e Mon Sep 17 00:00:00 2001 From: Daniel Lublin Date: Mon, 4 Dec 2023 15:22:00 +0100 Subject: [PATCH 13/50] Add ComponentPropertyPriority; the VTODO component can have that --- calendar.go | 1 + 1 file changed, 1 insertion(+) diff --git a/calendar.go b/calendar.go index 9ab15d3..5e2a1bb 100644 --- a/calendar.go +++ b/calendar.go @@ -53,6 +53,7 @@ const ( ComponentPropertyRrule = ComponentProperty(PropertyRrule) ComponentPropertyAction = ComponentProperty(PropertyAction) ComponentPropertyTrigger = ComponentProperty(PropertyTrigger) + ComponentPropertyPriority = ComponentProperty(PropertyPriority) ) type Property string From 8525f6ba7d7e11e6b28c81f61d44bb80ab49603f Mon Sep 17 00:00:00 2001 From: Brandon Sprague Date: Mon, 18 Dec 2023 08:44:45 -0800 Subject: [PATCH 14/50] Add {Get,Set}LastModifiedAt Adding some helpers for parsing another timestamp field. I originally considered exposing `getTimeProp` as `ParseTimeProp`, but the ergonomics are weird enough (is the prop nil? should it be all-day-able?) that I decided against it. --- components.go | 8 ++++++++ components_test.go | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/components.go b/components.go index f7dc05f..2914a69 100644 --- a/components.go +++ b/components.go @@ -139,6 +139,10 @@ func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) { event.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), props...) } +func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) { + event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) +} + // SetDuration updates the duration of an event. // This function will set either the end or start time of an event depending what is already given. // The duration defines the length of a event relative to start or end time. @@ -234,6 +238,10 @@ func (event *VEvent) GetEndAt() (time.Time, error) { return event.getTimeProp(ComponentPropertyDtEnd, false) } +func (event *VEvent) GetLastModifiedAt() (time.Time, error) { + return event.getTimeProp(ComponentPropertyLastModified, false) +} + func (event *VEvent) GetAllDayStartAt() (time.Time, error) { return event.getTimeProp(ComponentPropertyDtStart, true) } diff --git a/components_test.go b/components_test.go index d7d8902..d42a83e 100644 --- a/components_test.go +++ b/components_test.go @@ -94,3 +94,17 @@ END:VEVENT }) } } + +func TestGetLastModifiedAt(t *testing.T) { + e := NewEvent("test-last-modified") + lastModified := time.Unix(123456789, 0) + e.SetLastModifiedAt(lastModified) + got, err := e.GetLastModifiedAt() + if err != nil { + t.Fatalf("e.GetLastModifiedAt: %v", err) + } + + if !got.Equal(lastModified) { + t.Errorf("got last modified = %q, want %q", got, lastModified) + } +} From 5f3bef9829df7253ceef3087dc29e3d9bd073dab Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 8 Jan 2024 15:56:48 +1100 Subject: [PATCH 15/50] Unnecessary functions causing lint recursion errors. --- components.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/components.go b/components.go index 488c6d8..74d6864 100644 --- a/components.go +++ b/components.go @@ -424,10 +424,6 @@ func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetLocation(s string, props ...PropertyParameter) { - event.SetLocation(s, props...) -} - func (event *VEvent) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { event.setGeo(lat, lng, props...) } @@ -555,10 +551,6 @@ func (todo *VTodo) SetPercentComplete(p int, props ...PropertyParameter) { todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), props...) } -func (todo *VTodo) SetLocation(s string, props ...PropertyParameter) { - todo.SetLocation(s, props...) -} - func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { todo.setGeo(lat, lng, props...) } From 55df13ec27738206ce0973a2046bb625c40ada72 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 8 Jan 2024 15:57:44 +1100 Subject: [PATCH 16/50] Consistent todo receiver --- components.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components.go b/components.go index 74d6864..a367811 100644 --- a/components.go +++ b/components.go @@ -491,13 +491,13 @@ type VTodo struct { ComponentBase } -func (c *VTodo) serialize(w io.Writer) { - c.ComponentBase.serializeThis(w, "VTODO") +func (todo *VTodo) serialize(w io.Writer) { + todo.ComponentBase.serializeThis(w, "VTODO") } -func (c *VTodo) Serialize() string { +func (todo *VTodo) Serialize() string { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VTODO") + todo.ComponentBase.serializeThis(b, "VTODO") return b.String() } From 9e30bdf5f06c08661809d4195ec05e138458b6e7 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 8 Jan 2024 15:58:10 +1100 Subject: [PATCH 17/50] Ignore explicitly --- components.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components.go b/components.go index a367811..f2557ab 100644 --- a/components.go +++ b/components.go @@ -32,14 +32,14 @@ func (cb *ComponentBase) SubComponents() []Component { } func (base ComponentBase) serializeThis(writer io.Writer, componentType string) { - fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") + _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") for _, p := range base.Properties { p.serialize(writer) } for _, c := range base.Components { c.serialize(writer) } - fmt.Fprint(writer, "END:"+componentType, "\r\n") + _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") } func NewComponent(uniqueId string) ComponentBase { From 1e96c159570cc454f2c4a5318b7ac3c091014f23 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 8 Jan 2024 15:58:23 +1100 Subject: [PATCH 18/50] Consistent receivers. --- components.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components.go b/components.go index f2557ab..7713239 100644 --- a/components.go +++ b/components.go @@ -31,12 +31,12 @@ func (cb *ComponentBase) SubComponents() []Component { return cb.Components } -func (base ComponentBase) serializeThis(writer io.Writer, componentType string) { +func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") - for _, p := range base.Properties { + for _, p := range cb.Properties { p.serialize(writer) } - for _, c := range base.Components { + for _, c := range cb.Components { c.serialize(writer) } _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") From cf8d1b371d4511c6842a8182ad17ba90e9396186 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 8 Jan 2024 15:59:24 +1100 Subject: [PATCH 19/50] Ignore explicitly --- calendar.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/calendar.go b/calendar.go index 7f21a86..e7f6ca0 100644 --- a/calendar.go +++ b/calendar.go @@ -305,14 +305,14 @@ func (calendar *Calendar) Serialize() string { } func (calendar *Calendar) SerializeTo(w io.Writer) error { - fmt.Fprint(w, "BEGIN:VCALENDAR", "\r\n") + _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", "\r\n") for _, p := range calendar.CalendarProperties { p.serialize(w) } for _, c := range calendar.Components { c.serialize(w) } - fmt.Fprint(w, "END:VCALENDAR", "\r\n") + _, _ = fmt.Fprint(w, "END:VCALENDAR", "\r\n") return nil } @@ -534,7 +534,7 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) { if len(p) == 0 { c = false } else if p[0] == ' ' || p[0] == '\t' { - cs.b.Discard(1) // nolint:errcheck + _, _ = cs.b.Discard(1) // nolint:errcheck } else { c = false } From 681cc6e62c0347b7296fcf70b77613af7761dc2f Mon Sep 17 00:00:00 2001 From: Bracken Dawson Date: Sat, 27 Jan 2024 19:20:46 +0000 Subject: [PATCH 20/50] fix panic for missing property param operator --- calendar_test.go | 15 ++++++++++++++- property.go | 3 +++ testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af diff --git a/calendar_test.go b/calendar_test.go index abbd39b..aca4cce 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -1,7 +1,7 @@ package ics import ( - "github.com/stretchr/testify/assert" + "bytes" "io" "io/ioutil" "os" @@ -11,6 +11,9 @@ import ( "testing" "time" "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTimeParsing(t *testing.T) { @@ -360,3 +363,13 @@ func TestIssue52(t *testing.T) { t.Fatalf("cannot read test directory: %v", err) } } + +func FuzzParseCalendar(f *testing.F) { + ics, err := os.ReadFile("testdata/timeparsing.ics") + require.NoError(f, err) + f.Add(ics) + f.Fuzz(func(t *testing.T, ics []byte) { + _, err := ParseCalendar(bytes.NewReader(ics)) + t.Log(err) + }) +} diff --git a/property.go b/property.go index 62418ad..da648f6 100644 --- a/property.go +++ b/property.go @@ -194,6 +194,9 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper k, v := "", "" k = string(contentLine[p : p+tokenPos[1]]) p += tokenPos[1] + if p >= len(contentLine) { + return nil, p, fmt.Errorf("missing property param operator for %s in %s", k, r.IANAToken) + } switch rune(contentLine[p]) { case '=': p += 1 diff --git a/testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af b/testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af new file mode 100644 index 0000000..4fbd2fc --- /dev/null +++ b/testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0;0") From 3631125a31b5e5a706ba2d4845b5e9b2b5f6523f Mon Sep 17 00:00:00 2001 From: Bracken Dawson Date: Sat, 27 Jan 2024 19:35:37 +0000 Subject: [PATCH 21/50] fix panic when property ends without colon or value --- property.go | 3 +++ testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6 | 2 ++ 2 files changed, 5 insertions(+) create mode 100644 testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6 diff --git a/property.go b/property.go index da648f6..7a16203 100644 --- a/property.go +++ b/property.go @@ -213,6 +213,9 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper return nil, 0, fmt.Errorf("parse error: %w %s in %s", err, k, r.IANAToken) } r.ICalParameters[k] = append(r.ICalParameters[k], v) + if p >= len(contentLine) { + return nil, p, fmt.Errorf("unexpected end of property %s", r.IANAToken) + } switch rune(contentLine[p]) { case ',': p += 1 diff --git a/testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6 b/testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6 new file mode 100644 index 0000000..84d7974 --- /dev/null +++ b/testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0;0=0") From a8f0586c9068f1577128e98214a7c9d359dfcdd9 Mon Sep 17 00:00:00 2001 From: Bracken Dawson Date: Sat, 27 Jan 2024 19:49:41 +0000 Subject: [PATCH 22/50] fix panic when param value has incomplete escape sequence --- property.go | 4 ++++ testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30 | 2 ++ 2 files changed, 6 insertions(+) create mode 100644 testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30 diff --git a/property.go b/property.go index 7a16203..5855896 100644 --- a/property.go +++ b/property.go @@ -2,6 +2,7 @@ package ics import ( "bytes" + "errors" "fmt" "io" "log" @@ -264,6 +265,9 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { 0x1C, 0x1D, 0x1E, 0x1F: return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p]) case '\\': + if p+2 >= len(s) { + return "", 0, errors.New("unexpected end of param value") + } r = append(r, []byte(FromText(string(s[p+1:p+2])))...) p++ continue diff --git a/testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30 b/testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30 new file mode 100644 index 0000000..9daedbd --- /dev/null +++ b/testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0;0=\\") From 46e2a5c0ed8c575780ff6f50bef9b7a8648f16e7 Mon Sep 17 00:00:00 2001 From: Bracken Dawson Date: Sun, 28 Jan 2024 12:58:24 +0000 Subject: [PATCH 23/50] Exclude fuzz testing from pre-1.18 toolchains --- calendar_fuzz_test.go | 22 ++++++++++++++++++++++ calendar_test.go | 15 +-------------- 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 calendar_fuzz_test.go diff --git a/calendar_fuzz_test.go b/calendar_fuzz_test.go new file mode 100644 index 0000000..8d3f717 --- /dev/null +++ b/calendar_fuzz_test.go @@ -0,0 +1,22 @@ +//go:build go1.18 +// +build go1.18 + +package ics + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func FuzzParseCalendar(f *testing.F) { + ics, err := os.ReadFile("testdata/timeparsing.ics") + require.NoError(f, err) + f.Add(ics) + f.Fuzz(func(t *testing.T, ics []byte) { + _, err := ParseCalendar(bytes.NewReader(ics)) + t.Log(err) + }) +} diff --git a/calendar_test.go b/calendar_test.go index aca4cce..abbd39b 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -1,7 +1,7 @@ package ics import ( - "bytes" + "github.com/stretchr/testify/assert" "io" "io/ioutil" "os" @@ -11,9 +11,6 @@ import ( "testing" "time" "unicode/utf8" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestTimeParsing(t *testing.T) { @@ -363,13 +360,3 @@ func TestIssue52(t *testing.T) { t.Fatalf("cannot read test directory: %v", err) } } - -func FuzzParseCalendar(f *testing.F) { - ics, err := os.ReadFile("testdata/timeparsing.ics") - require.NoError(f, err) - f.Add(ics) - f.Fuzz(func(t *testing.T, ics []byte) { - _, err := ParseCalendar(bytes.NewReader(ics)) - t.Log(err) - }) -} From 3ffa099f553cee70045fefbd8303743f72ddc0cc Mon Sep 17 00:00:00 2001 From: Frederik Reiter Date: Fri, 9 Feb 2024 18:44:13 +0100 Subject: [PATCH 24/50] fix: only escape property values when serializing. This commit normalizes the meaning of "property.Value". It always contains an unserialized property value. Previously, property params were deserialized when parsing but not serialized again. --- calendar.go | 8 ++++---- components.go | 8 ++++---- property.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/calendar.go b/calendar.go index e7f6ca0..311e952 100644 --- a/calendar.go +++ b/calendar.go @@ -317,7 +317,7 @@ func (calendar *Calendar) SerializeTo(w io.Writer) error { } func (calendar *Calendar) SetMethod(method Method, props ...PropertyParameter) { - calendar.setProperty(PropertyMethod, ToText(string(method)), props...) + calendar.setProperty(PropertyMethod, string(method), props...) } func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) { @@ -325,11 +325,11 @@ func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) } func (calendar *Calendar) SetVersion(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyVersion, ToText(s), props...) + calendar.setProperty(PropertyVersion, s, props...) } func (calendar *Calendar) SetProductId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyProductId, ToText(s), props...) + calendar.setProperty(PropertyProductId, s, props...) } func (calendar *Calendar) SetName(s string, props ...PropertyParameter) { @@ -358,7 +358,7 @@ func (calendar *Calendar) SetXWRCalID(s string, props ...PropertyParameter) { } func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyDescription, ToText(s), props...) + calendar.setProperty(PropertyDescription, s, props...) } func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) { diff --git a/components.go b/components.go index 7713239..9ee85a2 100644 --- a/components.go +++ b/components.go @@ -214,19 +214,19 @@ func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { } func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertySummary, ToText(s), props...) + cb.SetProperty(ComponentPropertySummary, s, props...) } func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) + cb.SetProperty(ComponentPropertyStatus, string(s), props...) } func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDescription, ToText(s), props...) + cb.SetProperty(ComponentPropertyDescription, s, props...) } func (cb *ComponentBase) SetLocation(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyLocation, ToText(s), props...) + cb.SetProperty(ComponentPropertyLocation, s, props...) } func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { diff --git a/property.go b/property.go index 5855896..1c2f33e 100644 --- a/property.go +++ b/property.go @@ -109,7 +109,7 @@ func (property *BaseProperty) serialize(w io.Writer) { } } fmt.Fprint(b, ":") - fmt.Fprint(b, property.Value) + fmt.Fprint(b, ToText(property.Value)) r := b.String() if len(r) > 75 { l := trimUT8StringUpTo(75, r) From 0f8a32553bf227a97bf4b1550b7407f2c6337dc2 Mon Sep 17 00:00:00 2001 From: Frederik Reiter Date: Fri, 9 Feb 2024 18:45:27 +0100 Subject: [PATCH 25/50] fix: reorder string replaces in escaping values. Previously, ";" was incorrectly escaped. First, ";" was replaced by "\;". Then, the backslash was escpaed to "\\", resulting in "\\;". Now, we first escape all backslashes and then escape other characters. --- property.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/property.go b/property.go index 1c2f33e..7c4c7ce 100644 --- a/property.go +++ b/property.go @@ -99,9 +99,9 @@ func (property *BaseProperty) serialize(w io.Writer) { fmt.Fprint(b, ",") } if strings.ContainsAny(v, ";:\\\",") { + v = strings.Replace(v, "\\", "\\\\", -1) v = strings.Replace(v, ";", "\\;", -1) v = strings.Replace(v, ":", "\\:", -1) - v = strings.Replace(v, "\\", "\\\\", -1) v = strings.Replace(v, "\"", "\\\"", -1) v = strings.Replace(v, ",", "\\,", -1) } From 647cf9ea6dd29eece2fdc95653b2d4b8af9526a2 Mon Sep 17 00:00:00 2001 From: Frederik Reiter Date: Tue, 13 Feb 2024 14:33:55 +0100 Subject: [PATCH 26/50] test: Add test for escaped semicolons in property parameters --- calendar_test.go | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/calendar_test.go b/calendar_test.go index abbd39b..38b1b73 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -1,7 +1,6 @@ package ics import ( - "github.com/stretchr/testify/assert" "io" "io/ioutil" "os" @@ -11,6 +10,8 @@ import ( "testing" "time" "unicode/utf8" + + "github.com/stretchr/testify/assert" ) func TestTimeParsing(t *testing.T) { @@ -315,6 +316,31 @@ DESCRIPTION:blablablablablablablablablablablablablablablabltesttesttest CLASS:PUBLIC END:VEVENT END:VCALENDAR +`, + }, + { + name: "test semicolon in attendee property parameter", + input: `BEGIN:VCALENDAR +VERSION:2.0 +X-CUSTOM-FIELD:test +PRODID:-//arran4//Golang ICS Library +DESCRIPTION:test +BEGIN:VEVENT +ATTENDEE;CN=Test\;User:mailto:user@example.com +CLASS:PUBLIC +END:VEVENT +END:VCALENDAR +`, + output: `BEGIN:VCALENDAR +VERSION:2.0 +X-CUSTOM-FIELD:test +PRODID:-//arran4//Golang ICS Library +DESCRIPTION:test +BEGIN:VEVENT +ATTENDEE;CN=Test\;User:mailto:user@example.com +CLASS:PUBLIC +END:VEVENT +END:VCALENDAR `, }, } From 7bd8708c269f52ba5a34021df470ae340c09bfcf Mon Sep 17 00:00:00 2001 From: Frederik Reiter Date: Thu, 15 Feb 2024 18:13:56 +0100 Subject: [PATCH 27/50] test: Add test for escaped semicolons in RRULEs --- calendar_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/calendar_test.go b/calendar_test.go index 38b1b73..b2658aa 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -341,6 +341,31 @@ ATTENDEE;CN=Test\;User:mailto:user@example.com CLASS:PUBLIC END:VEVENT END:VCALENDAR +`, + }, + { + name: "test RRULE escaping", + input: `BEGIN:VCALENDAR +VERSION:2.0 +X-CUSTOM-FIELD:test +PRODID:-//arran4//Golang ICS Library +DESCRIPTION:test +BEGIN:VEVENT +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=SU +CLASS:PUBLIC +END:VEVENT +END:VCALENDAR +`, + output: `BEGIN:VCALENDAR +VERSION:2.0 +X-CUSTOM-FIELD:test +PRODID:-//arran4//Golang ICS Library +DESCRIPTION:test +BEGIN:VEVENT +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=SU +CLASS:PUBLIC +END:VEVENT +END:VCALENDAR `, }, } From bb39d64d8ff895b2bd9397d88873db0ea409d566 Mon Sep 17 00:00:00 2001 From: Frederik Reiter Date: Thu, 15 Feb 2024 18:14:17 +0100 Subject: [PATCH 28/50] Revert "fix: only escape property values when serializing." This reverts commit 3ffa099f553cee70045fefbd8303743f72ddc0cc. --- calendar.go | 8 ++++---- components.go | 8 ++++---- property.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/calendar.go b/calendar.go index 311e952..e7f6ca0 100644 --- a/calendar.go +++ b/calendar.go @@ -317,7 +317,7 @@ func (calendar *Calendar) SerializeTo(w io.Writer) error { } func (calendar *Calendar) SetMethod(method Method, props ...PropertyParameter) { - calendar.setProperty(PropertyMethod, string(method), props...) + calendar.setProperty(PropertyMethod, ToText(string(method)), props...) } func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) { @@ -325,11 +325,11 @@ func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) } func (calendar *Calendar) SetVersion(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyVersion, s, props...) + calendar.setProperty(PropertyVersion, ToText(s), props...) } func (calendar *Calendar) SetProductId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyProductId, s, props...) + calendar.setProperty(PropertyProductId, ToText(s), props...) } func (calendar *Calendar) SetName(s string, props ...PropertyParameter) { @@ -358,7 +358,7 @@ func (calendar *Calendar) SetXWRCalID(s string, props ...PropertyParameter) { } func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyDescription, s, props...) + calendar.setProperty(PropertyDescription, ToText(s), props...) } func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) { diff --git a/components.go b/components.go index 9ee85a2..7713239 100644 --- a/components.go +++ b/components.go @@ -214,19 +214,19 @@ func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { } func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertySummary, s, props...) + cb.SetProperty(ComponentPropertySummary, ToText(s), props...) } func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyStatus, string(s), props...) + cb.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) } func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDescription, s, props...) + cb.SetProperty(ComponentPropertyDescription, ToText(s), props...) } func (cb *ComponentBase) SetLocation(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyLocation, s, props...) + cb.SetProperty(ComponentPropertyLocation, ToText(s), props...) } func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { diff --git a/property.go b/property.go index 7c4c7ce..f03a260 100644 --- a/property.go +++ b/property.go @@ -109,7 +109,7 @@ func (property *BaseProperty) serialize(w io.Writer) { } } fmt.Fprint(b, ":") - fmt.Fprint(b, ToText(property.Value)) + fmt.Fprint(b, property.Value) r := b.String() if len(r) > 75 { l := trimUT8StringUpTo(75, r) From f5b4408f0fe2fe130df97e348fe4f23186bf7aa2 Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Tue, 27 Feb 2024 16:11:31 +0530 Subject: [PATCH 29/50] Prefix mailto: for email in organizer property --- components.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components.go b/components.go index 7713239..7c808b3 100644 --- a/components.go +++ b/components.go @@ -238,7 +238,7 @@ func (cb *ComponentBase) SetURL(s string, props ...PropertyParameter) { } func (cb *ComponentBase) SetOrganizer(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyOrganizer, s, props...) + cb.SetProperty(ComponentPropertyOrganizer, "mailto:"+s, props...) } func (cb *ComponentBase) SetColor(s string, props ...PropertyParameter) { From 93ca35ff07a9bf419eb5f7423dc6e5cc872d0482 Mon Sep 17 00:00:00 2001 From: Abin Simon Date: Mon, 4 Mar 2024 15:20:19 +0530 Subject: [PATCH 30/50] Only conditionally add in the mailto: prefix for attendee and organizer --- components.go | 12 ++++++++++-- components_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/components.go b/components.go index 7c808b3..71d4e96 100644 --- a/components.go +++ b/components.go @@ -238,7 +238,11 @@ func (cb *ComponentBase) SetURL(s string, props ...PropertyParameter) { } func (cb *ComponentBase) SetOrganizer(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyOrganizer, "mailto:"+s, props...) + if !strings.HasPrefix(s, "mailto:") { + s = "mailto:" + s + } + + cb.SetProperty(ComponentPropertyOrganizer, s, props...) } func (cb *ComponentBase) SetColor(s string, props ...PropertyParameter) { @@ -258,7 +262,11 @@ func (cb *ComponentBase) setResources(r string, props ...PropertyParameter) { } func (cb *ComponentBase) AddAttendee(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyAttendee, "mailto:"+s, props...) + if !strings.HasPrefix(s, "mailto:") { + s = "mailto:" + s + } + + cb.AddProperty(ComponentPropertyAttendee, s, props...) } func (cb *ComponentBase) AddExdate(s string, props ...PropertyParameter) { diff --git a/components_test.go b/components_test.go index d42a83e..bb38585 100644 --- a/components_test.go +++ b/components_test.go @@ -108,3 +108,27 @@ func TestGetLastModifiedAt(t *testing.T) { t.Errorf("got last modified = %q, want %q", got, lastModified) } } + +func TestSetMailtoPrefix(t *testing.T) { + e := NewEvent("test-set-organizer") + + e.SetOrganizer("org1@provider.com") + if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org1@provider.com") { + t.Errorf("expected single mailto: prefix for email org1") + } + + e.SetOrganizer("mailto:org2@provider.com") + if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org2@provider.com") { + t.Errorf("expected single mailto: prefix for email org2") + } + + e.AddAttendee("att1@provider.com") + if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att1@provider.com") { + t.Errorf("expected single mailto: prefix for email att1") + } + + e.AddAttendee("mailto:att2@provider.com") + if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att2@provider.com") { + t.Errorf("expected single mailto: prefix for email att2") + } +} From 02c63354dc7614411c44eb466822dd5744cc73b2 Mon Sep 17 00:00:00 2001 From: zachmann Date: Thu, 11 Apr 2024 06:36:19 +0200 Subject: [PATCH 31/50] Re-add Calendar.RemoveEvent method --- components.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components.go b/components.go index 71d4e96..3931825 100644 --- a/components.go +++ b/components.go @@ -413,6 +413,22 @@ func (calendar *Calendar) AddVEvent(e *VEvent) { calendar.Components = append(calendar.Components, e) } +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 (calendar *Calendar) Events() (r []*VEvent) { r = []*VEvent{} for i := range calendar.Components { From 1e5b6e40adb9966127c09ee67966878f5ac6fe7b Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 20:27:51 -0700 Subject: [PATCH 32/50] add: contributing.md --- CONTRIBUTING.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..87d0e00 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,17 @@ +# Contributing + +## Linting +Make sure your code has been linted using [golangci-lint](https://github.com/golangci/golangci-lint?tab=readme-ov-file#install-golangci-lint) + +```shell +$ golangci-lint run +``` + +## Tests + +If you want to submit a bug fix or new feature, make sure that all tests are passing. +```shell +$ go test ./... +``` + + From 27bb2dde85660ba1bc79a6123c4b230d236d022e Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 20:47:59 -0700 Subject: [PATCH 33/50] fix: simplify & update ghas --- .github/workflows/golangci-lint.yml | 20 +++++++------------- .github/workflows/test.yml | 27 ++++++++++----------------- 2 files changed, 17 insertions(+), 30 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index fcf128a..596edad 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,21 +9,15 @@ on: pull_request: jobs: golangci: - name: lint + name: Lint runs-on: ubuntu-latest + permissions: + contents: read # allow read access to the content for analysis. + checks: write # allow write access to checks to allow the action to annotate code in the PR. steps: - - uses: actions/checkout@v2 - - name: Cache-Go - uses: actions/cache@v1 - with: - path: | - ~/go/pkg/mod # Module download cache - ~/.cache/go-build # Build cache (Linux) - ~/Library/Caches/go-build # Build cache (Mac) - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + - name: Checkout + uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v6 with: version: latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 05effc0..07ae067 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,27 +2,20 @@ on: [push, pull_request] name: Test jobs: test: + name: Test + permissions: + contents: read strategy: matrix: - go-version: [1.14.x, 1.15.x, 1.16.x, 1.17.x] + go-version: ['1.14', '1.15', '1.16', '1.17', '1.22'] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - name: Install Go - uses: actions/setup-go@v2 + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Golang + uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go-version }} - - name: Cache-Go - uses: actions/cache@v1 - with: - path: | - ~/go/pkg/mod # Module download cache - ~/.cache/go-build # Build cache (Linux) - ~/Library/Caches/go-build # Build cache (Mac) - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - - name: Checkout code - uses: actions/checkout@v2 - - name: Test + go-version: "${{ matrix.go-version }}" + - name: Go Test run: go test ./... From 909bd3b609dd1a6f91c1832c8b3eb4bde0726524 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 22:13:23 -0700 Subject: [PATCH 34/50] fix: explicitly list golang versions --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07ae067..d8f8986 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: contents: read strategy: matrix: - go-version: ['1.14', '1.15', '1.16', '1.17', '1.22'] + go-version: ['1.14.15', '1.15.15', '1.16.15', '1.17.13', '1.22.3'] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: From 3e4aba3bf61befd362919aca69537d03a002f44a Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 22:48:33 -0700 Subject: [PATCH 35/50] fix: use macos-13, arm is not supported by setup-go --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8f8986..9fdae94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: go-version: ['1.14.15', '1.15.15', '1.16.15', '1.17.13', '1.22.3'] - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-13, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout From 008589d55143f2382773ef21b3b97b72a5980544 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 15:15:24 -0700 Subject: [PATCH 36/50] add: stable serialization of property parameters --- property.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/property.go b/property.go index f03a260..388160b 100644 --- a/property.go +++ b/property.go @@ -7,6 +7,7 @@ import ( "io" "log" "regexp" + "sort" "strconv" "strings" "unicode/utf8" @@ -90,7 +91,14 @@ func trimUT8StringUpTo(maxLength int, s string) string { func (property *BaseProperty) serialize(w io.Writer) { b := bytes.NewBufferString("") fmt.Fprint(b, property.IANAToken) - for k, vs := range property.ICalParameters { + + var keys []string + for k, _ := range property.ICalParameters { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + vs := property.ICalParameters[k] fmt.Fprint(b, ";") fmt.Fprint(b, k) fmt.Fprint(b, "=") From baa1c1dcadb0a1d370f646705ee128af0654a46a Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 12:20:16 -0700 Subject: [PATCH 37/50] add: serialiation test --- .gitignore | 1 + calendar_serialization_test.go | 69 ++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + testdata/serialization/expected/input1.ics | 16 +++++ testdata/serialization/expected/input2.ics | 34 +++++++++++ testdata/serialization/expected/input3.ics | 21 +++++++ testdata/serialization/expected/input4.ics | 20 +++++++ testdata/serialization/expected/input5.ics | 20 +++++++ testdata/serialization/expected/input6.ics | 13 ++++ testdata/serialization/expected/input7.ics | 16 +++++ testdata/serialization/input1.ics | 17 ++++++ testdata/serialization/input2.ics | 34 +++++++++++ testdata/serialization/input3.ics | 22 +++++++ testdata/serialization/input4.ics | 23 ++++++++ testdata/serialization/input5.ics | 23 ++++++++ testdata/serialization/input6.ics | 13 ++++ testdata/serialization/input7.ics | 17 ++++++ 18 files changed, 362 insertions(+) create mode 100644 calendar_serialization_test.go create mode 100644 testdata/serialization/expected/input1.ics create mode 100644 testdata/serialization/expected/input2.ics create mode 100644 testdata/serialization/expected/input3.ics create mode 100644 testdata/serialization/expected/input4.ics create mode 100644 testdata/serialization/expected/input5.ics create mode 100644 testdata/serialization/expected/input6.ics create mode 100644 testdata/serialization/expected/input7.ics create mode 100644 testdata/serialization/input1.ics create mode 100644 testdata/serialization/input2.ics create mode 100644 testdata/serialization/input3.ics create mode 100644 testdata/serialization/input4.ics create mode 100644 testdata/serialization/input5.ics create mode 100644 testdata/serialization/input6.ics create mode 100644 testdata/serialization/input7.ics diff --git a/.gitignore b/.gitignore index 85e7c1d..c75db32 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea/ +actual diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go new file mode 100644 index 0000000..4565fae --- /dev/null +++ b/calendar_serialization_test.go @@ -0,0 +1,69 @@ +//go:build go1.18 +// +build go1.18 + +package ics + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/require" +) + +func TestCalendar_ReSerialization(t *testing.T) { + testDir := "testdata/serialization" + expectedDir := filepath.Join(testDir, "expected") + actualDir := filepath.Join(testDir, "actual") + + testFileNames := []string{ + "input1.ics", + "input2.ics", + "input3.ics", + "input4.ics", + "input5.ics", + "input6.ics", + "input7.ics", + } + + for _, filename := range testFileNames { + t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", filename), func(t *testing.T) { + //given + originalSeriailizedCal, err := os.ReadFile(filepath.Join(testDir, filename)) + require.NoError(t, err) + + //when + deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal)) + serializedCal := deserializedCal.Serialize() + + //then + expectedCal, _ := os.ReadFile(filepath.Join(expectedDir, filename)) + if diff := cmp.Diff(string(expectedCal), serializedCal); diff != "" { + os.MkdirAll(actualDir, 0755) + os.WriteFile(filepath.Join(actualDir, filename), []byte(serializedCal), 0644) + t.Error(diff) + } + }) + + t.Run(fmt.Sprintf("compare deserialized -> serialized -> deserialized: %s", filename), func(t *testing.T) { + //given + loadIcsContent, err := os.ReadFile(filepath.Join(testDir, filename)) + require.NoError(t, err) + originalDeserializedCal, err := ParseCalendar(bytes.NewReader(loadIcsContent)) + + //when + serializedCal := originalDeserializedCal.Serialize() + deserializedCal, err := ParseCalendar(strings.NewReader(serializedCal)) + require.NoError(t, err) + + //then + if diff := cmp.Diff(originalDeserializedCal, deserializedCal); diff != "" { + t.Error(diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 8daa44b..26045ca 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.13 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index a945f38..a04e1ef 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= diff --git a/testdata/serialization/expected/input1.ics b/testdata/serialization/expected/input1.ics new file mode 100644 index 0000000..e6cf960 --- /dev/null +++ b/testdata/serialization/expected/input1.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:Networld+Interop Conference and Exhibit\nAtlanta World Congress + Center\nAtlanta\, Georgia +END:VEVENT +END:VCALENDAR diff --git a/testdata/serialization/expected/input2.ics b/testdata/serialization/expected/input2.ics new file mode 100644 index 0000000..ad15b08 --- /dev/null +++ b/testdata/serialization/expected/input2.ics @@ -0,0 +1,34 @@ +BEGIN:VCALENDAR +PRODID:-//RDU Software//NONSGML HandCal//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:STANDARD +DTSTART:19981025T020000 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19990404T020000 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:19980309T231000Z +UID:guid-1.example.com +ORGANIZER:mailto:mrbig@example.com +ATTENDEE;CUTYPE=GROUP;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:employee-A@exam + ple.com +DESCRIPTION:Project XYZ Review Meeting +CATEGORIES:MEETING +CLASS:PUBLIC +CREATED:19980309T130000Z +SUMMARY:XYZ Project Review +DTSTART;TZID=America/New_York:19980312T083000 +DTEND;TZID=America/New_York:19980312T093000 +LOCATION:1CP Conference Room 4350 +END:VEVENT +END:VCALENDAR diff --git a/testdata/serialization/expected/input3.ics b/testdata/serialization/expected/input3.ics new file mode 100644 index 0000000..822135a --- /dev/null +++ b/testdata/serialization/expected/input3.ics @@ -0,0 +1,21 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VTODO +DTSTAMP:19980130T134500Z +SEQUENCE:2 +UID:uid4@example.com +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com +DUE:19980415T000000 +STATUS:NEEDS-ACTION +SUMMARY:Submit Income Taxes +BEGIN:VALARM +ACTION:AUDIO +TRIGGER:19980403T120000Z +ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-files/ssbanner.aud +REPEAT:4 +DURATION:PT1H +END:VALARM +END:VTODO +END:VCALENDAR diff --git a/testdata/serialization/expected/input4.ics b/testdata/serialization/expected/input4.ics new file mode 100644 index 0000000..e8a51ec --- /dev/null +++ b/testdata/serialization/expected/input4.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VJOURNAL +DTSTAMP:19970324T120000Z +UID:uid5@example.com +ORGANIZER:mailto:jsmith@example.com +STATUS:DRAFT +CLASS:PUBLIC +CATEGORIES:Project Report,XYZ,Weekly Meeting +DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of + project version 1.0 requirements.\n2. Definitionof project processes.\n3. + Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim + Dandy\n-It was decided that the requirements need to be signed off by + product marketing.\n-Project processes were accepted.\n-Project schedule + needs to account for scheduled holidays and employee vacation time. Check + with HR for specific dates.\n-New schedule will be distributed by + Friday.\n-Next weeks meeting is cancelled. No meeting until 3/23. +END:VJOURNAL +END:VCALENDAR diff --git a/testdata/serialization/expected/input5.ics b/testdata/serialization/expected/input5.ics new file mode 100644 index 0000000..e8a51ec --- /dev/null +++ b/testdata/serialization/expected/input5.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VJOURNAL +DTSTAMP:19970324T120000Z +UID:uid5@example.com +ORGANIZER:mailto:jsmith@example.com +STATUS:DRAFT +CLASS:PUBLIC +CATEGORIES:Project Report,XYZ,Weekly Meeting +DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of + project version 1.0 requirements.\n2. Definitionof project processes.\n3. + Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim + Dandy\n-It was decided that the requirements need to be signed off by + product marketing.\n-Project processes were accepted.\n-Project schedule + needs to account for scheduled holidays and employee vacation time. Check + with HR for specific dates.\n-New schedule will be distributed by + Friday.\n-Next weeks meeting is cancelled. No meeting until 3/23. +END:VJOURNAL +END:VCALENDAR diff --git a/testdata/serialization/expected/input6.ics b/testdata/serialization/expected/input6.ics new file mode 100644 index 0000000..a0d3960 --- /dev/null +++ b/testdata/serialization/expected/input6.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//RDU Software//NONSGML HandCal//EN +BEGIN:VBUSY +ORGANIZER:mailto:jsmith@example.com +DTSTART:19980313T141711Z +DTEND:19980410T141711Z +FREEBUSY:19980314T233000Z/19980315T003000Z +FREEBUSY:19980316T153000Z/19980316T163000Z +FREEBUSY:19980318T030000Z/19980318T040000Z +URL:http://www.example.com/calendar/busytime/jsmith.ifb +END:VBUSY +END:VCALENDAR diff --git a/testdata/serialization/expected/input7.ics b/testdata/serialization/expected/input7.ics new file mode 100644 index 0000000..e6a25ac --- /dev/null +++ b/testdata/serialization/expected/input7.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:[{"Name":"Some + Test"\,"Data":""}\,{"Name":"Meeting"\,"Foo":"Bar"}] +END:VEVENT +END:VCALENDAR diff --git a/testdata/serialization/input1.ics b/testdata/serialization/input1.ics new file mode 100644 index 0000000..e7a9fc4 --- /dev/null +++ b/testdata/serialization/input1.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:Networld+Interop Conference + and Exhibit\nAtlanta World Congress Center\n + Atlanta\, Georgia +END:VEVENT +END:VCALENDAR diff --git a/testdata/serialization/input2.ics b/testdata/serialization/input2.ics new file mode 100644 index 0000000..4ac07fc --- /dev/null +++ b/testdata/serialization/input2.ics @@ -0,0 +1,34 @@ +BEGIN:VCALENDAR +PRODID:-//RDU Software//NONSGML HandCal//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:America/New_York +BEGIN:STANDARD +DTSTART:19981025T020000 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19990404T020000 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +DTSTAMP:19980309T231000Z +UID:guid-1.example.com +ORGANIZER:mailto:mrbig@example.com +ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP: + mailto:employee-A@example.com +DESCRIPTION:Project XYZ Review Meeting +CATEGORIES:MEETING +CLASS:PUBLIC +CREATED:19980309T130000Z +SUMMARY:XYZ Project Review +DTSTART;TZID=America/New_York:19980312T083000 +DTEND;TZID=America/New_York:19980312T093000 +LOCATION:1CP Conference Room 4350 +END:VEVENT +END:VCALENDAR diff --git a/testdata/serialization/input3.ics b/testdata/serialization/input3.ics new file mode 100644 index 0000000..7f797ad --- /dev/null +++ b/testdata/serialization/input3.ics @@ -0,0 +1,22 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VTODO +DTSTAMP:19980130T134500Z +SEQUENCE:2 +UID:uid4@example.com +ORGANIZER:mailto:unclesam@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com +DUE:19980415T000000 +STATUS:NEEDS-ACTION +SUMMARY:Submit Income Taxes +BEGIN:VALARM +ACTION:AUDIO +TRIGGER:19980403T120000Z +ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio- + files/ssbanner.aud +REPEAT:4 +DURATION:PT1H +END:VALARM +END:VTODO +END:VCALENDAR diff --git a/testdata/serialization/input4.ics b/testdata/serialization/input4.ics new file mode 100644 index 0000000..1e1d9da --- /dev/null +++ b/testdata/serialization/input4.ics @@ -0,0 +1,23 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VJOURNAL +DTSTAMP:19970324T120000Z +UID:uid5@example.com +ORGANIZER:mailto:jsmith@example.com +STATUS:DRAFT +CLASS:PUBLIC +CATEGORIES:Project Report,XYZ,Weekly Meeting +DESCRIPTION:Project xyz Review Meeting Minutes\n + Agenda\n1. Review of project version 1.0 requirements.\n2. + Definition + of project processes.\n3. Review of project schedule.\n + Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was + decided that the requirements need to be signed off by + product marketing.\n-Project processes were accepted.\n + -Project schedule needs to account for scheduled holidays + and employee vacation time. Check with HR for specific + dates.\n-New schedule will be distributed by Friday.\n- + Next weeks meeting is cancelled. No meeting until 3/23. +END:VJOURNAL +END:VCALENDAR diff --git a/testdata/serialization/input5.ics b/testdata/serialization/input5.ics new file mode 100644 index 0000000..1e1d9da --- /dev/null +++ b/testdata/serialization/input5.ics @@ -0,0 +1,23 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VJOURNAL +DTSTAMP:19970324T120000Z +UID:uid5@example.com +ORGANIZER:mailto:jsmith@example.com +STATUS:DRAFT +CLASS:PUBLIC +CATEGORIES:Project Report,XYZ,Weekly Meeting +DESCRIPTION:Project xyz Review Meeting Minutes\n + Agenda\n1. Review of project version 1.0 requirements.\n2. + Definition + of project processes.\n3. Review of project schedule.\n + Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was + decided that the requirements need to be signed off by + product marketing.\n-Project processes were accepted.\n + -Project schedule needs to account for scheduled holidays + and employee vacation time. Check with HR for specific + dates.\n-New schedule will be distributed by Friday.\n- + Next weeks meeting is cancelled. No meeting until 3/23. +END:VJOURNAL +END:VCALENDAR diff --git a/testdata/serialization/input6.ics b/testdata/serialization/input6.ics new file mode 100644 index 0000000..2623678 --- /dev/null +++ b/testdata/serialization/input6.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//RDU Software//NONSGML HandCal//EN +BEGIN:VFREEBUSY +ORGANIZER:mailto:jsmith@example.com +DTSTART:19980313T141711Z +DTEND:19980410T141711Z +FREEBUSY:19980314T233000Z/19980315T003000Z +FREEBUSY:19980316T153000Z/19980316T163000Z +FREEBUSY:19980318T030000Z/19980318T040000Z +URL:http://www.example.com/calendar/busytime/jsmith.ifb +END:VFREEBUSY +END:VCALENDAR diff --git a/testdata/serialization/input7.ics b/testdata/serialization/input7.ics new file mode 100644 index 0000000..f9a9cf0 --- /dev/null +++ b/testdata/serialization/input7.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:[{"Name":"Some + Test"\,"Data":""}\,{"Name":"Meeting"\,"Foo":"Bar"}] +END:VEVENT +END:VCALENDAR + From ea37e627a7338e17673901f4992d682931659c93 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 12:20:33 -0700 Subject: [PATCH 38/50] fix: VFREEBUSY serialization --- components.go | 4 ++-- testdata/serialization/expected/input6.ics | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components.go b/components.go index 3931825..bf30b04 100644 --- a/components.go +++ b/components.go @@ -675,12 +675,12 @@ type VBusy struct { func (c *VBusy) Serialize() string { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VBUSY") + c.ComponentBase.serializeThis(b, "VFREEBUSY") return b.String() } func (c *VBusy) serialize(w io.Writer) { - c.ComponentBase.serializeThis(w, "VBUSY") + c.ComponentBase.serializeThis(w, "VFREEBUSY") } func NewBusy(uniqueId string) *VBusy { diff --git a/testdata/serialization/expected/input6.ics b/testdata/serialization/expected/input6.ics index a0d3960..b5d73e2 100644 --- a/testdata/serialization/expected/input6.ics +++ b/testdata/serialization/expected/input6.ics @@ -1,7 +1,7 @@ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//RDU Software//NONSGML HandCal//EN -BEGIN:VBUSY +BEGIN:VFREEBUSY ORGANIZER:mailto:jsmith@example.com DTSTART:19980313T141711Z DTEND:19980410T141711Z @@ -9,5 +9,5 @@ FREEBUSY:19980314T233000Z/19980315T003000Z FREEBUSY:19980316T153000Z/19980316T163000Z FREEBUSY:19980318T030000Z/19980318T040000Z URL:http://www.example.com/calendar/busytime/jsmith.ifb -END:VBUSY +END:VFREEBUSY END:VCALENDAR From 8d1cec9c96e0870ecb61f66b380ee4c71dc070c9 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Sun, 19 May 2024 13:23:08 -0700 Subject: [PATCH 39/50] breaking: unescape Property.Value of type TEXT This is a breaking change. Property.Value of value-type: TEXT is now unescaped in the deserialized model. Previously, this was escaped. --- calendar.go | 36 +++--- components.go | 12 +- property.go | 126 ++++++++++++++++++++- testdata/serialization/expected/input4.ics | 2 +- testdata/serialization/expected/input5.ics | 2 +- 5 files changed, 150 insertions(+), 28 deletions(-) diff --git a/calendar.go b/calendar.go index e7f6ca0..c17aae5 100644 --- a/calendar.go +++ b/calendar.go @@ -220,7 +220,7 @@ const ( ) func (ps ObjectStatus) KeyValue(s ...interface{}) (string, []string) { - return string(PropertyStatus), []string{ToText(string(ps))} + return string(PropertyStatus), []string{string(ps)} } type RelationshipType string @@ -317,48 +317,48 @@ func (calendar *Calendar) SerializeTo(w io.Writer) error { } func (calendar *Calendar) SetMethod(method Method, props ...PropertyParameter) { - calendar.setProperty(PropertyMethod, ToText(string(method)), props...) + calendar.setProperty(PropertyMethod, string(method), props...) } func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXPublishedTTL, string(s), props...) + calendar.setProperty(PropertyXPublishedTTL, s, props...) } func (calendar *Calendar) SetVersion(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyVersion, ToText(s), props...) + calendar.setProperty(PropertyVersion, s, props...) } func (calendar *Calendar) SetProductId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyProductId, ToText(s), props...) + calendar.setProperty(PropertyProductId, s, props...) } func (calendar *Calendar) SetName(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyName, string(s), props...) - calendar.setProperty(PropertyXWRCalName, string(s), props...) + calendar.setProperty(PropertyName, s, props...) + calendar.setProperty(PropertyXWRCalName, s, props...) } func (calendar *Calendar) SetColor(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyColor, string(s), props...) + calendar.setProperty(PropertyColor, s, props...) } func (calendar *Calendar) SetXWRCalName(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalName, string(s), props...) + calendar.setProperty(PropertyXWRCalName, s, props...) } func (calendar *Calendar) SetXWRCalDesc(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalDesc, string(s), props...) + calendar.setProperty(PropertyXWRCalDesc, s, props...) } func (calendar *Calendar) SetXWRTimezone(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRTimezone, string(s), props...) + calendar.setProperty(PropertyXWRTimezone, s, props...) } func (calendar *Calendar) SetXWRCalID(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalID, string(s), props...) + calendar.setProperty(PropertyXWRCalID, s, props...) } func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyDescription, ToText(s), props...) + calendar.setProperty(PropertyDescription, s, props...) } func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) { @@ -366,23 +366,23 @@ func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParamete } func (calendar *Calendar) SetRefreshInterval(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyRefreshInterval, string(s), props...) + calendar.setProperty(PropertyRefreshInterval, s, props...) } func (calendar *Calendar) SetCalscale(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyCalscale, string(s), props...) + calendar.setProperty(PropertyCalscale, s, props...) } func (calendar *Calendar) SetUrl(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyUrl, string(s), props...) + calendar.setProperty(PropertyUrl, s, props...) } func (calendar *Calendar) SetTzid(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyTzid, string(s), props...) + calendar.setProperty(PropertyTzid, s, props...) } func (calendar *Calendar) SetTimezoneId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyTimezoneId, string(s), props...) + calendar.setProperty(PropertyTimezoneId, s, props...) } func (calendar *Calendar) setProperty(property Property, value string, props ...PropertyParameter) { diff --git a/components.go b/components.go index bf30b04..4b5ccbb 100644 --- a/components.go +++ b/components.go @@ -45,7 +45,7 @@ func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { func NewComponent(uniqueId string) ComponentBase { return ComponentBase{ Properties: []IANAProperty{ - {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}}, + {BaseProperty{IANAToken: string(ComponentPropertyUniqueId), Value: uniqueId}}, }, } } @@ -214,19 +214,19 @@ func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { } func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertySummary, ToText(s), props...) + cb.SetProperty(ComponentPropertySummary, s, props...) } func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) + cb.SetProperty(ComponentPropertyStatus, string(s), props...) } func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDescription, ToText(s), props...) + cb.SetProperty(ComponentPropertyDescription, s, props...) } func (cb *ComponentBase) SetLocation(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyLocation, ToText(s), props...) + cb.SetProperty(ComponentPropertyLocation, s, props...) } func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { @@ -729,7 +729,7 @@ func NewTimezone(tzId string) *VTimezone { e := &VTimezone{ ComponentBase{ Properties: []IANAProperty{ - {BaseProperty{IANAToken: ToText(string(ComponentPropertyTzid)), Value: tzId}}, + {BaseProperty{IANAToken: string(ComponentPropertyTzid), Value: tzId}}, }, }, } diff --git a/property.go b/property.go index 388160b..95360ae 100644 --- a/property.go +++ b/property.go @@ -88,6 +88,121 @@ func trimUT8StringUpTo(maxLength int, s string) string { return s[:length] } +func (p *BaseProperty) GetValueType() ValueDataType { + for k, v := range p.ICalParameters { + if Parameter(k) == ParameterValue && len(v) == 1 { + return ValueDataType(v[0]) + } + } + + // defaults from spec if unspecified + switch Property(p.IANAToken) { + case PropertyCalscale: + fallthrough + case PropertyMethod: + fallthrough + case PropertyProductId: + fallthrough + case PropertyVersion: + fallthrough + case PropertyCategories: + fallthrough + case PropertyClass: + fallthrough + case PropertyComment: + fallthrough + case PropertyDescription: + fallthrough + case PropertyLocation: + fallthrough + case PropertyResources: + fallthrough + case PropertyStatus: + fallthrough + case PropertySummary: + fallthrough + case PropertyTransp: + fallthrough + case PropertyTzid: + fallthrough + case PropertyTzname: + fallthrough + case PropertyContact: + fallthrough + case PropertyRelatedTo: + fallthrough + case PropertyUid: + fallthrough + case PropertyAction: + fallthrough + default: + fallthrough + case PropertyRequestStatus: + return ValueDataTypeText + + case PropertyAttach: + fallthrough + case PropertyTzurl: + fallthrough + case PropertyUrl: + return ValueDataTypeUri + + case PropertyGeo: + return ValueDataTypeFloat + + case PropertyPercentComplete: + fallthrough + case PropertyPriority: + fallthrough + case PropertyRepeat: + fallthrough + case PropertySequence: + return ValueDataTypeInteger + + case PropertyCompleted: + fallthrough + case PropertyDtend: + fallthrough + case PropertyDue: + fallthrough + case PropertyDtstart: + fallthrough + case PropertyRecurrenceId: + fallthrough + case PropertyExdate: + fallthrough + case PropertyRdate: + fallthrough + case PropertyCreated: + fallthrough + case PropertyDtstamp: + fallthrough + case PropertyLastModified: + return ValueDataTypeDateTime + + case PropertyDuration: + fallthrough + case PropertyTrigger: + return ValueDataTypeDuration + + case PropertyFreebusy: + return ValueDataTypePeriod + + case PropertyTzoffsetfrom: + fallthrough + case PropertyTzoffsetto: + return ValueDataTypeUtcOffset + + case PropertyAttendee: + fallthrough + case PropertyOrganizer: + return ValueDataTypeCalAddress + + case PropertyRrule: + return ValueDataTypeRecur + } +} + func (property *BaseProperty) serialize(w io.Writer) { b := bytes.NewBufferString("") fmt.Fprint(b, property.IANAToken) @@ -117,7 +232,11 @@ func (property *BaseProperty) serialize(w io.Writer) { } } fmt.Fprint(b, ":") - fmt.Fprint(b, property.Value) + propertyValue := property.Value + if property.GetValueType() == ValueDataTypeText { + propertyValue = ToText(propertyValue) + } + fmt.Fprint(b, propertyValue) r := b.String() if len(r) > 75 { l := trimUT8StringUpTo(75, r) @@ -306,7 +425,10 @@ func parsePropertyValue(r *BaseProperty, contentLine string, p int) *BasePropert if tokenPos == nil { return nil } - r.Value = string(contentLine[p : p+tokenPos[1]]) + r.Value = contentLine[p : p+tokenPos[1]] + if r.GetValueType() == ValueDataTypeText { + r.Value = FromText(r.Value) + } return r } diff --git a/testdata/serialization/expected/input4.ics b/testdata/serialization/expected/input4.ics index e8a51ec..5dc38ea 100644 --- a/testdata/serialization/expected/input4.ics +++ b/testdata/serialization/expected/input4.ics @@ -7,7 +7,7 @@ UID:uid5@example.com ORGANIZER:mailto:jsmith@example.com STATUS:DRAFT CLASS:PUBLIC -CATEGORIES:Project Report,XYZ,Weekly Meeting +CATEGORIES:Project Report\,XYZ\,Weekly Meeting DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of project version 1.0 requirements.\n2. Definitionof project processes.\n3. Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim diff --git a/testdata/serialization/expected/input5.ics b/testdata/serialization/expected/input5.ics index e8a51ec..5dc38ea 100644 --- a/testdata/serialization/expected/input5.ics +++ b/testdata/serialization/expected/input5.ics @@ -7,7 +7,7 @@ UID:uid5@example.com ORGANIZER:mailto:jsmith@example.com STATUS:DRAFT CLASS:PUBLIC -CATEGORIES:Project Report,XYZ,Weekly Meeting +CATEGORIES:Project Report\,XYZ\,Weekly Meeting DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of project version 1.0 requirements.\n2. Definitionof project processes.\n3. Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim From 68b453c89c516a6fe8c6cdeebafff20a4eeea4f5 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Mon, 20 May 2024 18:41:52 -0700 Subject: [PATCH 40/50] refactor: fix linting errors --- calendar_serialization_test.go | 12 ++++++++++-- property.go | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go index 4565fae..7fa6fde 100644 --- a/calendar_serialization_test.go +++ b/calendar_serialization_test.go @@ -38,13 +38,20 @@ func TestCalendar_ReSerialization(t *testing.T) { //when deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal)) + require.NoError(t, err) serializedCal := deserializedCal.Serialize() //then expectedCal, _ := os.ReadFile(filepath.Join(expectedDir, filename)) if diff := cmp.Diff(string(expectedCal), serializedCal); diff != "" { - os.MkdirAll(actualDir, 0755) - os.WriteFile(filepath.Join(actualDir, filename), []byte(serializedCal), 0644) + err = os.MkdirAll(actualDir, 0755) + if err != nil { + t.Logf("failed to create actual dir: %v", err) + } + err = os.WriteFile(filepath.Join(actualDir, filename), []byte(serializedCal), 0644) + if err != nil { + t.Logf("failed to write actual file: %v", err) + } t.Error(diff) } }) @@ -54,6 +61,7 @@ func TestCalendar_ReSerialization(t *testing.T) { loadIcsContent, err := os.ReadFile(filepath.Join(testDir, filename)) require.NoError(t, err) originalDeserializedCal, err := ParseCalendar(bytes.NewReader(loadIcsContent)) + require.NoError(t, err) //when serializedCal := originalDeserializedCal.Serialize() diff --git a/property.go b/property.go index 95360ae..b454c55 100644 --- a/property.go +++ b/property.go @@ -208,7 +208,7 @@ func (property *BaseProperty) serialize(w io.Writer) { fmt.Fprint(b, property.IANAToken) var keys []string - for k, _ := range property.ICalParameters { + for k := range property.ICalParameters { keys = append(keys, k) } sort.Strings(keys) From b8666200d77783b5e9603447b1dd9b5e18c8f981 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Wed, 29 May 2024 20:28:53 -0700 Subject: [PATCH 41/50] refactor: reduce scope of gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c75db32..400eb8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /.idea/ -actual +/testdata/serialization/actual From 2f726f225b2da9aa4d4f358300d186a20aa15714 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Wed, 29 May 2024 20:34:54 -0700 Subject: [PATCH 42/50] refactor: switch syntax --- property.go | 90 +++++++---------------------------------------------- 1 file changed, 11 insertions(+), 79 deletions(-) diff --git a/property.go b/property.go index b454c55..67c3b15 100644 --- a/property.go +++ b/property.go @@ -97,105 +97,37 @@ func (p *BaseProperty) GetValueType() ValueDataType { // defaults from spec if unspecified switch Property(p.IANAToken) { - case PropertyCalscale: - fallthrough - case PropertyMethod: - fallthrough - case PropertyProductId: - fallthrough - case PropertyVersion: - fallthrough - case PropertyCategories: - fallthrough - case PropertyClass: - fallthrough - case PropertyComment: - fallthrough - case PropertyDescription: - fallthrough - case PropertyLocation: - fallthrough - case PropertyResources: - fallthrough - case PropertyStatus: - fallthrough - case PropertySummary: - fallthrough - case PropertyTransp: - fallthrough - case PropertyTzid: - fallthrough - case PropertyTzname: - fallthrough - case PropertyContact: - fallthrough - case PropertyRelatedTo: - fallthrough - case PropertyUid: - fallthrough - case PropertyAction: - fallthrough default: fallthrough - case PropertyRequestStatus: + case PropertyCalscale, PropertyMethod, PropertyProductId, PropertyVersion, PropertyCategories, PropertyClass, + PropertyComment, PropertyDescription, PropertyLocation, PropertyResources, PropertyStatus, PropertySummary, + PropertyTransp, PropertyTzid, PropertyTzname, PropertyContact, PropertyRelatedTo, PropertyUid, PropertyAction, + PropertyRequestStatus: return ValueDataTypeText - case PropertyAttach: - fallthrough - case PropertyTzurl: - fallthrough - case PropertyUrl: + case PropertyAttach, PropertyTzurl, PropertyUrl: return ValueDataTypeUri case PropertyGeo: return ValueDataTypeFloat - case PropertyPercentComplete: - fallthrough - case PropertyPriority: - fallthrough - case PropertyRepeat: - fallthrough - case PropertySequence: + case PropertyPercentComplete, PropertyPriority, PropertyRepeat, PropertySequence: return ValueDataTypeInteger - case PropertyCompleted: - fallthrough - case PropertyDtend: - fallthrough - case PropertyDue: - fallthrough - case PropertyDtstart: - fallthrough - case PropertyRecurrenceId: - fallthrough - case PropertyExdate: - fallthrough - case PropertyRdate: - fallthrough - case PropertyCreated: - fallthrough - case PropertyDtstamp: - fallthrough - case PropertyLastModified: + case PropertyCompleted, PropertyDtend, PropertyDue, PropertyDtstart, PropertyRecurrenceId, PropertyExdate, + PropertyRdate, PropertyCreated, PropertyDtstamp, PropertyLastModified: return ValueDataTypeDateTime - case PropertyDuration: - fallthrough - case PropertyTrigger: + case PropertyDuration, PropertyTrigger: return ValueDataTypeDuration case PropertyFreebusy: return ValueDataTypePeriod - case PropertyTzoffsetfrom: - fallthrough - case PropertyTzoffsetto: + case PropertyTzoffsetfrom, PropertyTzoffsetto: return ValueDataTypeUtcOffset - case PropertyAttendee: - fallthrough - case PropertyOrganizer: + case PropertyAttendee, PropertyOrganizer: return ValueDataTypeCalAddress case PropertyRrule: From 9a995148ab314baf97b9b40912b8639bcd6b9d84 Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Wed, 29 May 2024 20:36:49 -0700 Subject: [PATCH 43/50] fix: add test err assertion --- calendar_serialization_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go index 7fa6fde..333887a 100644 --- a/calendar_serialization_test.go +++ b/calendar_serialization_test.go @@ -42,7 +42,8 @@ func TestCalendar_ReSerialization(t *testing.T) { serializedCal := deserializedCal.Serialize() //then - expectedCal, _ := os.ReadFile(filepath.Join(expectedDir, filename)) + expectedCal, err := os.ReadFile(filepath.Join(expectedDir, filename)) + require.NoError(t, err) if diff := cmp.Diff(string(expectedCal), serializedCal); diff != "" { err = os.MkdirAll(actualDir, 0755) if err != nil { From 20061ba5f28d3098a569f5c24efb958cfc183f0b Mon Sep 17 00:00:00 2001 From: Brenan Kelley Date: Wed, 29 May 2024 20:37:27 -0700 Subject: [PATCH 44/50] fix: reduce build restriction on serialization test certain os methods were introduced in go1.16 --- calendar_serialization_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go index 333887a..eec5871 100644 --- a/calendar_serialization_test.go +++ b/calendar_serialization_test.go @@ -1,5 +1,5 @@ -//go:build go1.18 -// +build go1.18 +//go:build go1.16 +// +build go1.16 package ics From c331f7a6fbf1fd6c8c03e5534d4b67192f1d1156 Mon Sep 17 00:00:00 2001 From: Daniel Lublin Date: Wed, 10 Jul 2024 14:27:41 +0200 Subject: [PATCH 45/50] Add ComponentPropertyRelatedTo; the VTODO component can have that --- calendar.go | 1 + 1 file changed, 1 insertion(+) diff --git a/calendar.go b/calendar.go index c17aae5..1cd88ac 100644 --- a/calendar.go +++ b/calendar.go @@ -60,6 +60,7 @@ const ( ComponentPropertyPercentComplete = ComponentProperty(PropertyPercentComplete) ComponentPropertyTzid = ComponentProperty(PropertyTzid) ComponentPropertyComment = ComponentProperty(PropertyComment) + ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo) ) type Property string From f76e1f331270313469b4d16e62cb68eaa78a126e Mon Sep 17 00:00:00 2001 From: Manuel Lopez Date: Wed, 25 Sep 2024 11:05:30 +0200 Subject: [PATCH 46/50] fixed merge conflicts --- calendar.go | 18 +++++++++--------- components.go | 44 ++++++++++++++++++++++++++++++++------------ errors.go | 35 +++++++++++++++++++++++++++++++++++ property.go | 12 ++++++------ 4 files changed, 82 insertions(+), 27 deletions(-) create mode 100644 errors.go diff --git a/calendar.go b/calendar.go index 1cd88ac..4c9dbb2 100644 --- a/calendar.go +++ b/calendar.go @@ -432,10 +432,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { } line, err := ParseProperty(*l) if err != nil { - return nil, fmt.Errorf("parsing line %d: %w", ln, err) + return nil, fmt.Errorf("%s %d: %w", ParsingLineError, ln, err) } if line == nil { - return nil, fmt.Errorf("parsing calendar line %d", ln) + return nil, fmt.Errorf("%s %d", ParsingCalendarLineError, ln) } switch state { case "begin": @@ -445,10 +445,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "properties" default: - return nil, errors.New("malformed calendar; expected a vcalendar") + return nil, errors.New(MalformedCalendarExpectedVCalendarError) } default: - return nil, errors.New("malformed calendar; expected begin") + return nil, errors.New(MalformedCalendarExpectedBeginError) } case "properties": switch line.IANAToken { @@ -457,7 +457,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New("malformed calendar; expected end") + return nil, errors.New(MalformedCalendarExpectedEndError) } case "BEGIN": state = "components" @@ -475,7 +475,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New("malformed calendar; expected end") + return nil, errors.New(MalformedCalendarExpectedEndError) } case "BEGIN": co, err := GeneralParseComponent(cs, line) @@ -486,12 +486,12 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { c.Components = append(c.Components, co) } default: - return nil, errors.New("malformed calendar; expected begin or end") + return nil, errors.New(MalformedCalendarExpectedBeginOrEnd) } case "end": - return nil, errors.New("malformed calendar; unexpected end") + return nil, errors.New(MalformedCalendarUnexpectedEndError) default: - return nil, errors.New("malformed calendar; bad state") + return nil, errors.New(MalformedCalendarBadStateError) } } return c, nil diff --git a/components.go b/components.go index 4b5ccbb..ea8d87e 100644 --- a/components.go +++ b/components.go @@ -130,16 +130,36 @@ func (cb *ComponentBase) SetAllDayEndAt(t time.Time, props ...PropertyParameter) cb.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), props...) } -func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { - timeProp := cb.GetProperty(componentProperty) +// SetDuration updates the duration of an event. +// This function will set either the end or start time of an event depending what is already given. +// The duration defines the length of a event relative to start or end time. +// +// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. +func (event *VEvent) SetDuration(d time.Duration) error { + t, err := event.GetStartAt() + if err == nil { + event.SetEndAt(t.Add(d)) + return nil + } else { + t, err = event.GetEndAt() + if err == nil { + event.SetStartAt(t.Add(-d)) + return nil + } + } + return errors.New(StartOrEndNotYetDefinedError) +} + +func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { + timeProp := event.GetProperty(componentProperty) if timeProp == nil { - return time.Time{}, errors.New("property not found") + return time.Time{}, errors.New(PropertyNotFoundError) } timeVal := timeProp.BaseProperty.Value matched := timeStampVariations.FindStringSubmatch(timeVal) if matched == nil { - return time.Time{}, fmt.Errorf("time value not matched, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueNotMatchedError, timeVal) } tOrZGrp := matched[2] zGrp := matched[4] @@ -150,7 +170,7 @@ func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expect var propLoc *time.Location if tzIdOk { if len(tzId) != 1 { - return time.Time{}, errors.New("expected only one TZID") + return time.Time{}, errors.New(ExpectedOneTZIDError) } var tzErr error propLoc, tzErr = time.LoadLocation(tzId[0]) @@ -173,7 +193,7 @@ func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expect } } - return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButUnsupportedAllDayTimeStampError, timeVal) } if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" { @@ -194,7 +214,7 @@ func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expect } } - return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupported, timeVal) } func (cb *ComponentBase) GetStartAt() (time.Time, error) { @@ -846,7 +866,7 @@ func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Compone var co Component switch startLine.Value { case "VCALENDAR": - return nil, errors.New("malformed calendar; vcalendar not where expected") + return nil, errors.New(MalformedCalendarVCalendarNotWhereExpected) case "VEVENT": co = ParseVEvent(cs, startLine) case "VTODO": @@ -987,10 +1007,10 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, } line, err := ParseProperty(*l) if err != nil { - return cb, fmt.Errorf("parsing component property %d: %w", ln, err) + return cb, fmt.Errorf("%s %d: %w", ParsingComponentPropertyError, ln, err) } if line == nil { - return cb, errors.New("parsing component line") + return cb, errors.New(ParsingComponentLineError) } switch line.IANAToken { case "END": @@ -998,7 +1018,7 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, case startLine.Value: return cb, nil default: - return cb, errors.New("unbalanced end") + return cb, errors.New(UnbalancedEndError) } case "BEGIN": co, err := GeneralParseComponent(cs, line) @@ -1012,5 +1032,5 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, cb.Properties = append(cb.Properties, IANAProperty{*line}) } } - return cb, errors.New("ran out of lines") + return cb, errors.New(OutOfLinesError) } diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..99bff37 --- /dev/null +++ b/errors.go @@ -0,0 +1,35 @@ +package ics + +const ( + MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" + MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" + MalformedCalendarExpectedEndError = "malformed calendar; expected a end" + MalformedCalendarExpectedBeginOrEnd = "malformed calendar; expected begin or end" + + MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" + MalformedCalendarBadStateError = "malformed calendar; bad state" + MalformedCalendarVCalendarNotWhereExpected = "malformed calendar; vcalendar not where expected" + + StartOrEndNotYetDefinedError = "start or end not yet defined" + PropertyNotFoundError = "property not found" + ExpectedOneTZIDError = "expected one TZID" + + TimeValueNotMatchedError = "time value not matched" + TimeValueMatchedButUnsupportedAllDayTimeStampError = "time value matched but unsupported all-day timestamp" + TimeValueMatchedButNotSupported = "time value matched but not supported" + + ParsingComponentPropertyError = "parsing component property" + ParsingComponentLineError = "parsing component line" + ParsingLineError = "parsing line" + ParsingCalendarLineError = "parsing calendar line" + ParsingPropertyError = "parsing property" + ParseError = "parse error" + + MissingPropertyValueError = "missing property value" + + UnexpectedASCIIChar = "unexpected char ascii" + UnexpectedDoubleQuoteInPropertyParamValue = "unexpected double quote in property param value" + + UnbalancedEndError = "unbalanced end" + OutOfLinesError = "ran out of lines" +) diff --git a/property.go b/property.go index 67c3b15..d484f0e 100644 --- a/property.go +++ b/property.go @@ -234,7 +234,7 @@ func ParseProperty(contentLine ContentLine) (*BaseProperty, error) { t := r.IANAToken r, np, err = parsePropertyParam(r, string(contentLine), p+1) if err != nil { - return nil, fmt.Errorf("parsing property %s: %w", t, err) + return nil, fmt.Errorf("%s %s: %w", ParsingPropertyError, t, err) } if r == nil { return nil, nil @@ -261,7 +261,7 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper case '=': p += 1 default: - return nil, p, fmt.Errorf("missing property value for %s in %s", k, r.IANAToken) + return nil, p, fmt.Errorf("%s for %s in %s", MissingPropertyValueError, k, r.IANAToken) } for { if p >= len(contentLine) { @@ -270,7 +270,7 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper var err error v, p, err = parsePropertyParamValue(contentLine, p) if err != nil { - return nil, 0, fmt.Errorf("parse error: %w %s in %s", err, k, r.IANAToken) + return nil, 0, fmt.Errorf("%s: %w %s in %s", ParseError, err, k, r.IANAToken) } r.ICalParameters[k] = append(r.ICalParameters[k], v) if p >= len(contentLine) { @@ -319,10 +319,10 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { for ; p < len(s) && !done; p++ { switch s[p] { case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08: - return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F: - return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) case '\\': if p+2 >= len(s) { return "", 0, errors.New("unexpected end of param value") @@ -345,7 +345,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { done = true continue } - return "", 0, fmt.Errorf("unexpected double quote in property param value") + return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValue) } r = append(r, s[p]) } From e616cd64f6bdf46d2d8f51e364b19ec266dfde64 Mon Sep 17 00:00:00 2001 From: Manuel Lopez <74105210+ManoloTonto1@users.noreply.github.com> Date: Wed, 12 Jul 2023 19:26:46 +0200 Subject: [PATCH 47/50] fixed some namings --- calendar.go | 2 +- components.go | 4 ++-- errors.go | 20 ++++++++++---------- property.go | 6 +++--- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/calendar.go b/calendar.go index 4c9dbb2..38fae46 100644 --- a/calendar.go +++ b/calendar.go @@ -486,7 +486,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { c.Components = append(c.Components, co) } default: - return nil, errors.New(MalformedCalendarExpectedBeginOrEnd) + return nil, errors.New(MalformedCalendarExpectedBeginOrEndError) } case "end": return nil, errors.New(MalformedCalendarUnexpectedEndError) diff --git a/components.go b/components.go index ea8d87e..fc5d58a 100644 --- a/components.go +++ b/components.go @@ -214,7 +214,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupported, timeVal) + return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupportedError, timeVal) } func (cb *ComponentBase) GetStartAt() (time.Time, error) { @@ -866,7 +866,7 @@ func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Compone var co Component switch startLine.Value { case "VCALENDAR": - return nil, errors.New(MalformedCalendarVCalendarNotWhereExpected) + return nil, errors.New(MalformedCalendarVCalendarNotWhereExpectedError) case "VEVENT": co = ParseVEvent(cs, startLine) case "VTODO": diff --git a/errors.go b/errors.go index 99bff37..4a74708 100644 --- a/errors.go +++ b/errors.go @@ -1,14 +1,14 @@ package ics const ( - MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" - MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" - MalformedCalendarExpectedEndError = "malformed calendar; expected a end" - MalformedCalendarExpectedBeginOrEnd = "malformed calendar; expected begin or end" + MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" + MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" + MalformedCalendarExpectedEndError = "malformed calendar; expected a end" + MalformedCalendarExpectedBeginOrEndError = "malformed calendar; expected begin or end" - MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" - MalformedCalendarBadStateError = "malformed calendar; bad state" - MalformedCalendarVCalendarNotWhereExpected = "malformed calendar; vcalendar not where expected" + MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" + MalformedCalendarBadStateError = "malformed calendar; bad state" + MalformedCalendarVCalendarNotWhereExpectedError = "malformed calendar; vcalendar not where expected" StartOrEndNotYetDefinedError = "start or end not yet defined" PropertyNotFoundError = "property not found" @@ -16,7 +16,7 @@ const ( TimeValueNotMatchedError = "time value not matched" TimeValueMatchedButUnsupportedAllDayTimeStampError = "time value matched but unsupported all-day timestamp" - TimeValueMatchedButNotSupported = "time value matched but not supported" + TimeValueMatchedButNotSupportedError = "time value matched but not supported" ParsingComponentPropertyError = "parsing component property" ParsingComponentLineError = "parsing component line" @@ -27,8 +27,8 @@ const ( MissingPropertyValueError = "missing property value" - UnexpectedASCIIChar = "unexpected char ascii" - UnexpectedDoubleQuoteInPropertyParamValue = "unexpected double quote in property param value" + UnexpectedASCIICharError = "unexpected char ascii" + UnexpectedDoubleQuoteInPropertyParamValueError = "unexpected double quote in property param value" UnbalancedEndError = "unbalanced end" OutOfLinesError = "ran out of lines" diff --git a/property.go b/property.go index d484f0e..21616ef 100644 --- a/property.go +++ b/property.go @@ -319,10 +319,10 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { for ; p < len(s) && !done; p++ { switch s[p] { case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIIChar, s[p]) + return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) case '\\': if p+2 >= len(s) { return "", 0, errors.New("unexpected end of param value") @@ -345,7 +345,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { done = true continue } - return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValue) + return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValueError) } r = append(r, s[p]) } From 4c3688bc645356df1bfbb733cff62114ee52f774 Mon Sep 17 00:00:00 2001 From: Manuel Lopez Date: Wed, 25 Sep 2024 11:19:08 +0200 Subject: [PATCH 48/50] fixed som functions that are using the wrong Struct Embeddings --- components.go | 52 ++++++--------------------------------------------- 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/components.go b/components.go index fc5d58a..d1f0693 100644 --- a/components.go +++ b/components.go @@ -217,19 +217,19 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupportedError, timeVal) } -func (cb *ComponentBase) GetStartAt() (time.Time, error) { +func (cb *VEvent) GetStartAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtStart, false) } -func (cb *ComponentBase) GetAllDayStartAt() (time.Time, error) { +func (cb *VEvent) GetAllDayStartAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtStart, true) } -func (cb *ComponentBase) GetLastModifiedAt() (time.Time, error) { +func (cb *VEvent) GetLastModifiedAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyLastModified, false) } -func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { +func (cb *VEvent) GetDtStampTime() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtstamp, false) } @@ -480,26 +480,6 @@ func (event *VEvent) SetResources(r string, props ...PropertyParameter) { event.setResources(r, props...) } -// SetDuration updates the duration of an event. -// This function will set either the end or start time of an event depending what is already given. -// The duration defines the length of a event relative to start or end time. -// -// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. -func (event *VEvent) SetDuration(d time.Duration) error { - t, err := event.GetStartAt() - if err == nil { - event.SetEndAt(t.Add(d)) - return nil - } else { - t, err = event.GetEndAt() - if err == nil { - event.SetStartAt(t.Add(-d)) - return nil - } - } - return errors.New("start or end not yet defined") -} - func (event *VEvent) AddAlarm() *VAlarm { return event.addAlarm() } @@ -607,26 +587,6 @@ func (todo *VTodo) SetResources(r string, props ...PropertyParameter) { todo.setResources(r, props...) } -// SetDuration updates the duration of an event. -// This function will set either the end or start time of an event depending what is already given. -// The duration defines the length of a event relative to start or end time. -// -// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. -func (todo *VTodo) SetDuration(d time.Duration) error { - t, err := todo.GetStartAt() - if err == nil { - todo.SetDueAt(t.Add(d)) - return nil - } else { - t, err = todo.GetDueAt() - if err == nil { - todo.SetStartAt(t.Add(-d)) - return nil - } - } - return errors.New("start or end not yet defined") -} - func (todo *VTodo) AddAlarm() *VAlarm { return todo.addAlarm() } @@ -639,11 +599,11 @@ func (todo *VTodo) Alarms() (r []*VAlarm) { return todo.alarms() } -func (todo *VTodo) GetDueAt() (time.Time, error) { +func (todo *VEvent) GetDueAt() (time.Time, error) { return todo.getTimeProp(ComponentPropertyDue, false) } -func (todo *VTodo) GetAllDayDueAt() (time.Time, error) { +func (todo *VEvent) GetAllDayDueAt() (time.Time, error) { return todo.getTimeProp(ComponentPropertyDue, true) } From 1d9da2ea74e881b085024681b7d108229d6fef5c Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Sat, 5 Oct 2024 01:43:15 +1000 Subject: [PATCH 49/50] Some suggestions, this could be improved still. (#1) * Add leaks and vunerability checks * Requires secrets now * Renamed interface as it's useful own it's own. Made it public. Added a comment * Some suggestions, this could be improved still. * Version bump required by this change. * 19 too fails. * Should be able to distinguish unset from invalid time properties * Improve error for property not found Co-authored-by: Arran Ubels * Remove deprecated ioutil * Move targeted Go version to 1.20 And test the target forever * Add method to remove property Fixes https://github.com/arran4/golang-ical/issues/95 * Merged * New tool * Test fixed. * Fixed test failure. (Error wrapping needed to be considered in EOF checks.) * %s => %w fixes and an additional error. --------- Co-authored-by: Bracken Dawson Co-authored-by: Daniel Lublin --- .github/workflows/goleaks.yml | 11 +++++ .github/workflows/govun.yml | 11 +++++ .github/workflows/test.yml | 26 ++++++++++-- calendar.go | 43 +++++++++++-------- calendar_test.go | 24 +++++------ components.go | 78 +++++++++++++++++++++++------------ components_test.go | 31 ++++++++++++++ errors.go | 77 ++++++++++++++++++++-------------- go.mod | 10 +++-- property.go | 19 ++++----- 10 files changed, 224 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/goleaks.yml create mode 100644 .github/workflows/govun.yml diff --git a/.github/workflows/goleaks.yml b/.github/workflows/goleaks.yml new file mode 100644 index 0000000..45fda20 --- /dev/null +++ b/.github/workflows/goleaks.yml @@ -0,0 +1,11 @@ +name: gitleaks +on: [push,pull_request] +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: gitleaks-action + uses: zricethezav/gitleaks-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/govun.yml b/.github/workflows/govun.yml new file mode 100644 index 0000000..c068f34 --- /dev/null +++ b/.github/workflows/govun.yml @@ -0,0 +1,11 @@ +name: Go vunderability check +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Golang + uses: actions/setup-go@v5 + - id: govulncheck + uses: golang/govulncheck-action@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fdae94..1eb0e41 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,14 @@ on: [push, pull_request] name: Test jobs: - test: + version: name: Test permissions: contents: read strategy: matrix: - go-version: ['1.14.15', '1.15.15', '1.16.15', '1.17.13', '1.22.3'] - os: [ubuntu-latest, macos-13, windows-latest] + go-version: ['oldstable', 'stable'] + os: ['ubuntu-latest', 'macos-13', 'windows-latest'] runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -18,4 +18,22 @@ jobs: with: go-version: "${{ matrix.go-version }}" - name: Go Test - run: go test ./... + run: go test -race ./... + module: + name: Test + permissions: + contents: read + strategy: + matrix: + go-version-file: ['go.mod'] + os: ['ubuntu-latest', 'macos-13', 'windows-latest'] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Golang + uses: actions/setup-go@v5 + with: + go-version-file: "${{ matrix.go-version-file }}" + - name: Go Test + run: go test -race ./... diff --git a/calendar.go b/calendar.go index 38fae46..471741a 100644 --- a/calendar.go +++ b/calendar.go @@ -311,7 +311,7 @@ func (calendar *Calendar) SerializeTo(w io.Writer) error { p.serialize(w) } for _, c := range calendar.Components { - c.serialize(w) + c.SerializeTo(w) } _, _ = fmt.Fprint(w, "END:VCALENDAR", "\r\n") return nil @@ -420,8 +420,8 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { for ln := 0; cont; ln++ { l, err := cs.ReadLine() if err != nil { - switch err { - case io.EOF: + switch { + case errors.Is(err, io.EOF): cont = false default: return c, err @@ -432,10 +432,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { } line, err := ParseProperty(*l) if err != nil { - return nil, fmt.Errorf("%s %d: %w", ParsingLineError, ln, err) + return nil, fmt.Errorf("%w %d: %w", ErrParsingLine, ln, err) } if line == nil { - return nil, fmt.Errorf("%s %d", ParsingCalendarLineError, ln) + return nil, fmt.Errorf("%w %d", ErrParsingCalendarLine, ln) } switch state { case "begin": @@ -445,10 +445,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "properties" default: - return nil, errors.New(MalformedCalendarExpectedVCalendarError) + return nil, ErrMalformedCalendarExpectedVCalendar } default: - return nil, errors.New(MalformedCalendarExpectedBeginError) + return nil, ErrMalformedCalendarExpectedBegin } case "properties": switch line.IANAToken { @@ -457,7 +457,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New(MalformedCalendarExpectedEndError) + return nil, ErrMalformedCalendarExpectedEnd } case "BEGIN": state = "components" @@ -475,23 +475,23 @@ func ParseCalendar(r io.Reader) (*Calendar, error) { case "VCALENDAR": state = "end" default: - return nil, errors.New(MalformedCalendarExpectedEndError) + return nil, fmt.Errorf("%w at '%s': %w", ErrMalformedCalendarExpectedEnd, line.IANAToken, err) } case "BEGIN": co, err := GeneralParseComponent(cs, line) if err != nil { - return nil, err + return nil, fmt.Errorf("%w at '%s': %w", ErrMalformedCalendarExpectedEnd, line.IANAToken, err) } if co != nil { c.Components = append(c.Components, co) } default: - return nil, errors.New(MalformedCalendarExpectedBeginOrEndError) + return nil, fmt.Errorf("%w at '%s'", ErrMalformedCalendarExpectedBeginOrEnd, line.IANAToken) } case "end": - return nil, errors.New(MalformedCalendarUnexpectedEndError) + return nil, ErrMalformedCalendarUnexpectedEnd default: - return nil, errors.New(MalformedCalendarBadStateError) + return nil, ErrMalformedCalendarBadState } } return c, nil @@ -529,7 +529,7 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) { } p, err := cs.b.Peek(1) r = append(r, b[:len(b)-o]...) - if err == io.EOF { + if errors.Is(err, io.EOF) { c = false } if len(p) == 0 { @@ -542,20 +542,27 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) { } else { r = append(r, b...) } - switch err { - case nil: + switch { + case err == nil: if len(r) == 0 { c = true } - case io.EOF: + case errors.Is(err, io.EOF): c = false default: + // This must be as a result of boxing? + if err != nil { + err = fmt.Errorf("readline: %w", err) + } return nil, err } } if len(r) == 0 && err != nil { - return nil, err + return nil, fmt.Errorf("readline: %w", err) } cl := ContentLine(r) + if err != nil { + err = fmt.Errorf("readline: %w", err) + } return &cl, err } diff --git a/calendar_test.go b/calendar_test.go index b2658aa..a1e9ddd 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -1,8 +1,9 @@ package ics import ( + "errors" + "github.com/stretchr/testify/assert" "io" - "io/ioutil" "os" "path/filepath" "regexp" @@ -10,8 +11,6 @@ import ( "testing" "time" "unicode/utf8" - - "github.com/stretchr/testify/assert" ) func TestTimeParsing(t *testing.T) { @@ -20,7 +19,7 @@ func TestTimeParsing(t *testing.T) { t.Errorf("read file: %v", err) } cal, err := ParseCalendar(calFile) - if err != nil { + if err != nil && !errors.Is(err, io.EOF) { t.Errorf("parse calendar: %v", err) } @@ -129,8 +128,8 @@ CLASS:PUBLIC for i := 0; cont; i++ { l, err := c.ReadLine() if err != nil { - switch err { - case io.EOF: + switch { + case errors.Is(err, io.EOF): cont = false default: t.Logf("Unknown error; %v", err) @@ -139,7 +138,7 @@ CLASS:PUBLIC } } if l == nil { - if err == io.EOF && i == len(expected) { + if errors.Is(err, io.EOF) && i == len(expected) { cont = false } else { t.Logf("Nil response...") @@ -168,14 +167,15 @@ func TestRfc5545Sec4Examples(t *testing.T) { return nil } - inputBytes, err := ioutil.ReadFile(path) + inputBytes, err := os.ReadFile(path) if err != nil { return err } input := rnReplace.ReplaceAllString(string(inputBytes), "\r\n") structure, err := ParseCalendar(strings.NewReader(input)) - if assert.Nil(t, err, path) { + if err != nil && !errors.Is(err, io.EOF) { + assert.Nil(t, err, path) // This should fail as the sample data doesn't conform to https://tools.ietf.org/html/rfc5545#page-45 // Probably due to RFC width guides assert.NotNil(t, structure) @@ -373,7 +373,7 @@ END:VCALENDAR for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { c, err := ParseCalendar(strings.NewReader(tc.input)) - if !assert.NoError(t, err) { + if !errors.Is(err, io.EOF) && !assert.NoError(t, err) { return } @@ -394,12 +394,12 @@ func TestIssue52(t *testing.T) { _, fn := filepath.Split(path) t.Run(fn, func(t *testing.T) { f, err := os.Open(path) - if err != nil { + if err != nil && errors.Is(err, io.EOF) { t.Fatalf("Error reading file: %s", err) } defer f.Close() - if _, err := ParseCalendar(f); err != nil { + if _, err := ParseCalendar(f); err != nil && !errors.Is(err, io.EOF) { t.Fatalf("Error parsing file: %s", err) } diff --git a/components.go b/components.go index d1f0693..5687a79 100644 --- a/components.go +++ b/components.go @@ -12,12 +12,24 @@ import ( "time" ) +// Component To determine what this is please use a type switch or typecast to each of: +// - *VEvent +// - *VTodo +// - *VBusy +// - *VJournal type Component interface { UnknownPropertiesIANAProperties() []IANAProperty SubComponents() []Component - serialize(b io.Writer) + SerializeTo(b io.Writer) } +var ( + _ Component = (*VEvent)(nil) + _ Component = (*VTodo)(nil) + _ Component = (*VBusy)(nil) + _ Component = (*VJournal)(nil) +) + type ComponentBase struct { Properties []IANAProperty Components []Component @@ -37,7 +49,7 @@ func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { p.serialize(writer) } for _, c := range cb.Components { - c.serialize(writer) + c.SerializeTo(writer) } _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") } @@ -89,6 +101,18 @@ 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) { + var keptProperties []IANAProperty + for i := range cb.Properties { + if cb.Properties[i].IANAToken != string(removeProp) { + keptProperties = append(keptProperties, cb.Properties[i]) + } + } + cb.Properties = keptProperties +} + const ( icalTimestampFormatUtc = "20060102T150405Z" icalTimestampFormatLocal = "20060102T150405" @@ -147,19 +171,19 @@ func (event *VEvent) SetDuration(d time.Duration) error { return nil } } - return errors.New(StartOrEndNotYetDefinedError) + return ErrStartOrEndNotYetDefined } func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { timeProp := event.GetProperty(componentProperty) if timeProp == nil { - return time.Time{}, errors.New(PropertyNotFoundError) + return time.Time{}, fmt.Errorf("%w: %s", ErrPropertyNotFound, componentProperty) } timeVal := timeProp.BaseProperty.Value matched := timeStampVariations.FindStringSubmatch(timeVal) if matched == nil { - return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueNotMatchedError, timeVal) + return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueNotMatched, timeVal) } tOrZGrp := matched[2] zGrp := matched[4] @@ -170,7 +194,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD var propLoc *time.Location if tzIdOk { if len(tzId) != 1 { - return time.Time{}, errors.New(ExpectedOneTZIDError) + return time.Time{}, ErrExpectedOneTZID } var tzErr error propLoc, tzErr = time.LoadLocation(tzId[0]) @@ -193,7 +217,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButUnsupportedAllDayTimeStampError, timeVal) + return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButUnsupportedAllDayTimeStamp, timeVal) } if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" { @@ -214,7 +238,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD } } - return time.Time{}, fmt.Errorf("%s, got '%s'", TimeValueMatchedButNotSupportedError, timeVal) + return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButNotSupported, timeVal) } func (cb *VEvent) GetStartAt() (time.Time, error) { @@ -406,13 +430,13 @@ type VEvent struct { ComponentBase } -func (c *VEvent) serialize(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() } @@ -515,7 +539,7 @@ type VTodo struct { ComponentBase } -func (todo *VTodo) serialize(w io.Writer) { +func (todo *VTodo) SerializeTo(w io.Writer) { todo.ComponentBase.serializeThis(w, "VTODO") } @@ -611,7 +635,7 @@ type VJournal struct { ComponentBase } -func (c *VJournal) serialize(w io.Writer) { +func (c *VJournal) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "VJOURNAL") } @@ -659,7 +683,7 @@ func (c *VBusy) Serialize() string { return b.String() } -func (c *VBusy) serialize(w io.Writer) { +func (c *VBusy) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "VFREEBUSY") } @@ -701,7 +725,7 @@ func (c *VTimezone) Serialize() string { return b.String() } -func (c *VTimezone) serialize(w io.Writer) { +func (c *VTimezone) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "VTIMEZONE") } @@ -747,7 +771,7 @@ func (c *VAlarm) Serialize() string { return b.String() } -func (c *VAlarm) serialize(w io.Writer) { +func (c *VAlarm) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "VALARM") } @@ -789,7 +813,7 @@ func (c *Standard) Serialize() string { return b.String() } -func (c *Standard) serialize(w io.Writer) { +func (c *Standard) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "STANDARD") } @@ -803,7 +827,7 @@ func (c *Daylight) Serialize() string { return b.String() } -func (c *Daylight) serialize(w io.Writer) { +func (c *Daylight) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "DAYLIGHT") } @@ -818,7 +842,7 @@ func (c *GeneralComponent) Serialize() string { return b.String() } -func (c *GeneralComponent) serialize(w io.Writer) { +func (c *GeneralComponent) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, c.Token) } @@ -826,7 +850,7 @@ func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Compone var co Component switch startLine.Value { case "VCALENDAR": - return nil, errors.New(MalformedCalendarVCalendarNotWhereExpectedError) + return nil, ErrMalformedCalendarVCalendarNotWhereExpected case "VEVENT": co = ParseVEvent(cs, startLine) case "VTODO": @@ -955,8 +979,8 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, for ln := 0; cont; ln++ { l, err := cs.ReadLine() if err != nil { - switch err { - case io.EOF: + switch { + case errors.Is(err, io.EOF): cont = false default: return cb, err @@ -967,10 +991,10 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, } line, err := ParseProperty(*l) if err != nil { - return cb, fmt.Errorf("%s %d: %w", ParsingComponentPropertyError, ln, err) + return cb, fmt.Errorf("%w %d: %w", ErrParsingComponentProperty, ln, err) } if line == nil { - return cb, errors.New(ParsingComponentLineError) + return cb, ErrParsingComponentLine } switch line.IANAToken { case "END": @@ -978,7 +1002,7 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, case startLine.Value: return cb, nil default: - return cb, errors.New(UnbalancedEndError) + return cb, ErrUnbalancedEnd } case "BEGIN": co, err := GeneralParseComponent(cs, line) @@ -992,5 +1016,5 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, cb.Properties = append(cb.Properties, IANAProperty{*line}) } } - return cb, errors.New(OutOfLinesError) + return cb, ErrOutOfLines } diff --git a/components_test.go b/components_test.go index bb38585..ffa7c44 100644 --- a/components_test.go +++ b/components_test.go @@ -132,3 +132,34 @@ func TestSetMailtoPrefix(t *testing.T) { t.Errorf("expected single mailto: prefix for email att2") } } + +func TestRemoveProperty(t *testing.T) { + testCases := []struct { + name string + output string + }{ + { + name: "test RemoveProperty - start", + output: `BEGIN:VTODO +UID:test-removeproperty +X-TEST:42 +END:VTODO +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := NewTodo("test-removeproperty") + e.AddProperty("X-TEST", "42") + e.AddProperty("X-TESTREMOVE", "FOO") + e.AddProperty("X-TESTREMOVE", "BAR") + e.RemoveProperty("X-TESTREMOVE") + + // adjust to expected linebreaks, since we're not testing the encoding + text := strings.Replace(e.Serialize(), "\r\n", "\n", -1) + + assert.Equal(t, tc.output, text) + }) + } +} diff --git a/errors.go b/errors.go index 4a74708..2c96581 100644 --- a/errors.go +++ b/errors.go @@ -1,35 +1,48 @@ package ics -const ( - MalformedCalendarExpectedVCalendarError = "malformed calendar; expected a vcalendar" - MalformedCalendarExpectedBeginError = "malformed calendar; expected begin" - MalformedCalendarExpectedEndError = "malformed calendar; expected a end" - MalformedCalendarExpectedBeginOrEndError = "malformed calendar; expected begin or end" - - MalformedCalendarUnexpectedEndError = "malformed calendar; unexpected end" - MalformedCalendarBadStateError = "malformed calendar; bad state" - MalformedCalendarVCalendarNotWhereExpectedError = "malformed calendar; vcalendar not where expected" - - StartOrEndNotYetDefinedError = "start or end not yet defined" - PropertyNotFoundError = "property not found" - ExpectedOneTZIDError = "expected one TZID" - - TimeValueNotMatchedError = "time value not matched" - TimeValueMatchedButUnsupportedAllDayTimeStampError = "time value matched but unsupported all-day timestamp" - TimeValueMatchedButNotSupportedError = "time value matched but not supported" - - ParsingComponentPropertyError = "parsing component property" - ParsingComponentLineError = "parsing component line" - ParsingLineError = "parsing line" - ParsingCalendarLineError = "parsing calendar line" - ParsingPropertyError = "parsing property" - ParseError = "parse error" - - MissingPropertyValueError = "missing property value" - - UnexpectedASCIICharError = "unexpected char ascii" - UnexpectedDoubleQuoteInPropertyParamValueError = "unexpected double quote in property param value" - - UnbalancedEndError = "unbalanced end" - OutOfLinesError = "ran out of lines" +import ( + "errors" + "fmt" +) + +var ( + ErrUnexpectedParamValueLength = errors.New("unexpected end of param value") + + ErrMalformedCalendar = errors.New("malformed calendar") + + ErrMalformedCalendarExpectedVCalendar = fmt.Errorf("%w: expected a vcalendar", ErrMalformedCalendar) + ErrMalformedCalendarExpectedBegin = fmt.Errorf("%w: expected begin", ErrMalformedCalendar) + ErrMalformedCalendarExpectedEnd = fmt.Errorf("%w: expected a end", ErrMalformedCalendar) + ErrMalformedCalendarExpectedBeginOrEnd = fmt.Errorf("%w: expected begin or end", ErrMalformedCalendar) + + ErrMissingPropertyParamOperator = fmt.Errorf("%w: missing property param operator", ErrMalformedCalendar) + ErrUnexpectedEndOfProperty = fmt.Errorf("%w: unexpected end of property", ErrMalformedCalendar) + ErrMalformedCalendarUnexpectedEnd = fmt.Errorf("%w: unexpected end", ErrMalformedCalendar) + ErrMalformedCalendarBadState = fmt.Errorf("%w: bad state", ErrMalformedCalendar) + ErrMalformedCalendarVCalendarNotWhereExpected = fmt.Errorf("%w: vcalendar not where expected", ErrMalformedCalendar) + + ErrStartOrEndNotYetDefined = errors.New("start or end not yet defined") + // ErrPropertyNotFound is the error returned if the requested valid + // property is not set. + ErrPropertyNotFound = errors.New("property not found") + ErrExpectedOneTZID = errors.New("expected one TZID") + + ErrTimeValueNotMatched = errors.New("time value not matched") + ErrTimeValueMatchedButUnsupportedAllDayTimeStamp = errors.New("time value matched but unsupported all-day timestamp") + ErrTimeValueMatchedButNotSupported = errors.New("time value matched but not supported") + + ErrParsingComponentProperty = errors.New("parsing component property") + ErrParsingComponentLine = errors.New("parsing component line") + ErrParsingLine = errors.New("parsing line") + ErrParsingCalendarLine = errors.New("parsing calendar line") + ErrParsingProperty = errors.New("parsing property") + ErrParse = errors.New("parse error") + + ErrMissingPropertyValue = errors.New("missing property value") + + ErrUnexpectedASCIIChar = errors.New("unexpected char ascii") + ErrUnexpectedDoubleQuoteInPropertyParamValue = errors.New("unexpected double quote in property param value") + + ErrUnbalancedEnd = errors.New("unbalanced end") + ErrOutOfLines = errors.New("ran out of lines") ) diff --git a/go.mod b/go.mod index 26045ca..8be604a 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,17 @@ module github.com/arran4/golang-ical -go 1.13 +go 1.20 + +require ( + github.com/google/go-cmp v0.6.0 + github.com/stretchr/testify v1.7.0 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect - github.com/stretchr/testify v1.7.0 + github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/yaml.v3 v3.0.0 // indirect ) diff --git a/property.go b/property.go index 21616ef..1331dff 100644 --- a/property.go +++ b/property.go @@ -2,7 +2,6 @@ package ics import ( "bytes" - "errors" "fmt" "io" "log" @@ -234,7 +233,7 @@ func ParseProperty(contentLine ContentLine) (*BaseProperty, error) { t := r.IANAToken r, np, err = parsePropertyParam(r, string(contentLine), p+1) if err != nil { - return nil, fmt.Errorf("%s %s: %w", ParsingPropertyError, t, err) + return nil, fmt.Errorf("%w %s: %w", ErrParsingProperty, t, err) } if r == nil { return nil, nil @@ -255,13 +254,13 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper k = string(contentLine[p : p+tokenPos[1]]) p += tokenPos[1] if p >= len(contentLine) { - return nil, p, fmt.Errorf("missing property param operator for %s in %s", k, r.IANAToken) + return nil, p, fmt.Errorf("%w for %s in %s", ErrMissingPropertyParamOperator, k, r.IANAToken) } switch rune(contentLine[p]) { case '=': p += 1 default: - return nil, p, fmt.Errorf("%s for %s in %s", MissingPropertyValueError, k, r.IANAToken) + return nil, p, fmt.Errorf("%w for %s in %s", ErrMissingPropertyValue, k, r.IANAToken) } for { if p >= len(contentLine) { @@ -270,11 +269,11 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper var err error v, p, err = parsePropertyParamValue(contentLine, p) if err != nil { - return nil, 0, fmt.Errorf("%s: %w %s in %s", ParseError, err, k, r.IANAToken) + return nil, 0, fmt.Errorf("%w: %w %s in %s", ErrParse, err, k, r.IANAToken) } r.ICalParameters[k] = append(r.ICalParameters[k], v) if p >= len(contentLine) { - return nil, p, fmt.Errorf("unexpected end of property %s", r.IANAToken) + return nil, p, fmt.Errorf("%w %s", ErrUnexpectedEndOfProperty, r.IANAToken) } switch rune(contentLine[p]) { case ',': @@ -319,13 +318,13 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { for ; p < len(s) && !done; p++ { switch s[p] { case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) + return "", 0, fmt.Errorf("%w:%d in property param value", ErrUnexpectedASCIIChar, s[p]) case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F: - return "", 0, fmt.Errorf("%s:%d in property param value", UnexpectedASCIICharError, s[p]) + return "", 0, fmt.Errorf("%w:%d in property param value", ErrUnexpectedASCIIChar, s[p]) case '\\': if p+2 >= len(s) { - return "", 0, errors.New("unexpected end of param value") + return "", 0, ErrUnexpectedParamValueLength } r = append(r, []byte(FromText(string(s[p+1:p+2])))...) p++ @@ -345,7 +344,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) { done = true continue } - return "", 0, errors.New(UnexpectedDoubleQuoteInPropertyParamValueError) + return "", 0, ErrUnexpectedDoubleQuoteInPropertyParamValue } r = append(r, s[p]) } From 3a306dd9d1cac6adc80e1aa7149b520103608a06 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Mon, 21 Oct 2024 21:34:18 +1100 Subject: [PATCH 50/50] Error handling conflicts again (#2) * Add leaks and vunerability checks * Requires secrets now * Renamed interface as it's useful own it's own. Made it public. Added a comment * fix: omit zone in "AllDay" event helpers For a date-only event (i.e., an event that lasts for the full day) the iCalendar specification indicates that the value for DTSTART / DTEND should be a DATE https://icalendar.org/iCalendar-RFC-5545/3-6-1-event-component.html > The "VEVENT" is also the calendar component used to specify an > anniversary or daily reminder within a calendar. These events have a > DATE value type for the "DTSTART" property instead of the default value > type of DATE-TIME. If such a "VEVENT" has a "DTEND" property, it MUST be > specified as a DATE value also The DATE format (https://icalendar.org/iCalendar-RFC-5545/3-3-4-date.html) should omit both time and zone/location elements and additionally notes that "The "TZID" property parameter MUST NOT be applied to DATE properties" As per the specification, this PR also adds an explicit "VALUE=DATE" parameter when the AllDay helpers were called, to indicate that the property's default value type has been overridden and the VEVENT is intended to be an all-day event https://icalendar.org/iCalendar-RFC-5545/3-2-20-value-data-types.html Finally the SetDuration call has been updated to preserve the "AllDay" characteristics if the existing start or end has been specified in DATE format, which is also a requirement of the spec. Contributes-to: #55 * calendar parsing url support * usage example, README * added functionnal option pattern for url parsing * Should be able to distinguish unset from invalid time properties * Improve error for property not found Co-authored-by: Arran Ubels * Remove deprecated ioutil * Move targeted Go version to 1.20 And test the target forever * Add method to remove property Fixes https://github.com/arran4/golang-ical/issues/95 * Merged * New tool * Reintegration of https://github.com/arran4/golang-ical/pull/67 * Resynced and moved some functions around https://github.com/arran4/golang-ical/pull/78 * Test fixed. * Create bug.md * Create other.md * Create default.md * Rename default.md to pull_request_template.md * Minor update * refactor: remove unnecessary named return values, harmonizing code base * refactor: rename var to reflect what it is These functions take optional variadic PropertyParam arguments, in ical speak they are not properties, but parameters for a property. * refactor: use ReplaceAll * refactor: prefer switch for readability * refactor: use consistent receiver names * refactor: rename unused arg as '_' * Tests added. * Some larger upgrades. * Fix * Some multiple-ness. * Duplication issue fixed. * Merged * Merge remote-tracking branch 'origin/master' into issue97 * origin/master: Duplication issue fixed. Some multiple-ness. Test fixed. Resynced and moved some functions around https://github.com/arran4/golang-ical/pull/78 added functionnal option pattern for url parsing usage example, README calendar parsing url support # Conflicts: # calendar.go # calendar_test.go # components.go --------- Co-authored-by: Dominic Evans Co-authored-by: tradulescu Co-authored-by: Bracken Dawson Co-authored-by: Daniel Lublin --- .github/ISSUE_TEMPLATE/bug.md | 39 + .github/ISSUE_TEMPLATE/other.md | 15 + .../pull_request_template.md | 35 + README.md | 9 +- calendar.go | 396 +++++++-- calendar_serialization_test.go | 7 +- calendar_test.go | 99 ++- cmd/issues/test97_1/main.go | 34 + components.go | 823 +++++++++++------- components_test.go | 61 +- errors.go | 2 + os.go | 6 + os_unix.go | 5 + os_windows.go | 5 + property.go | 148 +++- property_test.go | 68 ++ testdata/issue97/google.ics | 23 + testdata/issue97/thunderbird.ics_disabled | 37 + 18 files changed, 1376 insertions(+), 436 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/other.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/pull_request_template.md create mode 100644 cmd/issues/test97_1/main.go create mode 100644 os.go create mode 100644 os_unix.go create mode 100644 os_windows.go create mode 100644 testdata/issue97/google.ics create mode 100644 testdata/issue97/thunderbird.ics_disabled diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..d73773d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,39 @@ +--- +name: 🐞 Bug +about: File a bug/issue +title: '[BUG] ' +labels: Bug, Needs Triage +assignees: '' + +--- + +<!-- +Note: Please search to see if an issue already exists for the bug you encountered. +--> + +### Current Behavior: +<!-- A concise description of what you're experiencing. --> + +### Expected Behavior: +<!-- A concise description of what you expected to happen. --> + +### Steps To Reproduce: +<!-- +Example: steps to reproduce the behavior: +1. In this environment... +1. With this config... +1. Run '...' +1. See error... +--> + +### Minimal Example ical extract: + +```ical +BEGIN:VCALENDAR +.... +``` + +### Anything else: +<!-- +Links? References? Anything that will give us more context about the issue that you are encountering! +--> diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md new file mode 100644 index 0000000..9933437 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.md @@ -0,0 +1,15 @@ +--- +name: Something else +about: Any other issue +title: '<title>' +labels: Needs Triage +assignees: '' + +--- + +<!-- +Note: Please search to see if an issue already exists for the bug you encountered. + +Please include smallest possible sized examples. +--> + diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..58d632e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,35 @@ +<!-- + +Thanks for contributing! + +--> + +# Pull Request Template + +## Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +<!-- The following line will automatically close the issues if done correctly --> + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- This might take a while to be considered + +## Must haves: + +- [ ] I have commented in hard-to-understand areas and anywhere the function not immediately apparent +- [ ] I have made corresponding changes to the comments (if any) +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove the fix is effective or that my feature works +- [ ] I have added tests that protects the code from degradation in the future + +## Nice to haves: + +- [ ] I have added additional function comments to new or existing functions 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 471741a..7d9b2d8 100644 --- a/calendar.go +++ b/calendar.go @@ -3,9 +3,13 @@ package ics import ( "bufio" "bytes" + "context" "errors" "fmt" "io" + "net/http" + "reflect" + "strings" "time" ) @@ -61,8 +65,115 @@ 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 +} + +func ComponentPropertyExtended(s string) ComponentProperty { + return ComponentProperty("X-" + strings.TrimPrefix("X-", s)) +} + type Property string const ( @@ -126,6 +237,14 @@ const ( type Parameter string +func (p Parameter) IsQuoted() bool { + switch p { + case ParameterAltrep: + return true + } + return false +} + const ( ParameterAltrep Parameter = "ALTREP" ParameterCn Parameter = "CN" @@ -178,7 +297,7 @@ const ( CalendarUserTypeUnknown CalendarUserType = "UNKNOWN" ) -func (cut CalendarUserType) KeyValue(s ...interface{}) (string, []string) { +func (cut CalendarUserType) KeyValue(_ ...interface{}) (string, []string) { return string(ParameterCutype), []string{string(cut)} } @@ -203,7 +322,7 @@ const ( ParticipationStatusInProcess ParticipationStatus = "IN-PROCESS" ) -func (ps ParticipationStatus) KeyValue(s ...interface{}) (string, []string) { +func (ps ParticipationStatus) KeyValue(_ ...interface{}) (string, []string) { return string(ParameterParticipationStatus), []string{string(ps)} } @@ -220,7 +339,7 @@ const ( ObjectStatusFinal ObjectStatus = "FINAL" ) -func (ps ObjectStatus) KeyValue(s ...interface{}) (string, []string) { +func (ps ObjectStatus) KeyValue(_ ...interface{}) (string, []string) { return string(PropertyStatus), []string{string(ps)} } @@ -241,7 +360,7 @@ const ( ParticipationRoleNonParticipant ParticipationRole = "NON-PARTICIPANT" ) -func (pr ParticipationRole) KeyValue(s ...interface{}) (string, []string) { +func (pr ParticipationRole) KeyValue(_ ...interface{}) (string, []string) { return string(ParameterRole), []string{string(pr)} } @@ -298,102 +417,149 @@ func NewCalendarFor(service string) *Calendar { return c } -func (calendar *Calendar) Serialize() string { +func (cal *Calendar) Serialize(ops ...any) string { b := bytes.NewBufferString("") // We are intentionally ignoring the return value. _ used to communicate this to lint. - _ = calendar.SerializeTo(b) + _ = cal.SerializeTo(b, ops...) return b.String() } -func (calendar *Calendar) SerializeTo(w io.Writer) error { - _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", "\r\n") - for _, p := range calendar.CalendarProperties { - p.serialize(w) +type WithLineLength int +type WithNewLine string + +func (cal *Calendar) SerializeTo(w io.Writer, ops ...any) error { + serializeConfig, err := parseSerializeOps(ops) + if err != nil { + return err + } + _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", serializeConfig.NewLine) + for _, p := range cal.CalendarProperties { + err := p.serialize(w, serializeConfig) + if err != nil { + return err + } } - for _, c := range calendar.Components { - c.SerializeTo(w) + for _, c := range cal.Components { + err := c.SerializeTo(w, serializeConfig) + if err != nil { + return err + } } - _, _ = fmt.Fprint(w, "END:VCALENDAR", "\r\n") + _, _ = fmt.Fprint(w, "END:VCALENDAR", serializeConfig.NewLine) return nil } -func (calendar *Calendar) SetMethod(method Method, props ...PropertyParameter) { - calendar.setProperty(PropertyMethod, string(method), props...) +type SerializationConfiguration struct { + MaxLength int + NewLine string + PropertyMaxLength int +} + +func parseSerializeOps(ops []any) (*SerializationConfiguration, error) { + serializeConfig := defaultSerializationOptions() + for opi, op := range ops { + switch op := op.(type) { + case WithLineLength: + serializeConfig.MaxLength = int(op) + case WithNewLine: + serializeConfig.NewLine = string(op) + case *SerializationConfiguration: + return op, nil + case error: + return nil, op + default: + return nil, fmt.Errorf("unknown op %d of type %s", opi, reflect.TypeOf(op)) + } + } + return serializeConfig, nil +} + +func defaultSerializationOptions() *SerializationConfiguration { + serializeConfig := &SerializationConfiguration{ + MaxLength: 75, + PropertyMaxLength: 75, + NewLine: string(NewLine), + } + return serializeConfig +} + +func (cal *Calendar) SetMethod(method Method, params ...PropertyParameter) { + cal.setProperty(PropertyMethod, string(method), params...) } -func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXPublishedTTL, s, props...) +func (cal *Calendar) SetXPublishedTTL(s string, params ...PropertyParameter) { + cal.setProperty(PropertyXPublishedTTL, s, params...) } -func (calendar *Calendar) SetVersion(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyVersion, s, props...) +func (cal *Calendar) SetVersion(s string, params ...PropertyParameter) { + cal.setProperty(PropertyVersion, s, params...) } -func (calendar *Calendar) SetProductId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyProductId, s, props...) +func (cal *Calendar) SetProductId(s string, params ...PropertyParameter) { + cal.setProperty(PropertyProductId, s, params...) } -func (calendar *Calendar) SetName(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyName, s, props...) - calendar.setProperty(PropertyXWRCalName, s, props...) +func (cal *Calendar) SetName(s string, params ...PropertyParameter) { + cal.setProperty(PropertyName, s, params...) + cal.setProperty(PropertyXWRCalName, s, params...) } -func (calendar *Calendar) SetColor(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyColor, s, props...) +func (cal *Calendar) SetColor(s string, params ...PropertyParameter) { + cal.setProperty(PropertyColor, s, params...) } -func (calendar *Calendar) SetXWRCalName(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalName, s, props...) +func (cal *Calendar) SetXWRCalName(s string, params ...PropertyParameter) { + cal.setProperty(PropertyXWRCalName, s, params...) } -func (calendar *Calendar) SetXWRCalDesc(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalDesc, s, props...) +func (cal *Calendar) SetXWRCalDesc(s string, params ...PropertyParameter) { + cal.setProperty(PropertyXWRCalDesc, s, params...) } -func (calendar *Calendar) SetXWRTimezone(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRTimezone, s, props...) +func (cal *Calendar) SetXWRTimezone(s string, params ...PropertyParameter) { + cal.setProperty(PropertyXWRTimezone, s, params...) } -func (calendar *Calendar) SetXWRCalID(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyXWRCalID, s, props...) +func (cal *Calendar) SetXWRCalID(s string, params ...PropertyParameter) { + cal.setProperty(PropertyXWRCalID, s, params...) } -func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyDescription, s, props...) +func (cal *Calendar) SetDescription(s string, params ...PropertyParameter) { + cal.setProperty(PropertyDescription, s, params...) } -func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) { - calendar.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cal *Calendar) SetLastModified(t time.Time, params ...PropertyParameter) { + cal.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (calendar *Calendar) SetRefreshInterval(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyRefreshInterval, s, props...) +func (cal *Calendar) SetRefreshInterval(s string, params ...PropertyParameter) { + cal.setProperty(PropertyRefreshInterval, s, params...) } -func (calendar *Calendar) SetCalscale(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyCalscale, s, props...) +func (cal *Calendar) SetCalscale(s string, params ...PropertyParameter) { + cal.setProperty(PropertyCalscale, s, params...) } -func (calendar *Calendar) SetUrl(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyUrl, s, props...) +func (cal *Calendar) SetUrl(s string, params ...PropertyParameter) { + cal.setProperty(PropertyUrl, s, params...) } -func (calendar *Calendar) SetTzid(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyTzid, s, props...) +func (cal *Calendar) SetTzid(s string, params ...PropertyParameter) { + cal.setProperty(PropertyTzid, s, params...) } -func (calendar *Calendar) SetTimezoneId(s string, props ...PropertyParameter) { - calendar.setProperty(PropertyTimezoneId, s, props...) +func (cal *Calendar) SetTimezoneId(s string, params ...PropertyParameter) { + cal.setProperty(PropertyTimezoneId, s, params...) } -func (calendar *Calendar) setProperty(property Property, value string, props ...PropertyParameter) { - for i := range calendar.CalendarProperties { - if calendar.CalendarProperties[i].IANAToken == string(property) { - calendar.CalendarProperties[i].Value = value - calendar.CalendarProperties[i].ICalParameters = map[string][]string{} - for _, p := range props { +func (cal *Calendar) setProperty(property Property, value string, params ...PropertyParameter) { + for i := range cal.CalendarProperties { + if cal.CalendarProperties[i].IANAToken == string(property) { + cal.CalendarProperties[i].Value = value + cal.CalendarProperties[i].ICalParameters = map[string][]string{} + for _, p := range params { k, v := p.KeyValue() - calendar.CalendarProperties[i].ICalParameters[k] = v + cal.CalendarProperties[i].ICalParameters[k] = v } return } @@ -405,11 +571,113 @@ func (calendar *Calendar) setProperty(property Property, value string, props ... ICalParameters: map[string][]string{}, }, } - for _, p := range props { + for _, p := range params { k, v := p.KeyValue() r.ICalParameters[k] = v } - calendar.CalendarProperties = append(calendar.CalendarProperties, r) + 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) { @@ -516,13 +784,14 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) { for c { var b []byte b, err = cs.b.ReadBytes('\n') - if len(b) == 0 { + switch { + case len(b) == 0: if err == nil { continue } else { c = false } - } else if b[len(b)-1] == '\n' { + case b[len(b)-1] == '\n': o := 1 if len(b) > 1 && b[len(b)-2] == '\r' { o = 2 @@ -532,14 +801,15 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) { if errors.Is(err, io.EOF) { c = false } - if len(p) == 0 { + switch { + case len(p) == 0: c = false - } else if p[0] == ' ' || p[0] == '\t' { + case p[0] == ' ' || p[0] == '\t': _, _ = cs.b.Discard(1) // nolint:errcheck - } else { + default: c = false } - } else { + default: r = append(r, b...) } switch { diff --git a/calendar_serialization_test.go b/calendar_serialization_test.go index eec5871..a51db82 100644 --- a/calendar_serialization_test.go +++ b/calendar_serialization_test.go @@ -31,15 +31,16 @@ func TestCalendar_ReSerialization(t *testing.T) { } for _, filename := range testFileNames { - t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", filename), func(t *testing.T) { + fp := filepath.Join(testDir, filename) + t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", fp), func(t *testing.T) { //given - originalSeriailizedCal, err := os.ReadFile(filepath.Join(testDir, filename)) + originalSeriailizedCal, err := os.ReadFile(fp) require.NoError(t, err) //when deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal)) require.NoError(t, err) - serializedCal := deserializedCal.Serialize() + serializedCal := deserializedCal.Serialize(WithNewLineWindows) //then expectedCal, err := os.ReadFile(filepath.Join(expectedDir, filename)) diff --git a/calendar_test.go b/calendar_test.go index a1e9ddd..9544c67 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -3,8 +3,13 @@ package ics import ( "errors" "github.com/stretchr/testify/assert" + "bytes" + "embed" + _ "embed" + "github.com/google/go-cmp/cmp" "io" - "os" + "io/fs" + "net/http" "path/filepath" "regexp" "strings" @@ -13,8 +18,13 @@ import ( "unicode/utf8" ) +var ( + //go:embed testdata/* + TestData embed.FS +) + func TestTimeParsing(t *testing.T) { - calFile, err := os.OpenFile("./testdata/timeparsing.ics", os.O_RDONLY, 0400) + calFile, err := TestData.Open("testdata/timeparsing.ics") if err != nil { t.Errorf("read file: %v", err) } @@ -162,12 +172,15 @@ CLASS:PUBLIC func TestRfc5545Sec4Examples(t *testing.T) { rnReplace := regexp.MustCompile("\r?\n") - err := filepath.Walk("./testdata/rfc5545sec4/", func(path string, info os.FileInfo, _ error) error { + err := fs.WalkDir(TestData, "testdata/rfc5545sec4", func(path string, info fs.DirEntry, err error) error { + if err != nil { + return err + } if info.IsDir() { return nil } - inputBytes, err := os.ReadFile(path) + inputBytes, err := fs.ReadFile(TestData, path) if err != nil { return err } @@ -261,7 +274,7 @@ END:VCALENDAR c := NewCalendar() c.SetDescription(tc.input) // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks - text := strings.Replace(c.Serialize(), "\r\n", "\n", -1) + text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n") assert.Equal(t, tc.output, text) assert.True(t, utf8.ValidString(text), "Serialized .ics calendar isn't valid UTF-8 string") @@ -378,7 +391,7 @@ END:VCALENDAR } // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks - text := strings.Replace(c.Serialize(), "\r\n", "\n", -1) + text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n") if !assert.Equal(t, tc.output, text) { return } @@ -387,13 +400,13 @@ END:VCALENDAR } func TestIssue52(t *testing.T) { - err := filepath.Walk("./testdata/issue52/", func(path string, info os.FileInfo, _ error) error { + err := fs.WalkDir(TestData, "testdata/issue52", func(path string, info fs.DirEntry, _ error) error { if info.IsDir() { return nil } _, fn := filepath.Split(path) t.Run(fn, func(t *testing.T) { - f, err := os.Open(path) + f, err := TestData.Open(path) if err != nil && errors.Is(err, io.EOF) { t.Fatalf("Error reading file: %s", err) } @@ -411,3 +424,73 @@ func TestIssue52(t *testing.T) { t.Fatalf("cannot read test directory: %v", err) } } + +func TestIssue97(t *testing.T) { + err := fs.WalkDir(TestData, "testdata/issue97", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(d.Name(), ".ics") && !strings.HasSuffix(d.Name(), ".ics_disabled") { + return nil + } + t.Run(path, func(t *testing.T) { + if strings.HasSuffix(d.Name(), ".ics_disabled") { + t.Skipf("Test disabled") + } + b, err := TestData.ReadFile(path) + if err != nil { + t.Fatalf("Error reading file: %s", err) + } + ics, err := ParseCalendar(bytes.NewReader(b)) + if err != nil { + t.Fatalf("Error parsing file: %s", err) + } + + got := ics.Serialize(WithLineLength(74)) + if diff := cmp.Diff(string(b), got, cmp.Transformer("ToUnixText", func(a string) string { + return strings.ReplaceAll(a, "\r\n", "\n") + })); diff != "" { + t.Errorf("ParseCalendar() mismatch (-want +got):\n%s", diff) + t.Errorf("Complete got:\b%s", got) + } + }) + return nil + }) + + if err != nil { + 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/cmd/issues/test97_1/main.go b/cmd/issues/test97_1/main.go new file mode 100644 index 0000000..3eceab4 --- /dev/null +++ b/cmd/issues/test97_1/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "fmt" + ics "github.com/arran4/golang-ical" + "net/url" +) + +func main() { + i := ics.NewCalendarFor("Mozilla.org/NONSGML Mozilla Calendar V1.1") + tz := i.AddTimezone("Europe/Berlin") + tz.AddProperty(ics.ComponentPropertyExtended("TZINFO"), "Europe/Berlin[2024a]") + tzstd := tz.AddStandard() + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetto), "+010000") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetfrom), "+005328") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzname), "Europe/Berlin(STD)") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyDtstart), "18930401T000000") + tzstd.AddProperty(ics.ComponentProperty(ics.PropertyRdate), "18930401T000000") + vEvent := i.AddEvent("d23cef0d-9e58-43c4-9391-5ad8483ca346") + vEvent.AddProperty(ics.ComponentPropertyCreated, "20240929T120640Z") + vEvent.AddProperty(ics.ComponentPropertyLastModified, "20240929T120731Z") + vEvent.AddProperty(ics.ComponentPropertyDtstamp, "20240929T120731Z") + vEvent.AddProperty(ics.ComponentPropertySummary, "Test Event") + vEvent.AddProperty(ics.ComponentPropertyDtStart, "20240929T144500", ics.WithTZID("Europe/Berlin")) + vEvent.AddProperty(ics.ComponentPropertyDtEnd, "20240929T154500", ics.WithTZID("Europe/Berlin")) + vEvent.AddProperty(ics.ComponentPropertyTransp, "OPAQUE") + vEvent.AddProperty(ics.ComponentPropertyLocation, "Github") + uri := &url.URL{ + Scheme: "data", + Opaque: "text/html,I%20want%20a%20custom%20linkout%20for%20Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20%3Ca%20href%3D%22https%3A%2F%2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22%3EIssue%3C%2Fa%3E.", + } + vEvent.AddProperty(ics.ComponentPropertyDescription, "I want a custom linkout for Thunderbird.\nThis is the Github Issue.", ics.WithAlternativeRepresentation(uri)) + fmt.Println(i.Serialize()) +} diff --git a/components.go b/components.go index 5687a79..d71dc52 100644 --- a/components.go +++ b/components.go @@ -20,7 +20,7 @@ import ( type Component interface { UnknownPropertiesIANAProperties() []IANAProperty SubComponents() []Component - SerializeTo(b io.Writer) + SerializeTo(b io.Writer, serialConfig *SerializationConfiguration) error } var ( @@ -43,15 +43,22 @@ func (cb *ComponentBase) SubComponents() []Component { return cb.Components } -func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { - _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") +func (cb *ComponentBase) serializeThis(writer io.Writer, componentType ComponentType, serialConfig *SerializationConfiguration) error { + _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, serialConfig.NewLine) for _, p := range cb.Properties { - p.serialize(writer) + err := p.serialize(writer, serialConfig) + if err != nil { + return err + } } for _, c := range cb.Components { - c.SerializeTo(writer) + err := c.SerializeTo(writer, serialConfig) + if err != nil { + return err + } } - _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") + _, err := fmt.Fprint(writer, "END:"+componentType, serialConfig.NewLine) + return err } func NewComponent(uniqueId string) ComponentBase { @@ -62,6 +69,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,22 +80,58 @@ func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAP return nil } -func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, props ...PropertyParameter) { +// 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) { cb.Properties[i].Value = value cb.Properties[i].ICalParameters = map[string][]string{} - for _, p := range props { + for _, p := range params { k, v := p.KeyValue() cb.Properties[i].ICalParameters[k] = v } return } } - cb.AddProperty(property, value, props...) + 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 } -func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, props ...PropertyParameter) { +// AddProperty appends a property +func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, params ...PropertyParameter) { r := IANAProperty{ BaseProperty{ IANAToken: string(property), @@ -94,23 +139,51 @@ func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, p ICalParameters: map[string][]string{}, }, } - for _, p := range props { + for _, p := range params { k, v := p.KeyValue() r.ICalParameters[k] = v } 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 ( @@ -120,38 +193,46 @@ const ( icalDateFormatLocal = "20060102" ) -var ( - timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$") -) +var timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$") -func (cb *ComponentBase) SetCreatedTime(t time.Time, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetCreatedTime(t time.Time, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (cb *ComponentBase) SetDtStampTime(t time.Time, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetDtStampTime(t time.Time, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (cb *ComponentBase) SetModifiedAt(t time.Time, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetModifiedAt(t time.Time, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (cb *ComponentBase) SetSequence(seq int, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...) +func (cb *ComponentBase) SetSequence(seq int, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), params...) } -func (cb *ComponentBase) SetStartAt(t time.Time, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetStartAt(t time.Time, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (cb *ComponentBase) SetAllDayStartAt(t time.Time, props ...PropertyParameter) { - props = append(props, WithValue(string(ValueDataTypeDate))) - cb.SetProperty(ComponentPropertyDtStart, t.Format(icalDateFormatLocal), props...) +func (cb *ComponentBase) SetAllDayStartAt(t time.Time, params ...PropertyParameter) { + cb.SetProperty( + ComponentPropertyDtStart, + t.Format(icalDateFormatLocal), + append(params, WithValue(string(ValueDataTypeDate)))..., + ) } -func (cb *ComponentBase) SetAllDayEndAt(t time.Time, props ...PropertyParameter) { - props = append(props, WithValue(string(ValueDataTypeDate))) - cb.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), props...) +func (cb *ComponentBase) SetEndAt(t time.Time, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), params...) +} + +func (cb *ComponentBase) SetAllDayEndAt(t time.Time, params ...PropertyParameter) { + cb.SetProperty( + ComponentPropertyDtEnd, + t.Format(icalDateFormatLocal), + append(params, WithValue(string(ValueDataTypeDate)))..., + ) } // SetDuration updates the duration of an event. @@ -159,23 +240,42 @@ func (cb *ComponentBase) SetAllDayEndAt(t time.Time, props ...PropertyParameter) // The duration defines the length of a event relative to start or end time. // // Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. -func (event *VEvent) SetDuration(d time.Duration) error { - t, err := event.GetStartAt() - if err == nil { - event.SetEndAt(t.Add(d)) - return nil - } else { - t, err = event.GetEndAt() +func (cb *ComponentBase) SetDuration(d time.Duration) error { + startProp := cb.GetProperty(ComponentPropertyDtStart) + if startProp != nil { + t, err := cb.GetStartAt() + if err == nil { + v, _ := startProp.parameterValue(ParameterValue) + if v == string(ValueDataTypeDate) { + cb.SetAllDayEndAt(t.Add(d)) + } else { + cb.SetEndAt(t.Add(d)) + } + return nil + } + } + endProp := cb.GetProperty(ComponentPropertyDtEnd) + if endProp != nil { + t, err := cb.GetEndAt() if err == nil { - event.SetStartAt(t.Add(-d)) + v, _ := endProp.parameterValue(ParameterValue) + if v == string(ValueDataTypeDate) { + cb.SetAllDayStartAt(t.Add(-d)) + } else { + cb.SetStartAt(t.Add(-d)) + } return nil } } - return ErrStartOrEndNotYetDefined + return errors.New("start or end not yet defined") } -func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { - timeProp := event.GetProperty(componentProperty) +func (cb *ComponentBase) GetEndAt() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyDtEnd, false) +} + +func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) { + timeProp := cb.GetProperty(componentProperty) if timeProp == nil { return time.Time{}, fmt.Errorf("%w: %s", ErrPropertyNotFound, componentProperty) } @@ -220,17 +320,18 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButUnsupportedAllDayTimeStamp, timeVal) } - if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" { + switch { + case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z": return time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC) - } else if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "" { + case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "": if propLoc == nil { return time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local) } else { return time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc) } - } else if grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "" { + case grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "": return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC) - } else if grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "" { + case grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "": if propLoc == nil { return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local) } else { @@ -241,96 +342,96 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButNotSupported, timeVal) } -func (cb *VEvent) GetStartAt() (time.Time, error) { +func (cb *ComponentBase) GetStartAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtStart, false) } -func (cb *VEvent) GetAllDayStartAt() (time.Time, error) { +func (cb *ComponentBase) GetAllDayStartAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtStart, true) } -func (cb *VEvent) GetLastModifiedAt() (time.Time, error) { +func (cb *ComponentBase) GetLastModifiedAt() (time.Time, error) { return cb.getTimeProp(ComponentPropertyLastModified, false) } -func (cb *VEvent) GetDtStampTime() (time.Time, error) { +func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { return cb.getTimeProp(ComponentPropertyDtstamp, false) } -func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertySummary, s, props...) +func (cb *ComponentBase) SetSummary(s string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertySummary, s, params...) } -func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyStatus, string(s), props...) +func (cb *ComponentBase) SetStatus(s ObjectStatus, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyStatus, string(s), params...) } -func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyDescription, s, props...) +func (cb *ComponentBase) SetDescription(s string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDescription, s, params...) } -func (cb *ComponentBase) SetLocation(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyLocation, s, props...) +func (cb *ComponentBase) SetLocation(s string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLocation, s, params...) } -func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), props...) +func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), params...) } -func (cb *ComponentBase) SetURL(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyUrl, s, props...) +func (cb *ComponentBase) SetURL(s string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyUrl, s, params...) } -func (cb *ComponentBase) SetOrganizer(s string, props ...PropertyParameter) { +func (cb *ComponentBase) SetOrganizer(s string, params ...PropertyParameter) { if !strings.HasPrefix(s, "mailto:") { s = "mailto:" + s } - cb.SetProperty(ComponentPropertyOrganizer, s, props...) + cb.SetProperty(ComponentPropertyOrganizer, s, params...) } -func (cb *ComponentBase) SetColor(s string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyColor, s, props...) +func (cb *ComponentBase) SetColor(s string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyColor, s, params...) } -func (cb *ComponentBase) SetClass(c Classification, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyClass, string(c), props...) +func (cb *ComponentBase) SetClass(c Classification, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyClass, string(c), params...) } -func (cb *ComponentBase) setPriority(p int, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), props...) +func (cb *ComponentBase) setPriority(p int, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), params...) } -func (cb *ComponentBase) setResources(r string, props ...PropertyParameter) { - cb.SetProperty(ComponentPropertyResources, r, props...) +func (cb *ComponentBase) setResources(r string, params ...PropertyParameter) { + cb.SetProperty(ComponentPropertyResources, r, params...) } -func (cb *ComponentBase) AddAttendee(s string, props ...PropertyParameter) { +func (cb *ComponentBase) AddAttendee(s string, params ...PropertyParameter) { if !strings.HasPrefix(s, "mailto:") { s = "mailto:" + s } - cb.AddProperty(ComponentPropertyAttendee, s, props...) + cb.AddProperty(ComponentPropertyAttendee, s, params...) } -func (cb *ComponentBase) AddExdate(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyExdate, s, props...) +func (cb *ComponentBase) AddExdate(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyExdate, s, params...) } -func (cb *ComponentBase) AddExrule(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyExrule, s, props...) +func (cb *ComponentBase) AddExrule(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyExrule, s, params...) } -func (cb *ComponentBase) AddRdate(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyRdate, s, props...) +func (cb *ComponentBase) AddRdate(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyRdate, s, params...) } -func (cb *ComponentBase) AddRrule(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyRrule, s, props...) +func (cb *ComponentBase) AddRrule(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyRrule, s, params...) } -func (cb *ComponentBase) AddAttachment(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyAttach, s, props...) +func (cb *ComponentBase) AddAttachment(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyAttach, s, params...) } func (cb *ComponentBase) AddAttachmentURL(uri string, contentType string) { @@ -343,46 +444,46 @@ func (cb *ComponentBase) AddAttachmentBinary(binary []byte, contentType string) ) } -func (cb *ComponentBase) AddComment(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyComment, s, props...) +func (cb *ComponentBase) AddComment(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyComment, s, params...) } -func (cb *ComponentBase) AddCategory(s string, props ...PropertyParameter) { - cb.AddProperty(ComponentPropertyCategories, s, props...) +func (cb *ComponentBase) AddCategory(s string, params ...PropertyParameter) { + cb.AddProperty(ComponentPropertyCategories, s, params...) } type Attendee struct { IANAProperty } -func (attendee *Attendee) Email() string { - if strings.HasPrefix(attendee.Value, "mailto:") { - return attendee.Value[len("mailto:"):] +func (p *Attendee) Email() string { + if strings.HasPrefix(p.Value, "mailto:") { + return p.Value[len("mailto:"):] } - return attendee.Value + return p.Value } -func (attendee *Attendee) ParticipationStatus() ParticipationStatus { - return ParticipationStatus(attendee.getPropertyFirst(ParameterParticipationStatus)) +func (p *Attendee) ParticipationStatus() ParticipationStatus { + return ParticipationStatus(p.getPropertyFirst(ParameterParticipationStatus)) } -func (attendee *Attendee) getPropertyFirst(parameter Parameter) string { - vs := attendee.getProperty(parameter) +func (p *Attendee) getPropertyFirst(parameter Parameter) string { + vs := p.getProperty(parameter) if len(vs) > 0 { return vs[0] } return "" } -func (attendee *Attendee) getProperty(parameter Parameter) []string { - if vs, ok := attendee.ICalParameters[string(parameter)]; ok { +func (p *Attendee) getProperty(parameter Parameter) []string { + if vs, ok := p.ICalParameters[string(parameter)]; ok { return vs } return nil } -func (cb *ComponentBase) Attendees() (r []*Attendee) { - r = []*Attendee{} +func (cb *ComponentBase) Attendees() []*Attendee { + var r []*Attendee for i := range cb.Properties { switch cb.Properties[i].IANAToken { case string(ComponentPropertyAttendee): @@ -392,7 +493,7 @@ func (cb *ComponentBase) Attendees() (r []*Attendee) { r = append(r, a) } } - return + return r } func (cb *ComponentBase) Id() string { @@ -415,29 +516,34 @@ func (cb *ComponentBase) addVAlarm(a *VAlarm) { cb.Components = append(cb.Components, a) } -func (cb *ComponentBase) alarms() (r []*VAlarm) { - r = []*VAlarm{} +func (cb *ComponentBase) alarms() []*VAlarm { + var r []*VAlarm for i := range cb.Components { switch alarm := cb.Components[i].(type) { case *VAlarm: r = append(r, alarm) } } - return + return r } type VEvent struct { ComponentBase } -func (event *VEvent) SerializeTo(w io.Writer) { - event.ComponentBase.serializeThis(w, "VEVENT") +func (event *VEvent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return event.ComponentBase.serializeThis(w, ComponentVEvent, serialConfig) +} + +func (event *VEvent) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := event.serialize(serialConfig) + return s } -func (event *VEvent) Serialize() string { +func (event *VEvent) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - event.ComponentBase.serializeThis(b, "VEVENT") - return b.String() + err := event.ComponentBase.serializeThis(b, ComponentVEvent, serialConfig) + return b.String(), err } func NewEvent(uniqueId string) *VEvent { @@ -447,43 +553,6 @@ func NewEvent(uniqueId string) *VEvent { return e } -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) 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 (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 (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) { event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...) } @@ -492,16 +561,17 @@ func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { - event.setGeo(lat, lng, props...) +// TODO use generics +func (event *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { + event.setGeo(lat, lng, params...) } -func (event *VEvent) SetPriority(p int, props ...PropertyParameter) { - event.setPriority(p, props...) +func (event *VEvent) SetPriority(p int, params ...PropertyParameter) { + event.setPriority(p, params...) } -func (event *VEvent) SetResources(r string, props ...PropertyParameter) { - event.setResources(r, props...) +func (event *VEvent) SetResources(r string, params ...PropertyParameter) { + event.setResources(r, params...) } func (event *VEvent) AddAlarm() *VAlarm { @@ -512,14 +582,10 @@ func (event *VEvent) AddVAlarm(a *VAlarm) { event.addVAlarm(a) } -func (event *VEvent) Alarms() (r []*VAlarm) { +func (event *VEvent) Alarms() []*VAlarm { return event.alarms() } -func (event *VEvent) GetEndAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtEnd, false) -} - func (event *VEvent) GetAllDayEndAt() (time.Time, error) { return event.getTimeProp(ComponentPropertyDtEnd, true) } @@ -531,22 +597,30 @@ const ( TransparencyTransparent TimeTransparency = "TRANSPARENT" ) -func (event *VEvent) SetTimeTransparency(v TimeTransparency, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyTransp, string(v), props...) +func (event *VEvent) SetTimeTransparency(v TimeTransparency, params ...PropertyParameter) { + event.SetProperty(ComponentPropertyTransp, string(v), params...) } type VTodo struct { ComponentBase } -func (todo *VTodo) SerializeTo(w io.Writer) { - todo.ComponentBase.serializeThis(w, "VTODO") +func (todo *VTodo) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return todo.ComponentBase.serializeThis(w, ComponentVTodo, serialConfig) +} + +func (todo *VTodo) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := todo.serialize(serialConfig) + return s } -func (todo *VTodo) Serialize() string { +func (todo *VTodo) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - todo.ComponentBase.serializeThis(b, "VTODO") - return b.String() + err := todo.ComponentBase.serializeThis(b, ComponentVTodo, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } func NewTodo(uniqueId string) *VTodo { @@ -556,59 +630,79 @@ func NewTodo(uniqueId string) *VTodo { return e } -func (calendar *Calendar) AddTodo(id string) *VTodo { +func (cal *Calendar) AddTodo(id string) *VTodo { e := NewTodo(id) - calendar.Components = append(calendar.Components, e) + cal.Components = append(cal.Components, e) return e } -func (calendar *Calendar) AddVTodo(e *VTodo) { - calendar.Components = append(calendar.Components, e) +func (cal *Calendar) AddVTodo(e *VTodo) { + cal.Components = append(cal.Components, e) } -func (calendar *Calendar) Todos() (r []*VTodo) { - r = []*VTodo{} - for i := range calendar.Components { - switch todo := calendar.Components[i].(type) { +func (cal *Calendar) Todos() []*VTodo { + var r []*VTodo + for i := range cal.Components { + switch todo := cal.Components[i].(type) { case *VTodo: r = append(r, todo) } } - return + return r } -func (todo *VTodo) SetCompletedAt(t time.Time, props ...PropertyParameter) { - todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), props...) +func (todo *VTodo) SetCompletedAt(t time.Time, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (todo *VTodo) SetAllDayCompletedAt(t time.Time, props ...PropertyParameter) { - props = append(props, WithValue(string(ValueDataTypeDate))) - todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), props...) +func (todo *VTodo) SetAllDayCompletedAt(t time.Time, params ...PropertyParameter) { + params = append(params, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), params...) } -func (todo *VTodo) SetDueAt(t time.Time, props ...PropertyParameter) { - todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), props...) +func (todo *VTodo) SetDueAt(t time.Time, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), params...) } -func (todo *VTodo) SetAllDayDueAt(t time.Time, props ...PropertyParameter) { - props = append(props, WithValue(string(ValueDataTypeDate))) - todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), props...) +func (todo *VTodo) SetAllDayDueAt(t time.Time, params ...PropertyParameter) { + params = append(params, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), params...) } -func (todo *VTodo) SetPercentComplete(p int, props ...PropertyParameter) { - todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), props...) +func (todo *VTodo) SetPercentComplete(p int, params ...PropertyParameter) { + todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), params...) } -func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, props ...PropertyParameter) { - todo.setGeo(lat, lng, props...) +func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) { + todo.setGeo(lat, lng, params...) } -func (todo *VTodo) SetPriority(p int, props ...PropertyParameter) { - todo.setPriority(p, props...) +func (todo *VTodo) SetPriority(p int, params ...PropertyParameter) { + todo.setPriority(p, params...) } -func (todo *VTodo) SetResources(r string, props ...PropertyParameter) { - todo.setResources(r, props...) +func (todo *VTodo) SetResources(r string, params ...PropertyParameter) { + todo.setResources(r, params...) +} + +// SetDuration updates the duration of an event. +// This function will set either the end or start time of an event depending what is already given. +// The duration defines the length of a event relative to start or end time. +// +// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected. +func (todo *VTodo) SetDuration(d time.Duration) error { + t, err := todo.GetStartAt() + if err == nil { + todo.SetDueAt(t.Add(d)) + return nil + } else { + t, err = todo.GetDueAt() + if err == nil { + todo.SetStartAt(t.Add(-d)) + return nil + } + } + return errors.New("start or end not yet defined") } func (todo *VTodo) AddAlarm() *VAlarm { @@ -619,11 +713,12 @@ func (todo *VTodo) AddVAlarm(a *VAlarm) { todo.addVAlarm(a) } -func (todo *VTodo) Alarms() (r []*VAlarm) { +func (todo *VTodo) Alarms() []*VAlarm { return todo.alarms() } -func (todo *VEvent) GetDueAt() (time.Time, error) { +// TODO verify that due is only relevant to VTodo if not move to ComponentBase. +func (todo *VTodo) GetDueAt() (time.Time, error) { return todo.getTimeProp(ComponentPropertyDue, false) } @@ -635,14 +730,22 @@ type VJournal struct { ComponentBase } -func (c *VJournal) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VJOURNAL") +func (journal *VJournal) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return journal.ComponentBase.serializeThis(w, ComponentVJournal, serialConfig) } -func (c *VJournal) Serialize() string { +func (journal *VJournal) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := journal.serialize(serialConfig) + return s +} + +func (journal *VJournal) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VJOURNAL") - return b.String() + err := journal.ComponentBase.serializeThis(b, ComponentVJournal, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } func NewJournal(uniqueId string) *VJournal { @@ -652,39 +755,47 @@ func NewJournal(uniqueId string) *VJournal { return e } -func (calendar *Calendar) AddJournal(id string) *VJournal { +func (cal *Calendar) AddJournal(id string) *VJournal { e := NewJournal(id) - calendar.Components = append(calendar.Components, e) + cal.Components = append(cal.Components, e) return e } -func (calendar *Calendar) AddVJournal(e *VJournal) { - calendar.Components = append(calendar.Components, e) +func (cal *Calendar) AddVJournal(e *VJournal) { + cal.Components = append(cal.Components, e) } -func (calendar *Calendar) Journals() (r []*VJournal) { - r = []*VJournal{} - for i := range calendar.Components { - switch journal := calendar.Components[i].(type) { +func (cal *Calendar) Journals() []*VJournal { + var r []*VJournal + for i := range cal.Components { + switch journal := cal.Components[i].(type) { case *VJournal: r = append(r, journal) } } - return + return r } type VBusy struct { ComponentBase } -func (c *VBusy) Serialize() string { +func (busy *VBusy) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := busy.serialize(serialConfig) + return s +} + +func (busy *VBusy) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VFREEBUSY") - return b.String() + err := busy.ComponentBase.serializeThis(b, ComponentVFreeBusy, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VBusy) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VFREEBUSY") +func (busy *VBusy) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return busy.ComponentBase.serializeThis(w, ComponentVFreeBusy, serialConfig) } func NewBusy(uniqueId string) *VBusy { @@ -694,39 +805,53 @@ func NewBusy(uniqueId string) *VBusy { return e } -func (calendar *Calendar) AddBusy(id string) *VBusy { +func (cal *Calendar) AddBusy(id string) *VBusy { e := NewBusy(id) - calendar.Components = append(calendar.Components, e) + cal.Components = append(cal.Components, e) return e } -func (calendar *Calendar) AddVBusy(e *VBusy) { - calendar.Components = append(calendar.Components, e) +func (cal *Calendar) AddVBusy(e *VBusy) { + cal.Components = append(cal.Components, e) } -func (calendar *Calendar) Busys() (r []*VBusy) { - r = []*VBusy{} - for i := range calendar.Components { - switch busy := calendar.Components[i].(type) { +func (cal *Calendar) Busys() []*VBusy { + var r []*VBusy + for i := range cal.Components { + switch busy := cal.Components[i].(type) { case *VBusy: r = append(r, busy) } } - return + return r } type VTimezone struct { ComponentBase } -func (c *VTimezone) Serialize() string { +func (timezone *VTimezone) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := timezone.serialize(serialConfig) + return s +} + +func (timezone *VTimezone) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VTIMEZONE") - return b.String() + err := timezone.ComponentBase.serializeThis(b, ComponentVTimezone, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VTimezone) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VTIMEZONE") +func (timezone *VTimezone) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return timezone.ComponentBase.serializeThis(w, ComponentVTimezone, serialConfig) +} + +func (timezone *VTimezone) AddStandard() *Standard { + e := NewStandard() + timezone.Components = append(timezone.Components, e) + return e } func NewTimezone(tzId string) *VTimezone { @@ -740,95 +865,127 @@ func NewTimezone(tzId string) *VTimezone { return e } -func (calendar *Calendar) AddTimezone(id string) *VTimezone { +func (cal *Calendar) AddTimezone(id string) *VTimezone { e := NewTimezone(id) - calendar.Components = append(calendar.Components, e) + cal.Components = append(cal.Components, e) return e } -func (calendar *Calendar) AddVTimezone(e *VTimezone) { - calendar.Components = append(calendar.Components, e) +func (cal *Calendar) AddVTimezone(e *VTimezone) { + cal.Components = append(cal.Components, e) } -func (calendar *Calendar) Timezones() (r []*VTimezone) { - r = []*VTimezone{} - for i := range calendar.Components { - switch timezone := calendar.Components[i].(type) { +func (cal *Calendar) Timezones() []*VTimezone { + var r []*VTimezone + for i := range cal.Components { + switch timezone := cal.Components[i].(type) { case *VTimezone: r = append(r, timezone) } } - return + return r } type VAlarm struct { ComponentBase } -func (c *VAlarm) Serialize() string { +func (c *VAlarm) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := c.serialize(serialConfig) + return s +} + +func (c *VAlarm) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "VALARM") - return b.String() + err := c.ComponentBase.serializeThis(b, ComponentVAlarm, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *VAlarm) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "VALARM") +func (c *VAlarm) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return c.ComponentBase.serializeThis(w, ComponentVAlarm, serialConfig) } func NewAlarm(tzId string) *VAlarm { + // Todo How did this come about? e := &VAlarm{} return e } -func (calendar *Calendar) AddVAlarm(e *VAlarm) { - calendar.Components = append(calendar.Components, e) +func (cal *Calendar) AddVAlarm(e *VAlarm) { + cal.Components = append(cal.Components, e) } -func (calendar *Calendar) Alarms() (r []*VAlarm) { - r = []*VAlarm{} - for i := range calendar.Components { - switch alarm := calendar.Components[i].(type) { +func (cal *Calendar) Alarms() []*VAlarm { + var r []*VAlarm + for i := range cal.Components { + switch alarm := cal.Components[i].(type) { case *VAlarm: r = append(r, alarm) } } - return + return r } -func (alarm *VAlarm) SetAction(a Action, props ...PropertyParameter) { - alarm.SetProperty(ComponentPropertyAction, string(a), props...) +func (c *VAlarm) SetAction(a Action, params ...PropertyParameter) { + c.SetProperty(ComponentPropertyAction, string(a), params...) } -func (alarm *VAlarm) SetTrigger(s string, props ...PropertyParameter) { - alarm.SetProperty(ComponentPropertyTrigger, s, props...) +func (c *VAlarm) SetTrigger(s string, params ...PropertyParameter) { + c.SetProperty(ComponentPropertyTrigger, s, params...) } type Standard struct { ComponentBase } -func (c *Standard) Serialize() string { +func NewStandard() *Standard { + e := &Standard{ + ComponentBase{}, + } + return e +} + +func (standard *Standard) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := standard.serialize(serialConfig) + return s +} + +func (standard *Standard) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "STANDARD") - return b.String() + err := standard.ComponentBase.serializeThis(b, ComponentStandard, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *Standard) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "STANDARD") +func (standard *Standard) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return standard.ComponentBase.serializeThis(w, ComponentStandard, serialConfig) } type Daylight struct { ComponentBase } -func (c *Daylight) Serialize() string { +func (daylight *Daylight) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := daylight.serialize(serialConfig) + return s +} + +func (daylight *Daylight) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, "DAYLIGHT") - return b.String() + err := daylight.ComponentBase.serializeThis(b, ComponentDaylight, serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *Daylight) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, "DAYLIGHT") +func (daylight *Daylight) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return daylight.ComponentBase.serializeThis(w, ComponentDaylight, serialConfig) } type GeneralComponent struct { @@ -836,141 +993,195 @@ type GeneralComponent struct { Token string } -func (c *GeneralComponent) Serialize() string { +func (general *GeneralComponent) Serialize(serialConfig *SerializationConfiguration) string { + s, _ := general.serialize(serialConfig) + return s +} + +func (general *GeneralComponent) serialize(serialConfig *SerializationConfiguration) (string, error) { b := &bytes.Buffer{} - c.ComponentBase.serializeThis(b, c.Token) - return b.String() + err := general.ComponentBase.serializeThis(b, ComponentType(general.Token), serialConfig) + if err != nil { + return "", err + } + return b.String(), nil } -func (c *GeneralComponent) SerializeTo(w io.Writer) { - c.ComponentBase.serializeThis(w, c.Token) +func (general *GeneralComponent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error { + return general.ComponentBase.serializeThis(w, ComponentType(general.Token), serialConfig) } func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Component, error) { var co Component - switch startLine.Value { - case "VCALENDAR": + var err error + switch ComponentType(startLine.Value) { + case ComponentVCalendar: return nil, ErrMalformedCalendarVCalendarNotWhereExpected - case "VEVENT": - co = ParseVEvent(cs, startLine) - case "VTODO": - co = ParseVTodo(cs, startLine) - case "VJOURNAL": - co = ParseVJournal(cs, startLine) - case "VFREEBUSY": - co = ParseVBusy(cs, startLine) - case "VTIMEZONE": - co = ParseVTimezone(cs, startLine) - case "VALARM": - co = ParseVAlarm(cs, startLine) - case "STANDARD": - co = ParseStandard(cs, startLine) - case "DAYLIGHT": - co = ParseDaylight(cs, startLine) + case ComponentVEvent: + co, err = ParseVEventWithError(cs, startLine) + case ComponentVTodo: + co, err = ParseVTodoWithError(cs, startLine) + case ComponentVJournal: + co, err = ParseVJournalWithError(cs, startLine) + case ComponentVFreeBusy: + co, err = ParseVBusyWithError(cs, startLine) + case ComponentVTimezone: + co, err = ParseVTimezoneWithError(cs, startLine) + case ComponentVAlarm: + co, err = ParseVAlarmWithError(cs, startLine) + case ComponentStandard: + co, err = ParseStandardWithError(cs, startLine) + case ComponentDaylight: + co, err = ParseDaylightWithError(cs, startLine) default: - co = ParseGeneralComponent(cs, startLine) + co, err = ParseGeneralComponentWithError(cs, startLine) } - return co, nil + return co, err } func ParseVEvent(cs *CalendarStream, startLine *BaseProperty) *VEvent { + ev, _ := ParseVEventWithError(cs, startLine) + return ev +} + +func ParseVEventWithError(cs *CalendarStream, startLine *BaseProperty) (*VEvent, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, fmt.Errorf("failed to parse event: %w", err) } rr := &VEvent{ ComponentBase: r, } - return rr + return rr, nil } func ParseVTodo(cs *CalendarStream, startLine *BaseProperty) *VTodo { + c, _ := ParseVTodoWithError(cs, startLine) + return c +} + +func ParseVTodoWithError(cs *CalendarStream, startLine *BaseProperty) (*VTodo, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VTodo{ ComponentBase: r, } - return rr + return rr, nil } func ParseVJournal(cs *CalendarStream, startLine *BaseProperty) *VJournal { + c, _ := ParseVJournalWithError(cs, startLine) + return c +} + +func ParseVJournalWithError(cs *CalendarStream, startLine *BaseProperty) (*VJournal, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VJournal{ ComponentBase: r, } - return rr + return rr, nil } func ParseVBusy(cs *CalendarStream, startLine *BaseProperty) *VBusy { + c, _ := ParseVBusyWithError(cs, startLine) + return c +} + +func ParseVBusyWithError(cs *CalendarStream, startLine *BaseProperty) (*VBusy, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VBusy{ ComponentBase: r, } - return rr + return rr, nil } func ParseVTimezone(cs *CalendarStream, startLine *BaseProperty) *VTimezone { + c, _ := ParseVTimezoneWithError(cs, startLine) + return c +} + +func ParseVTimezoneWithError(cs *CalendarStream, startLine *BaseProperty) (*VTimezone, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VTimezone{ ComponentBase: r, } - return rr + return rr, nil } func ParseVAlarm(cs *CalendarStream, startLine *BaseProperty) *VAlarm { + c, _ := ParseVAlarmWithError(cs, startLine) + return c +} + +func ParseVAlarmWithError(cs *CalendarStream, startLine *BaseProperty) (*VAlarm, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &VAlarm{ ComponentBase: r, } - return rr + return rr, nil } func ParseStandard(cs *CalendarStream, startLine *BaseProperty) *Standard { + c, _ := ParseStandardWithError(cs, startLine) + return c +} + +func ParseStandardWithError(cs *CalendarStream, startLine *BaseProperty) (*Standard, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &Standard{ ComponentBase: r, } - return rr + return rr, nil } func ParseDaylight(cs *CalendarStream, startLine *BaseProperty) *Daylight { + c, _ := ParseDaylightWithError(cs, startLine) + return c +} + +func ParseDaylightWithError(cs *CalendarStream, startLine *BaseProperty) (*Daylight, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &Daylight{ ComponentBase: r, } - return rr + return rr, nil } func ParseGeneralComponent(cs *CalendarStream, startLine *BaseProperty) *GeneralComponent { + c, _ := ParseGeneralComponentWithError(cs, startLine) + return c +} + +func ParseGeneralComponentWithError(cs *CalendarStream, startLine *BaseProperty) (*GeneralComponent, error) { r, err := ParseComponent(cs, startLine) if err != nil { - return nil + return nil, err } rr := &GeneralComponent{ ComponentBase: r, Token: startLine.Value, } - return rr + return rr, nil } func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, error) { diff --git a/components_test.go b/components_test.go index ffa7c44..faedc40 100644 --- a/components_test.go +++ b/components_test.go @@ -52,7 +52,7 @@ END:VEVENT err := e.SetDuration(duration) // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks - text := strings.Replace(e.Serialize(), "\r\n", "\n", -1) + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) assert.Equal(t, nil, err) @@ -64,16 +64,36 @@ func TestSetAllDay(t *testing.T) { date, _ := time.Parse(time.RFC822, time.RFC822) testCases := []struct { - name string - start time.Time - end time.Time - output string + name string + start time.Time + end time.Time + duration time.Duration + output string }{ { - name: "test set duration - start", + name: "test set all day - start", start: date, output: `BEGIN:VEVENT -UID:test-duration +UID:test-allday +DTSTART;VALUE=DATE:20060102 +END:VEVENT +`, + }, + { + name: "test set all day - end", + end: date, + output: `BEGIN:VEVENT +UID:test-allday +DTEND;VALUE=DATE:20060102 +END:VEVENT +`, + }, + { + name: "test set all day - duration", + start: date, + duration: time.Hour * 24, + output: `BEGIN:VEVENT +UID:test-allday DTSTART;VALUE=DATE:20060102 DTEND;VALUE=DATE:20060103 END:VEVENT @@ -83,12 +103,19 @@ END:VEVENT for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - e := NewEvent("test-duration") - e.SetAllDayStartAt(date) - e.SetAllDayEndAt(date.AddDate(0, 0, 1)) + e := NewEvent("test-allday") + if !tc.start.IsZero() { + e.SetAllDayStartAt(tc.start) + } + if !tc.end.IsZero() { + e.SetAllDayEndAt(tc.end) + } + if tc.duration != 0 { + err := e.SetDuration(tc.duration) + assert.NoError(t, err) + } - // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks - text := strings.Replace(e.Serialize(), "\r\n", "\n", -1) + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) }) @@ -113,22 +140,22 @@ func TestSetMailtoPrefix(t *testing.T) { e := NewEvent("test-set-organizer") e.SetOrganizer("org1@provider.com") - if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org1@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org1@provider.com") { t.Errorf("expected single mailto: prefix for email org1") } e.SetOrganizer("mailto:org2@provider.com") - if !strings.Contains(e.Serialize(), "ORGANIZER:mailto:org2@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org2@provider.com") { t.Errorf("expected single mailto: prefix for email org2") } e.AddAttendee("att1@provider.com") - if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att1@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att1@provider.com") { t.Errorf("expected single mailto: prefix for email att1") } e.AddAttendee("mailto:att2@provider.com") - if !strings.Contains(e.Serialize(), "ATTENDEE:mailto:att2@provider.com") { + if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att2@provider.com") { t.Errorf("expected single mailto: prefix for email att2") } } @@ -157,7 +184,7 @@ END:VTODO e.RemoveProperty("X-TESTREMOVE") // adjust to expected linebreaks, since we're not testing the encoding - text := strings.Replace(e.Serialize(), "\r\n", "\n", -1) + text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n") assert.Equal(t, tc.output, text) }) diff --git a/errors.go b/errors.go index 2c96581..be34efd 100644 --- a/errors.go +++ b/errors.go @@ -23,6 +23,7 @@ var ( ErrStartOrEndNotYetDefined = errors.New("start or end not yet defined") // ErrPropertyNotFound is the error returned if the requested valid + // ErrorPropertyNotFound is the error returned if the requested valid // property is not set. ErrPropertyNotFound = errors.New("property not found") ErrExpectedOneTZID = errors.New("expected one TZID") @@ -45,4 +46,5 @@ var ( ErrUnbalancedEnd = errors.New("unbalanced end") ErrOutOfLines = errors.New("ran out of lines") + ErrorPropertyNotFound = errors.New("property not found") ) diff --git a/os.go b/os.go new file mode 100644 index 0000000..1cebf9a --- /dev/null +++ b/os.go @@ -0,0 +1,6 @@ +package ics + +const ( + WithNewLineUnix WithNewLine = "\n" + WithNewLineWindows WithNewLine = "\r\n" +) diff --git a/os_unix.go b/os_unix.go new file mode 100644 index 0000000..fe1bc84 --- /dev/null +++ b/os_unix.go @@ -0,0 +1,5 @@ +package ics + +const ( + NewLine = WithNewLineUnix +) diff --git a/os_windows.go b/os_windows.go new file mode 100644 index 0000000..81ebfce --- /dev/null +++ b/os_windows.go @@ -0,0 +1,5 @@ +package ics + +const ( + NewLineString = WithNewLineWindows +) diff --git a/property.go b/property.go index 1331dff..ed80ee8 100644 --- a/property.go +++ b/property.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "log" + "net/url" "regexp" "sort" "strconv" @@ -27,7 +28,7 @@ type KeyValues struct { Value []string } -func (kv *KeyValues) KeyValue(s ...interface{}) (string, []string) { +func (kv *KeyValues) KeyValue(_ ...interface{}) (string, []string) { return kv.Key, kv.Value } @@ -38,6 +39,21 @@ func WithCN(cn string) PropertyParameter { } } +func WithTZID(tzid string) PropertyParameter { + return &KeyValues{ + Key: string(ParameterTzid), + Value: []string{tzid}, + } +} + +// WithAlternativeRepresentation takes what must be a valid URI in quotation marks +func WithAlternativeRepresentation(uri *url.URL) PropertyParameter { + return &KeyValues{ + Key: string(ParameterAltrep), + Value: []string{uri.String()}, + } +} + func WithEncoding(encType string) PropertyParameter { return &KeyValues{ Key: string(ParameterEncoding), @@ -68,34 +84,48 @@ func WithRSVP(b bool) PropertyParameter { func trimUT8StringUpTo(maxLength int, s string) string { length := 0 - lastSpace := -1 + lastWordBoundary := -1 + var lastRune rune for i, r := range s { - if r == ' ' { - lastSpace = i + if r == ' ' || r == '<' { + lastWordBoundary = i + } else if lastRune == '>' { + lastWordBoundary = i } - + lastRune = r newLength := length + utf8.RuneLen(r) if newLength > maxLength { break } length = newLength } - if lastSpace > 0 { - return s[:lastSpace] + if lastWordBoundary > 0 { + return s[:lastWordBoundary] } return s[:length] } -func (p *BaseProperty) GetValueType() ValueDataType { - for k, v := range p.ICalParameters { +func (bp *BaseProperty) parameterValue(param Parameter) (string, error) { + v, ok := bp.ICalParameters[string(param)] + if !ok || len(v) == 0 { + return "", fmt.Errorf("parameter %q not found in property", param) + } + if len(v) != 1 { + return "", fmt.Errorf("expected only one value for parameter %q in property, found %d", param, len(v)) + } + return v[0], nil +} + +func (bp *BaseProperty) GetValueType() ValueDataType { + for k, v := range bp.ICalParameters { if Parameter(k) == ParameterValue && len(v) == 1 { return ValueDataType(v[0]) } } // defaults from spec if unspecified - switch Property(p.IANAToken) { + switch Property(bp.IANAToken) { default: fallthrough case PropertyCalscale, PropertyMethod, PropertyProductId, PropertyVersion, PropertyCategories, PropertyClass, @@ -134,54 +164,98 @@ func (p *BaseProperty) GetValueType() ValueDataType { } } -func (property *BaseProperty) serialize(w io.Writer) { +func (bp *BaseProperty) serialize(w io.Writer, serialConfig *SerializationConfiguration) error { b := bytes.NewBufferString("") - fmt.Fprint(b, property.IANAToken) + _, _ = fmt.Fprint(b, bp.IANAToken) var keys []string - for k := range property.ICalParameters { + for k := range bp.ICalParameters { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { - vs := property.ICalParameters[k] - fmt.Fprint(b, ";") - fmt.Fprint(b, k) - fmt.Fprint(b, "=") + vs := bp.ICalParameters[k] + _, _ = fmt.Fprint(b, ";") + _, _ = fmt.Fprint(b, k) + _, _ = fmt.Fprint(b, "=") for vi, v := range vs { if vi > 0 { - fmt.Fprint(b, ",") + _, _ = fmt.Fprint(b, ",") } - if strings.ContainsAny(v, ";:\\\",") { - v = strings.Replace(v, "\\", "\\\\", -1) - v = strings.Replace(v, ";", "\\;", -1) - v = strings.Replace(v, ":", "\\:", -1) - v = strings.Replace(v, "\"", "\\\"", -1) - v = strings.Replace(v, ",", "\\,", -1) + if Parameter(k).IsQuoted() { + v = quotedValueString(v) + _, _ = fmt.Fprint(b, v) + } else { + v = escapeValueString(v) + _, _ = fmt.Fprint(b, v) } - fmt.Fprint(b, v) } } - fmt.Fprint(b, ":") - propertyValue := property.Value - if property.GetValueType() == ValueDataTypeText { + _, _ = fmt.Fprint(b, ":") + propertyValue := bp.Value + if bp.GetValueType() == ValueDataTypeText { propertyValue = ToText(propertyValue) } - fmt.Fprint(b, propertyValue) + _, _ = fmt.Fprint(b, propertyValue) r := b.String() - if len(r) > 75 { - l := trimUT8StringUpTo(75, r) - fmt.Fprint(w, l, "\r\n") + if len(r) > serialConfig.MaxLength { + l := trimUT8StringUpTo(serialConfig.MaxLength, r) + _, err := fmt.Fprint(w, l, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } r = r[len(l):] - for len(r) > 74 { - l := trimUT8StringUpTo(74, r) - fmt.Fprint(w, " ", l, "\r\n") + for len(r) > serialConfig.MaxLength-1 { + l := trimUT8StringUpTo(serialConfig.MaxLength-1, r) + _, err = fmt.Fprint(w, " ", l, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } r = r[len(l):] } - fmt.Fprint(w, " ") + _, err = fmt.Fprint(w, " ") + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } + } + _, err := fmt.Fprint(w, r, serialConfig.NewLine) + if err != nil { + return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err) + } + return nil +} + +func escapeValueString(v string) string { + changed := 0 + result := "" + for i, r := range v { + switch r { + case ',', '"', ';', ':', '\\', '\'': + result = result + v[changed:i] + "\\" + string(r) + changed = i + 1 + } + } + if changed == 0 { + return v + } + return result + v[changed:] +} + +func quotedValueString(v string) string { + changed := 0 + result := "" + for i, r := range v { + switch r { + case '"', '\\': + result = result + v[changed:i] + "\\" + string(r) + changed = i + 1 + } + } + if changed == 0 { + return `"` + v + `"` } - fmt.Fprint(w, r, "\r\n") + return `"` + result + v[changed:] + `"` } type IANAProperty struct { diff --git a/property_test.go b/property_test.go index 84e2d04..5610c34 100644 --- a/property_test.go +++ b/property_test.go @@ -185,3 +185,71 @@ func Test_parsePropertyParamValue(t *testing.T) { }) } } + +func Test_trimUT8StringUpTo(t *testing.T) { + tests := []struct { + name string + maxLength int + s string + want string + }{ + { + name: "simply break at spaces", + s: "simply break at spaces", + maxLength: 14, + want: "simply break", + }, + { + name: "(Don't) Break after punctuation 1", // See if we can change this. + s: "hi.are.", + maxLength: len("hi.are"), + want: "hi.are", + }, + { + name: "Break after punctuation 2", + s: "Hi how are you?", + maxLength: len("Hi how are you"), + want: "Hi how are", + }, + { + name: "HTML opening tag breaking", + s: "I want a custom linkout for Thunderbird.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", + maxLength: len("I want a custom linkout for Thunderbird.<br>This is the Github<"), + want: "I want a custom linkout for Thunderbird.<br>This is the Github", + }, + { + name: "HTML closing tag breaking", + s: "I want a custom linkout for Thunderbird.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", + maxLength: len("I want a custom linkout for Thunderbird.<br>") + 1, + want: "I want a custom linkout for Thunderbird.<br>", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, trimUT8StringUpTo(tt.maxLength, tt.s), "trimUT8StringUpTo(%v, %v)", tt.maxLength, tt.s) + }) + } +} + +func TestFixValueStrings(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"hello;world", "hello\\;world"}, + {"path\\to:file", "path\\\\to\\:file"}, + {"name:\"value\"", "name\\:\\\"value\\\""}, + {"key,value", "key\\,value"}, + {";:\\\",", "\\;\\:\\\\\\\"\\,"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeValueString(tt.input) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/testdata/issue97/google.ics b/testdata/issue97/google.ics new file mode 100644 index 0000000..9408b3a --- /dev/null +++ b/testdata/issue97/google.ics @@ -0,0 +1,23 @@ +BEGIN:VCALENDAR +PRODID:-//Google Inc//Google Calendar 70.9054//EN +VERSION:2.0 +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:Test +X-WR-TIMEZONE:Europe/Berlin +BEGIN:VEVENT +DTSTART:20240929T124500Z +DTEND:20240929T134500Z +DTSTAMP:20240929T121653Z +UID:al23c5kr943d42u3bqoqrkf455@google.com +CREATED:20240929T121642Z +DESCRIPTION:I want a custom linkout for Thunderbird.<br>This is the Github + <a href="https://github.com/arran4/golang-ical/issues/97">Issue</a>. +LAST-MODIFIED:20240929T121642Z +LOCATION:GitHub +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Test Event +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/testdata/issue97/thunderbird.ics_disabled b/testdata/issue97/thunderbird.ics_disabled new file mode 100644 index 0000000..88fd37e --- /dev/null +++ b/testdata/issue97/thunderbird.ics_disabled @@ -0,0 +1,37 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-TZINFO:Europe/Berlin[2024a] +BEGIN:STANDARD +TZOFFSETTO:+010000 +TZOFFSETFROM:+005328 +TZNAME:Europe/Berlin(STD) +DTSTART:18930401T000000 +RDATE:18930401T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20240929T120640Z +LAST-MODIFIED:20240929T120731Z +DTSTAMP:20240929T120731Z +UID:d23cef0d-9e58-43c4-9391-5ad8483ca346 +SUMMARY:Test Event +DTSTART;TZID=Europe/Berlin:20240929T144500 +DTEND;TZID=Europe/Berlin:20240929T154500 +TRANSP:OPAQUE +LOCATION:Github +DESCRIPTION;ALTREP="data:text/html,I%20want%20 + a%20custom%20linkout%20for%20 + Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20 + %3Ca%20href%3D%22https%3A%2F + %2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22 + %3EIssue%3C%2Fa%3E.":I + want a custom linkout for Thunderbird.\nThis is the Github Issue. +END:VEVENT +END:VCALENDAR + + + +Disabled due to wordwrapping differences