├── .build.yml ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.md ├── .gitignore ├── LICENSE ├── README.md ├── components.go ├── components_test.go ├── decoder.go ├── decoder_test.go ├── encoder.go ├── encoder_test.go ├── enums.go ├── example_test.go ├── go.mod ├── go.sum ├── ical.go └── ical_test.go /.build.yml: -------------------------------------------------------------------------------- 1 | image: alpine/edge 2 | packages: 3 | - go 4 | sources: 5 | - https://github.com/emersion/go-ical 6 | artifacts: 7 | - coverage.html 8 | tasks: 9 | - build: | 10 | cd go-ical 11 | go build -race -v ./... 12 | - test: | 13 | cd go-ical 14 | go test -race -cover -coverprofile=coverage.txt -v ./... 15 | - coverage: | 16 | cd go-ical 17 | go tool cover -html=coverage.txt -o ~/coverage.html 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question 4 | url: "https://web.libera.chat/gamja/#emersion" 5 | about: "Please ask questions in #emersion on Libera Chat" 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report or feature request 3 | about: Report a bug or request a new feature 4 | --- 5 | 6 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Simon Ser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-ical 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/emersion/go-ical.svg)](https://pkg.go.dev/github.com/emersion/go-ical) 4 | 5 | An [iCalendar] library for Go. 6 | 7 | ## License 8 | 9 | MIT 10 | 11 | [iCalendar]: https://tools.ietf.org/html/rfc5545 12 | -------------------------------------------------------------------------------- /components.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/teambition/rrule-go" 9 | ) 10 | 11 | // Calendar is the top-level iCalendar object. 12 | type Calendar struct { 13 | *Component 14 | } 15 | 16 | // RecurrenceSet returns the Recurrence Set for this component. 17 | func (comp *Component) RecurrenceSet(loc *time.Location) (*rrule.Set, error) { 18 | roption, err := comp.Props.RecurrenceRule() 19 | if err != nil { 20 | return nil, fmt.Errorf("ical: error parsing recurrence: %v", err) 21 | } 22 | if roption == nil { 23 | return nil, nil 24 | } 25 | dateTime, err := comp.Props.DateTime(PropDateTimeStart, loc) 26 | if err != nil { 27 | return nil, fmt.Errorf("ical: error parsing start time: %v", err) 28 | } 29 | 30 | rule, err := rrule.NewRRule(*roption) 31 | if err != nil { 32 | return nil, fmt.Errorf("ical: error buildling rrule: %v", err) 33 | } 34 | 35 | ruleSet := rrule.Set{} 36 | ruleSet.RRule(rule) 37 | ruleSet.DTStart(dateTime) 38 | 39 | for _, exdateProp := range comp.Props[PropExceptionDates] { 40 | exdate, err := exdateProp.DateTime(loc) 41 | if err != nil { 42 | return nil, fmt.Errorf("ical: error parsing exdate: %v", err) 43 | } 44 | ruleSet.ExDate(exdate) 45 | } 46 | for _, rdateProp := range comp.Props[PropExceptionDates] { 47 | rdate, err := rdateProp.DateTime(loc) 48 | if err != nil { 49 | return nil, fmt.Errorf("ical: error parsing rdate: %v", err) 50 | } 51 | ruleSet.RDate(rdate) 52 | } 53 | 54 | return &ruleSet, nil 55 | } 56 | 57 | // NewCalendar creates a new calendar object. 58 | func NewCalendar() *Calendar { 59 | return &Calendar{NewComponent(CompCalendar)} 60 | } 61 | 62 | // Events extracts the list of events contained in the calendar. 63 | func (cal *Calendar) Events() []Event { 64 | l := make([]Event, 0, len(cal.Children)) 65 | for _, child := range cal.Children { 66 | if child.Name == CompEvent { 67 | l = append(l, Event{child}) 68 | } 69 | } 70 | return l 71 | } 72 | 73 | // Event represents a scheduled amount of time on a calendar. 74 | type Event struct { 75 | *Component 76 | } 77 | 78 | // NewEvent creates a new event. 79 | func NewEvent() *Event { 80 | return &Event{NewComponent(CompEvent)} 81 | } 82 | 83 | // DateTimeStart returns the inclusive start of the event. 84 | func (e *Event) DateTimeStart(loc *time.Location) (time.Time, error) { 85 | return e.Props.DateTime(PropDateTimeStart, loc) 86 | } 87 | 88 | // DateTimeEnd returns the non-inclusive end of the event. 89 | func (e *Event) DateTimeEnd(loc *time.Location) (time.Time, error) { 90 | if prop := e.Props.Get(PropDateTimeEnd); prop != nil { 91 | return prop.DateTime(loc) 92 | } 93 | 94 | startProp := e.Props.Get(PropDateTimeStart) 95 | if startProp == nil { 96 | return time.Time{}, nil 97 | } 98 | 99 | start, err := startProp.DateTime(loc) 100 | if err != nil { 101 | return time.Time{}, err 102 | } 103 | 104 | var dur time.Duration 105 | if durProp := e.Props.Get(PropDuration); durProp != nil { 106 | dur, err = durProp.Duration() 107 | if err != nil { 108 | return time.Time{}, err 109 | } 110 | } else if startProp.ValueType() == ValueDate { 111 | dur = 24 * time.Hour 112 | } 113 | 114 | return start.Add(dur), nil 115 | } 116 | 117 | func (e *Event) Status() (EventStatus, error) { 118 | s, err := e.Props.Text(PropStatus) 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | switch status := EventStatus(strings.ToUpper(s)); status { 124 | case "", EventTentative, EventConfirmed, EventCancelled: 125 | return status, nil 126 | default: 127 | return "", fmt.Errorf("ical: invalid VEVENT STATUS: %q", status) 128 | } 129 | } 130 | 131 | func (e *Event) SetStatus(status EventStatus) { 132 | if status == "" { 133 | e.Props.Del(PropStatus) 134 | } else { 135 | e.Props.SetText(PropStatus, string(status)) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /components_test.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | 8 | "github.com/teambition/rrule-go" 9 | ) 10 | 11 | func TestRecurrenceSet(t *testing.T) { 12 | events := exampleCalendar.Events() 13 | if len(events) != 1 { 14 | t.Fatalf("len(Calendar.Events()) = %v, want 1", len(events)) 15 | } 16 | event := events[0] 17 | 18 | wantRecurrenceSet := &rrule.Set{} 19 | rrule, err := rrule.NewRRule(rrule.ROption{ 20 | Freq: rrule.YEARLY, 21 | Bymonth: []int{3}, 22 | Byweekday: []rrule.Weekday{rrule.SU.Nth(3)}, 23 | }) 24 | if err != nil { 25 | t.Errorf("Could not build rrule: %v", err) // Should never really happen. 26 | } 27 | wantRecurrenceSet.DTStart(time.Date(1996, 9, 18, 14, 30, 0, 0, time.UTC)) 28 | wantRecurrenceSet.RRule(rrule) 29 | 30 | if gotRecurrenceSet, err := event.RecurrenceSet(nil); err != nil { 31 | t.Errorf("Props.RecurrenceSet() = %v", err) 32 | } else if !reflect.DeepEqual(gotRecurrenceSet, wantRecurrenceSet) { 33 | t.Errorf("Props.RecurrenceSet() = %v, want %v", gotRecurrenceSet, wantRecurrenceSet) 34 | } 35 | } 36 | 37 | func TestRecurrenceSetIsAbsent(t *testing.T) { 38 | event := Component{} 39 | gotRecurrenceSet, err := event.RecurrenceSet(nil) 40 | 41 | if gotRecurrenceSet != nil || err != nil { 42 | t.Errorf("Component.RecurrenceSet() = %v, %v, want nil, nil", gotRecurrenceSet, err) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /decoder.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | type lineDecoder struct { 11 | s string 12 | } 13 | 14 | func (ld *lineDecoder) decodeName() (string, error) { 15 | i := strings.IndexAny(ld.s, ";:") 16 | if i < 0 { 17 | return "", fmt.Errorf("ical: malformed content line: missing colon") 18 | } else if i == 0 { 19 | return "", fmt.Errorf("ical: malformed content line: empty property name") 20 | } 21 | 22 | name := strings.ToUpper(ld.s[:i]) 23 | ld.s = ld.s[i:] 24 | return name, nil 25 | } 26 | 27 | func (ld *lineDecoder) empty() bool { 28 | return len(ld.s) == 0 29 | } 30 | 31 | func (ld *lineDecoder) peek() byte { 32 | return ld.s[0] 33 | } 34 | 35 | func (ld *lineDecoder) consume(c byte) bool { 36 | if ld.empty() || ld.peek() != c { 37 | return false 38 | } 39 | ld.s = ld.s[1:] 40 | return true 41 | } 42 | 43 | func (ld *lineDecoder) decodeParamValue() (string, error) { 44 | var v string 45 | if ld.consume('"') { 46 | for !ld.empty() && ld.peek() != '"' { 47 | v += ld.s[:1] 48 | ld.s = ld.s[1:] 49 | } 50 | 51 | if !ld.consume('"') { 52 | return "", fmt.Errorf("ical: malformed param value: unterminated quoted string") 53 | } 54 | } else { 55 | Loop: 56 | for !ld.empty() { 57 | switch c := ld.peek(); c { 58 | case '"': 59 | return "", fmt.Errorf("ical: malformed param value: illegal double-quote") 60 | case ';', ',', ':': 61 | break Loop 62 | default: 63 | v += ld.s[:1] 64 | ld.s = ld.s[1:] 65 | } 66 | } 67 | } 68 | 69 | return v, nil 70 | } 71 | 72 | func (ld *lineDecoder) decodeParam() (string, []string, error) { 73 | i := strings.IndexByte(ld.s, '=') 74 | if i < 0 { 75 | return "", nil, fmt.Errorf("ical: malformed param: missing equal sign") 76 | } else if i == 0 { 77 | return "", nil, fmt.Errorf("ical: malformed param: empty param name") 78 | } 79 | 80 | name := strings.ToUpper(ld.s[:i]) 81 | ld.s = ld.s[i+1:] 82 | 83 | var values []string 84 | Loop: 85 | for { 86 | value, err := ld.decodeParamValue() 87 | if err != nil { 88 | return "", nil, err 89 | } 90 | values = append(values, value) 91 | 92 | switch c := ld.peek(); c { 93 | case ',': 94 | ld.s = ld.s[1:] 95 | case ';', ':': 96 | break Loop 97 | default: 98 | panic(fmt.Errorf("ical: unexpected character %q after decoding param value", c)) 99 | } 100 | } 101 | 102 | return name, values, nil 103 | } 104 | 105 | func (ld *lineDecoder) decodeContentLine() (*Prop, error) { 106 | name, err := ld.decodeName() 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | params := make(map[string][]string) 112 | for ld.consume(';') { 113 | paramName, paramValues, err := ld.decodeParam() 114 | if err != nil { 115 | return nil, err 116 | } 117 | params[paramName] = append(params[paramName], paramValues...) 118 | } 119 | 120 | if !ld.consume(':') { 121 | return nil, fmt.Errorf("ical: malformed property: expected colon") 122 | } 123 | 124 | return &Prop{ 125 | Name: name, 126 | Params: params, 127 | Value: ld.s, 128 | }, nil 129 | } 130 | 131 | type Decoder struct { 132 | br *bufio.Reader 133 | } 134 | 135 | func NewDecoder(r io.Reader) *Decoder { 136 | return &Decoder{bufio.NewReader(r)} 137 | } 138 | 139 | func (dec *Decoder) readLine() ([]byte, error) { 140 | var buf []byte 141 | for { 142 | line, isPrefix, err := dec.br.ReadLine() 143 | if err != nil { 144 | return nil, err 145 | } 146 | 147 | if !isPrefix && len(buf) == 0 { 148 | return line, err 149 | } 150 | 151 | buf = append(buf, line...) 152 | if !isPrefix { 153 | break 154 | } 155 | } 156 | return buf, nil 157 | } 158 | 159 | func (dec *Decoder) readContinuedLine() (string, error) { 160 | var sb strings.Builder 161 | 162 | l, err := dec.readLine() 163 | if err != nil { 164 | return "", err 165 | } 166 | sb.Write(l) 167 | 168 | for { 169 | r, _, err := dec.br.ReadRune() 170 | if err == io.EOF { 171 | break 172 | } else if err != nil { 173 | return "", err 174 | } 175 | 176 | if r != ' ' && r != '\t' { 177 | dec.br.UnreadRune() 178 | break 179 | } 180 | 181 | l, err := dec.readLine() 182 | if err != nil { 183 | return "", err 184 | } 185 | sb.Write(l) 186 | } 187 | 188 | return sb.String(), nil 189 | } 190 | 191 | func (dec *Decoder) decodeContentLine() (*Prop, error) { 192 | for { 193 | l, err := dec.readContinuedLine() 194 | if err != nil { 195 | return nil, err 196 | } 197 | if len(l) == 0 { 198 | continue 199 | } 200 | 201 | ld := lineDecoder{l} 202 | return ld.decodeContentLine() 203 | } 204 | } 205 | 206 | func (dec *Decoder) decodeComponentBody(name string) (*Component, error) { 207 | var prop *Prop 208 | props := make(Props) 209 | var children []*Component 210 | Loop: 211 | for { 212 | var err error 213 | prop, err = dec.decodeContentLine() 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | switch prop.Name { 219 | case "BEGIN": 220 | child, err := dec.decodeComponentBody(strings.ToUpper(prop.Value)) 221 | if err != nil { 222 | return nil, err 223 | } 224 | children = append(children, child) 225 | case "END": 226 | break Loop 227 | default: 228 | props[prop.Name] = append(props[prop.Name], *prop) 229 | } 230 | } 231 | 232 | if prop.Name != "END" { 233 | panic("ical: expected END property") 234 | } 235 | if !strings.EqualFold(prop.Value, name) { 236 | return nil, fmt.Errorf("ical: malformed component: expected END property for %q, got %q", name, prop.Value) 237 | } 238 | 239 | return &Component{ 240 | Name: name, 241 | Props: props, 242 | Children: children, 243 | }, nil 244 | } 245 | 246 | func (dec *Decoder) decodeComponent() (*Component, error) { 247 | prop, err := dec.decodeContentLine() 248 | if err != nil { 249 | return nil, err 250 | } 251 | if prop.Name != "BEGIN" { 252 | return nil, fmt.Errorf("ical: malformed component: expected BEGIN property, got %q", prop.Name) 253 | } 254 | 255 | return dec.decodeComponentBody(strings.ToUpper(prop.Value)) 256 | } 257 | 258 | func (dec *Decoder) Decode() (*Calendar, error) { 259 | comp, err := dec.decodeComponent() 260 | if err != nil { 261 | return nil, err 262 | } else if comp.Name != CompCalendar { 263 | return nil, fmt.Errorf("ical: invalid toplevel component name: expected %q, got %q", CompCalendar, comp.Name) 264 | } 265 | 266 | return &Calendar{comp}, nil 267 | } 268 | -------------------------------------------------------------------------------- /decoder_test.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestDecoder(t *testing.T) { 12 | dec := NewDecoder(strings.NewReader(exampleCalendarStr)) 13 | 14 | cal, err := dec.Decode() 15 | if err != nil { 16 | t.Fatalf("DecodeCalendar() = %v", err) 17 | } 18 | if !reflect.DeepEqual(cal, exampleCalendar) { 19 | t.Errorf("DecodeCalendar() = \n%#v\nbut want:\n%#v", cal, exampleCalendar) 20 | } 21 | 22 | if _, err := dec.Decode(); err != io.EOF { 23 | t.Errorf("DecodeCalendar() = %v, want io.EOF", err) 24 | } 25 | } 26 | 27 | func TestDecoderLongLine(t *testing.T) { 28 | template := `BEGIN:VCALENDAR 29 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN 30 | VERSION:2.0 31 | BEGIN:VEVENT 32 | DESCRIPTION:%v 33 | DTSTAMP:19960704T120000Z 34 | DTSTART:19960918T143000Z 35 | UID:uid1@example.com 36 | END:VEVENT 37 | END:VCALENDAR 38 | ` 39 | description := strings.Repeat("Networld+Interop Conference", 500) 40 | calendarStr := toCRLF(fmt.Sprintf(template, description)) 41 | calendar := &Calendar{&Component{ 42 | Name: "VCALENDAR", 43 | Props: Props{ 44 | "PRODID": []Prop{{ 45 | Name: "PRODID", 46 | Params: Params{}, 47 | Value: "-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN", 48 | }}, 49 | "VERSION": []Prop{{ 50 | Name: "VERSION", 51 | Params: Params{}, 52 | Value: "2.0", 53 | }}, 54 | }, 55 | Children: []*Component{ 56 | { 57 | Name: "VEVENT", 58 | Props: Props{ 59 | "DTSTAMP": []Prop{{ 60 | Name: "DTSTAMP", 61 | Params: Params{}, 62 | Value: "19960704T120000Z", 63 | }}, 64 | "UID": []Prop{{ 65 | Name: "UID", 66 | Params: Params{}, 67 | Value: "uid1@example.com", 68 | }}, 69 | "DTSTART": []Prop{{ 70 | Name: "DTSTART", 71 | Params: Params{}, 72 | Value: "19960918T143000Z", 73 | }}, 74 | "DESCRIPTION": []Prop{{ 75 | Name: "DESCRIPTION", 76 | Params: Params{}, 77 | Value: description, 78 | }}, 79 | }, 80 | }, 81 | }, 82 | }} 83 | 84 | dec := NewDecoder(strings.NewReader(calendarStr)) 85 | 86 | cal, err := dec.Decode() 87 | if err != nil { 88 | t.Fatalf("DecodeCalendar() = %v", err) 89 | } 90 | if !reflect.DeepEqual(cal, calendar) { 91 | t.Errorf("DecodeCalendar() = \n%#v\nbut want:\n%#v", cal, calendar) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /encoder.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | func checkComponent(comp *Component) error { 12 | var exactlyOneProps, atMostOneProps []string 13 | switch comp.Name { 14 | case CompCalendar: 15 | if len(comp.Children) == 0 { 16 | return fmt.Errorf("ical: failed to encode VCALENDAR: calendar is empty") 17 | } 18 | 19 | exactlyOneProps = []string{PropProductID, PropVersion} 20 | atMostOneProps = []string{ 21 | PropCalendarScale, 22 | PropMethod, 23 | PropUID, 24 | PropLastModified, 25 | PropURL, 26 | PropRefreshInterval, 27 | PropSource, 28 | PropColor, 29 | } 30 | case CompEvent: 31 | for _, child := range comp.Children { 32 | if child.Name != CompAlarm { 33 | return fmt.Errorf("ical: failed to encode VEVENT: nested %q components are forbidden, only VALARM is allowed", child.Name) 34 | } 35 | } 36 | 37 | exactlyOneProps = []string{PropDateTimeStamp, PropUID} 38 | atMostOneProps = []string{ 39 | PropDateTimeStart, 40 | PropClass, 41 | PropCreated, 42 | PropDescription, 43 | PropGeo, 44 | PropLastModified, 45 | PropLocation, 46 | PropOrganizer, 47 | PropPriority, 48 | PropRecurrenceRule, 49 | PropSequence, 50 | PropStatus, 51 | PropSummary, 52 | PropTransparency, 53 | PropURL, 54 | PropRecurrenceID, 55 | PropDateTimeEnd, 56 | PropDuration, 57 | PropColor, 58 | } 59 | 60 | // TODO: DTSTART is required if VCALENDAR is missing the METHOD prop 61 | if len(comp.Props[PropDateTimeEnd]) > 0 && len(comp.Props[PropDuration]) > 0 { 62 | return fmt.Errorf("ical: failed to encode VEVENT: only one of DTEND and DURATION can be specified") 63 | } 64 | case CompToDo: 65 | for _, child := range comp.Children { 66 | if child.Name != CompAlarm { 67 | return fmt.Errorf("ical: failed to encode VTODO: nested %q components are forbidden, only VALARM is allowed", child.Name) 68 | } 69 | } 70 | 71 | exactlyOneProps = []string{PropDateTimeStamp, PropUID} 72 | atMostOneProps = []string{ 73 | PropClass, 74 | PropCompleted, 75 | PropCreated, 76 | PropDescription, 77 | PropDateTimeStart, 78 | PropGeo, 79 | PropLastModified, 80 | PropLocation, 81 | PropOrganizer, 82 | PropPercentComplete, 83 | PropPriority, 84 | PropRecurrenceID, 85 | PropSequence, 86 | PropStatus, 87 | PropSummary, 88 | PropURL, 89 | PropDue, 90 | PropDuration, 91 | PropColor, 92 | } 93 | 94 | if len(comp.Props[PropDue]) > 0 && len(comp.Props[PropDuration]) > 0 { 95 | return fmt.Errorf("ical: failed to encode VTODO: only one of DUE and DURATION can be specified") 96 | } 97 | if len(comp.Props[PropDuration]) > 0 && len(comp.Props[PropDateTimeStart]) == 0 { 98 | return fmt.Errorf("ical: failed to encode VTODO: DTSTART is required when DURATION is specified") 99 | } 100 | case CompJournal: 101 | exactlyOneProps = []string{PropDateTimeStamp, PropUID} 102 | atMostOneProps = []string{ 103 | PropClass, 104 | PropCreated, 105 | PropDateTimeStart, 106 | PropLastModified, 107 | PropOrganizer, 108 | PropRecurrenceID, 109 | PropSequence, 110 | PropStatus, 111 | PropSummary, 112 | PropURL, 113 | PropColor, 114 | } 115 | 116 | if len(comp.Children) > 0 { 117 | return fmt.Errorf("ical: failed to encode VJOURNAL: nested components are forbidden") 118 | } 119 | case CompFreeBusy: 120 | exactlyOneProps = []string{PropDateTimeStamp, PropUID} 121 | atMostOneProps = []string{ 122 | PropContact, 123 | PropDateTimeStart, 124 | PropDateTimeEnd, 125 | PropOrganizer, 126 | PropURL, 127 | } 128 | 129 | if len(comp.Children) > 0 { 130 | return fmt.Errorf("ical: failed to encode VFREEBUSY: nested components are forbidden") 131 | } 132 | case CompTimezone: 133 | if len(comp.Children) == 0 { 134 | return fmt.Errorf("ical: failed to encode VTIMEZONE: expected one nested STANDARD or DAYLIGHT component") 135 | } 136 | for _, child := range comp.Children { 137 | if child.Name != CompTimezoneStandard && child.Name != CompTimezoneDaylight { 138 | return fmt.Errorf("ical: failed to encode VTIMEZONE: nested %q components are forbidden, only STANDARD and DAYLIGHT are allowed", child.Name) 139 | } 140 | } 141 | 142 | exactlyOneProps = []string{PropTimezoneID} 143 | atMostOneProps = []string{ 144 | PropLastModified, 145 | PropTimezoneURL, 146 | } 147 | case CompTimezoneStandard, CompTimezoneDaylight: 148 | exactlyOneProps = []string{ 149 | PropDateTimeStart, 150 | PropTimezoneOffsetTo, 151 | PropTimezoneOffsetFrom, 152 | } 153 | case CompAlarm: 154 | // TODO 155 | } 156 | 157 | for _, name := range exactlyOneProps { 158 | if n := len(comp.Props[name]); n != 1 { 159 | return fmt.Errorf("ical: failed to encode %q: want exactly one %q property, got %v", comp.Name, name, n) 160 | } 161 | } 162 | for _, name := range atMostOneProps { 163 | if n := len(comp.Props[name]); n > 1 { 164 | return fmt.Errorf("ical: failed to encode %q: want at most one %q property, got %v", comp.Name, name, n) 165 | } 166 | } 167 | 168 | return nil 169 | } 170 | 171 | type Encoder struct { 172 | w io.Writer 173 | } 174 | 175 | func NewEncoder(w io.Writer) *Encoder { 176 | return &Encoder{w} 177 | } 178 | 179 | func (enc *Encoder) encodeProp(prop *Prop) error { 180 | var buf bytes.Buffer 181 | buf.WriteString(prop.Name) 182 | 183 | paramNames := make([]string, 0, len(prop.Params)) 184 | for name := range prop.Params { 185 | paramNames = append(paramNames, name) 186 | } 187 | sort.Strings(paramNames) 188 | 189 | for _, name := range paramNames { 190 | buf.WriteString(";") 191 | buf.WriteString(name) 192 | buf.WriteString("=") 193 | 194 | for i, v := range prop.Params[name] { 195 | if i > 0 { 196 | buf.WriteString(",") 197 | } 198 | if strings.ContainsRune(v, '"') { 199 | return fmt.Errorf("ical: failed to encode param value: contains a double-quote") 200 | } 201 | if strings.ContainsAny(v, ";:,") { 202 | buf.WriteString(`"` + v + `"`) 203 | } else { 204 | buf.WriteString(v) 205 | } 206 | } 207 | } 208 | 209 | buf.WriteString(":") 210 | if strings.ContainsAny(prop.Value, "\r\n") { 211 | return fmt.Errorf("ical: failed to encode property value: contains a CR or LF") 212 | } 213 | buf.WriteString(prop.Value) 214 | buf.WriteString("\r\n") 215 | 216 | _, err := enc.w.Write(buf.Bytes()) 217 | return err 218 | } 219 | 220 | func (enc *Encoder) encodeComponent(comp *Component) error { 221 | if err := checkComponent(comp); err != nil { 222 | return err 223 | } 224 | 225 | err := enc.encodeProp(&Prop{Name: "BEGIN", Value: comp.Name}) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | propNames := make([]string, 0, len(comp.Props)) 231 | for name := range comp.Props { 232 | propNames = append(propNames, name) 233 | } 234 | sort.Strings(propNames) 235 | 236 | for _, name := range propNames { 237 | for _, prop := range comp.Props[name] { 238 | if err := enc.encodeProp(&prop); err != nil { 239 | return err 240 | } 241 | } 242 | } 243 | 244 | for _, child := range comp.Children { 245 | if err := enc.encodeComponent(child); err != nil { 246 | return err 247 | } 248 | } 249 | 250 | return enc.encodeProp(&Prop{Name: "END", Value: comp.Name}) 251 | } 252 | 253 | func (enc *Encoder) Encode(cal *Calendar) error { 254 | return enc.encodeComponent(cal.Component) 255 | } 256 | -------------------------------------------------------------------------------- /encoder_test.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestEncoder(t *testing.T) { 9 | var buf bytes.Buffer 10 | if err := NewEncoder(&buf).Encode(exampleCalendar); err != nil { 11 | t.Fatalf("Encode() = %v", err) 12 | } 13 | s := buf.String() 14 | 15 | if s != exampleCalendarStr { 16 | t.Errorf("Encode() = \n%v\nbut want:\n%v", s, exampleCalendarStr) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /enums.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | // Components as defined in RFC 5545 section 3.6. 4 | const ( 5 | CompCalendar = "VCALENDAR" 6 | CompEvent = "VEVENT" 7 | CompToDo = "VTODO" 8 | CompJournal = "VJOURNAL" 9 | CompFreeBusy = "VFREEBUSY" 10 | CompTimezone = "VTIMEZONE" 11 | CompAlarm = "VALARM" 12 | ) 13 | 14 | // Timezone components. 15 | const ( 16 | CompTimezoneStandard = "STANDARD" 17 | CompTimezoneDaylight = "DAYLIGHT" 18 | ) 19 | 20 | // Properties as defined in RFC 5545 section 3.7, RFC 5545 section 3.8 and 21 | // RFC 7986 section 5. 22 | const ( 23 | // Calendar properties 24 | PropCalendarScale = "CALSCALE" 25 | PropMethod = "METHOD" 26 | PropProductID = "PRODID" 27 | PropVersion = "VERSION" 28 | PropName = "NAME" 29 | PropRefreshInterval = "REFRESH-INTERVAL" 30 | PropSource = "SOURCE" 31 | 32 | // Component properties 33 | PropAttach = "ATTACH" 34 | PropCategories = "CATEGORIES" 35 | PropClass = "CLASS" 36 | PropComment = "COMMENT" 37 | PropDescription = "DESCRIPTION" 38 | PropGeo = "GEO" 39 | PropLocation = "LOCATION" 40 | PropPercentComplete = "PERCENT-COMPLETE" 41 | PropPriority = "PRIORITY" 42 | PropResources = "RESOURCES" 43 | PropStatus = "STATUS" 44 | PropSummary = "SUMMARY" 45 | PropColor = "COLOR" 46 | PropImage = "IMAGE" 47 | 48 | // Date and time component properties 49 | PropCompleted = "COMPLETED" 50 | PropDateTimeEnd = "DTEND" 51 | PropDue = "DUE" 52 | PropDateTimeStart = "DTSTART" 53 | PropDuration = "DURATION" 54 | PropFreeBusy = "FREEBUSY" 55 | PropTransparency = "TRANSP" 56 | 57 | // Timezone component properties 58 | PropTimezoneID = "TZID" 59 | PropTimezoneName = "TZNAME" 60 | PropTimezoneOffsetFrom = "TZOFFSETFROM" 61 | PropTimezoneOffsetTo = "TZOFFSETTO" 62 | PropTimezoneURL = "TZURL" 63 | 64 | // Relationship component properties 65 | PropAttendee = "ATTENDEE" 66 | PropContact = "CONTACT" 67 | PropOrganizer = "ORGANIZER" 68 | PropRecurrenceID = "RECURRENCE-ID" 69 | PropRelatedTo = "RELATED-TO" 70 | PropURL = "URL" 71 | PropUID = "UID" 72 | PropConference = "CONFERENCE" 73 | 74 | // Recurrence component properties 75 | PropExceptionDates = "EXDATE" 76 | PropRecurrenceDates = "RDATE" 77 | PropRecurrenceRule = "RRULE" 78 | 79 | // Alarm component properties 80 | PropAction = "ACTION" 81 | PropRepeat = "REPEAT" 82 | PropTrigger = "TRIGGER" 83 | 84 | // Change management component properties 85 | PropCreated = "CREATED" 86 | PropDateTimeStamp = "DTSTAMP" 87 | PropLastModified = "LAST-MODIFIED" 88 | PropSequence = "SEQUENCE" 89 | 90 | // Miscellaneous component properties 91 | PropRequestStatus = "REQUEST-STATUS" 92 | ) 93 | 94 | // Property parameters as defined in RFC 5545 section 3.2 and RFC 7986 95 | // section 6. 96 | const ( 97 | ParamAltRep = "ALTREP" 98 | ParamCommonName = "CN" 99 | ParamCalendarUserType = "CUTYPE" 100 | ParamDelegatedFrom = "DELEGATED-FROM" 101 | ParamDelegatedTo = "DELEGATED-TO" 102 | ParamDir = "DIR" 103 | ParamEncoding = "ENCODING" 104 | ParamFormatType = "FMTTYPE" 105 | ParamFreeBusyType = "FBTYPE" 106 | ParamLanguage = "LANGUAGE" 107 | ParamMember = "MEMBER" 108 | ParamParticipationStatus = "PARTSTAT" 109 | ParamRange = "RANGE" 110 | ParamRelated = "RELATED" 111 | ParamRelationshipType = "RELTYPE" 112 | ParamRole = "ROLE" 113 | ParamRSVP = "RSVP" 114 | ParamSentBy = "SENT-BY" 115 | ParamTimezoneID = "TZID" 116 | ParamValue = "VALUE" 117 | ParamDisplay = "DISPLAY" 118 | ParamEmail = "EMAIL" 119 | ParamFeature = "FEATURE" 120 | ParamLabel = "LABEL" 121 | ) 122 | 123 | // ValueType is the type of a property. 124 | type ValueType string 125 | 126 | // Value types as defined in RFC 5545 section 3.3. 127 | const ( 128 | ValueDefault ValueType = "" 129 | ValueBinary ValueType = "BINARY" 130 | ValueBool ValueType = "BOOLEAN" 131 | ValueCalendarAddress ValueType = "CAL-ADDRESS" 132 | ValueDate ValueType = "DATE" 133 | ValueDateTime ValueType = "DATE-TIME" 134 | ValueDuration ValueType = "DURATION" 135 | ValueFloat ValueType = "FLOAT" 136 | ValueInt ValueType = "INTEGER" 137 | ValuePeriod ValueType = "PERIOD" 138 | ValueRecurrence ValueType = "RECUR" 139 | ValueText ValueType = "TEXT" 140 | ValueTime ValueType = "TIME" 141 | ValueURI ValueType = "URI" 142 | ValueUTCOffset ValueType = "UTC-OFFSET" 143 | ) 144 | 145 | var defaultValueTypes = map[string]ValueType{ 146 | PropCalendarScale: ValueText, 147 | PropMethod: ValueText, 148 | PropProductID: ValueText, 149 | PropVersion: ValueText, 150 | PropAttach: ValueURI, // can be binary 151 | PropCategories: ValueText, 152 | PropClass: ValueText, 153 | PropComment: ValueText, 154 | PropDescription: ValueText, 155 | PropGeo: ValueFloat, 156 | PropLocation: ValueText, 157 | PropPercentComplete: ValueInt, 158 | PropPriority: ValueInt, 159 | PropResources: ValueText, 160 | PropStatus: ValueText, 161 | PropSummary: ValueText, 162 | PropCompleted: ValueDateTime, 163 | PropDateTimeEnd: ValueDateTime, // can be date 164 | PropDue: ValueDateTime, // can be date 165 | PropDateTimeStart: ValueDateTime, // can be date 166 | PropDuration: ValueDuration, 167 | PropFreeBusy: ValuePeriod, 168 | PropTransparency: ValueText, 169 | PropTimezoneID: ValueText, 170 | PropTimezoneName: ValueText, 171 | PropTimezoneOffsetFrom: ValueUTCOffset, 172 | PropTimezoneOffsetTo: ValueUTCOffset, 173 | PropTimezoneURL: ValueURI, 174 | PropAttendee: ValueCalendarAddress, 175 | PropContact: ValueText, 176 | PropOrganizer: ValueCalendarAddress, 177 | PropRecurrenceID: ValueDateTime, // can be date 178 | PropRelatedTo: ValueText, 179 | PropURL: ValueURI, 180 | PropUID: ValueText, 181 | PropExceptionDates: ValueDateTime, // can be date 182 | PropRecurrenceDates: ValueDateTime, // can be date or period 183 | PropRecurrenceRule: ValueRecurrence, 184 | PropAction: ValueText, 185 | PropRepeat: ValueInt, 186 | PropTrigger: ValueDuration, // can be date-time 187 | PropCreated: ValueDateTime, 188 | PropDateTimeStamp: ValueDateTime, 189 | PropLastModified: ValueDateTime, 190 | PropSequence: ValueInt, 191 | PropRequestStatus: ValueText, 192 | PropName: ValueText, 193 | PropRefreshInterval: ValueDuration, 194 | PropSource: ValueURI, 195 | PropColor: ValueText, 196 | PropImage: ValueURI, // can be binary 197 | PropConference: ValueURI, 198 | } 199 | 200 | type EventStatus string 201 | 202 | const ( 203 | EventTentative EventStatus = "TENTATIVE" 204 | EventConfirmed EventStatus = "CONFIRMED" 205 | EventCancelled EventStatus = "CANCELLED" 206 | ) 207 | 208 | // ImageDisplay describes the way an image for a component can be displayed. 209 | // Defined in RFC 7986 section 6.1. 210 | type ImageDisplay string 211 | 212 | const ( 213 | ImageBadge ImageDisplay = "BADGE" 214 | ImageGraphic ImageDisplay = "GRAPHIC" 215 | ImageFullSize ImageDisplay = "FULLSIZE" 216 | ImageThumbnail ImageDisplay = "THUMBNAIL" 217 | ) 218 | 219 | // ConferenceFeature describes features of a conference. Defined in RFC 7986 220 | // section 5.7. 221 | type ConferenceFeature string 222 | 223 | const ( 224 | ConferenceAudio ConferenceFeature = "AUDIO" 225 | ConferenceChat ConferenceFeature = "CHAT" 226 | ConferenceFeed ConferenceFeature = "FEED" 227 | ConferenceModerator ConferenceFeature = "MODERATOR" 228 | ConferencePhone ConferenceFeature = "PHONE" 229 | ConferenceScreen ConferenceFeature = "SCREEN" 230 | ConferenceVideo ConferenceFeature = "VIDEO" 231 | ) 232 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | package ical_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "log" 7 | "time" 8 | 9 | "github.com/emersion/go-ical" 10 | ) 11 | 12 | func ExampleDecoder() { 13 | // Let's assume r is an io.Reader containing iCal data 14 | var r io.Reader 15 | 16 | dec := ical.NewDecoder(r) 17 | for { 18 | cal, err := dec.Decode() 19 | if err == io.EOF { 20 | break 21 | } else if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | for _, event := range cal.Events() { 26 | summary, err := event.Props.Text(ical.PropSummary) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | log.Printf("Found event: %v", summary) 31 | } 32 | } 33 | } 34 | 35 | func ExampleEncoder() { 36 | event := ical.NewEvent() 37 | event.Props.SetText(ical.PropUID, "uid@example.org") 38 | event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now()) 39 | event.Props.SetText(ical.PropSummary, "My awesome event") 40 | event.Props.SetDateTime(ical.PropDateTimeStart, time.Now().Add(24*time.Hour)) 41 | 42 | cal := ical.NewCalendar() 43 | cal.Props.SetText(ical.PropVersion, "2.0") 44 | cal.Props.SetText(ical.PropProductID, "-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN") 45 | cal.Children = append(cal.Children, event.Component) 46 | 47 | var buf bytes.Buffer 48 | if err := ical.NewEncoder(&buf).Encode(cal); err != nil { 49 | log.Fatal(err) 50 | } 51 | 52 | log.Print(buf.String()) 53 | } 54 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/emersion/go-ical 2 | 3 | go 1.13 4 | 5 | require github.com/teambition/rrule-go v1.8.2 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= 2 | github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= 3 | -------------------------------------------------------------------------------- /ical.go: -------------------------------------------------------------------------------- 1 | // Package ical implements the iCalendar file format. 2 | // 3 | // iCalendar is defined in RFC 5545. 4 | package ical 5 | 6 | import ( 7 | "encoding/base64" 8 | "fmt" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/teambition/rrule-go" 15 | ) 16 | 17 | // MIME type and file extension for iCal, defined in RFC 5545 section 8.1. 18 | const ( 19 | MIMEType = "text/calendar" 20 | Extension = "ics" 21 | ) 22 | 23 | const ( 24 | dateFormat = "20060102" 25 | datetimeFormat = "20060102T150405" 26 | datetimeUTCFormat = "20060102T150405Z" 27 | ) 28 | 29 | // Params is a set of property parameters. 30 | type Params map[string][]string 31 | 32 | func (params Params) Values(name string) []string { 33 | return params[strings.ToUpper(name)] 34 | } 35 | 36 | func (params Params) Get(name string) string { 37 | if values := params[strings.ToUpper(name)]; len(values) > 0 { 38 | return values[0] 39 | } 40 | return "" 41 | } 42 | 43 | func (params Params) Set(name, value string) { 44 | params[strings.ToUpper(name)] = []string{value} 45 | } 46 | 47 | func (params Params) Add(name, value string) { 48 | name = strings.ToUpper(name) 49 | params[name] = append(params[name], value) 50 | } 51 | 52 | func (params Params) Del(name string) { 53 | delete(params, strings.ToUpper(name)) 54 | } 55 | 56 | // Prop is a component property. 57 | type Prop struct { 58 | Name string 59 | Params Params 60 | Value string 61 | } 62 | 63 | // NewProp creates a new property with the specified name. 64 | func NewProp(name string) *Prop { 65 | return &Prop{ 66 | Name: strings.ToUpper(name), 67 | Params: make(Params), 68 | } 69 | } 70 | 71 | func (prop *Prop) ValueType() ValueType { 72 | t := ValueType(prop.Params.Get(ParamValue)) 73 | if t == ValueDefault { 74 | t = defaultValueTypes[prop.Name] 75 | } 76 | return t 77 | } 78 | 79 | func (prop *Prop) SetValueType(t ValueType) { 80 | dt, ok := defaultValueTypes[prop.Name] 81 | if t == ValueDefault || (ok && t == dt) { 82 | prop.Params.Del(ParamValue) 83 | } else { 84 | prop.Params.Set(ParamValue, string(t)) 85 | } 86 | } 87 | 88 | func (prop *Prop) expectValueType(want ValueType) error { 89 | t := prop.ValueType() 90 | if t != ValueDefault && t != want { 91 | return fmt.Errorf("ical: property %q: expected type %q, got %q", prop.Name, want, t) 92 | } 93 | return nil 94 | } 95 | 96 | func (prop *Prop) Binary() ([]byte, error) { 97 | if err := prop.expectValueType(ValueBinary); err != nil { 98 | return nil, err 99 | } 100 | return base64.StdEncoding.DecodeString(prop.Value) 101 | } 102 | 103 | func (prop *Prop) SetBinary(b []byte) { 104 | prop.SetValueType(ValueBinary) 105 | prop.Params.Set("ENCODING", "BASE64") 106 | prop.Value = base64.StdEncoding.EncodeToString(b) 107 | } 108 | 109 | func (prop *Prop) Bool() (bool, error) { 110 | if err := prop.expectValueType(ValueBool); err != nil { 111 | return false, err 112 | } 113 | switch strings.ToUpper(prop.Value) { 114 | case "TRUE": 115 | return true, nil 116 | case "FALSE": 117 | return false, nil 118 | default: 119 | return false, fmt.Errorf("ical: invalid boolean: %q", prop.Value) 120 | } 121 | } 122 | 123 | // DateTime parses the property value as a date-time or a date. 124 | func (prop *Prop) DateTime(loc *time.Location) (time.Time, error) { 125 | // Default to UTC, if there is no given location. 126 | if loc == nil { 127 | loc = time.UTC 128 | } 129 | 130 | valueType := prop.ValueType() 131 | valueLength := len(prop.Value) 132 | if valueType == ValueDefault { 133 | switch valueLength { 134 | case len(dateFormat): 135 | valueType = ValueDate 136 | case len(datetimeFormat), len(datetimeUTCFormat): 137 | valueType = ValueDateTime 138 | } 139 | } 140 | 141 | switch valueType { 142 | case ValueDate: 143 | return time.ParseInLocation(dateFormat, prop.Value, loc) 144 | case ValueDateTime: 145 | if valueLength == len(datetimeUTCFormat) { 146 | return time.ParseInLocation(datetimeUTCFormat, prop.Value, time.UTC) 147 | } 148 | // Use the TZID location, if available. 149 | if tzid := prop.Params.Get(PropTimezoneID); tzid != "" { 150 | tzLoc, err := time.LoadLocation(tzid) 151 | if err != nil { 152 | return time.Time{}, err 153 | } 154 | loc = tzLoc 155 | } 156 | return time.ParseInLocation(datetimeFormat, prop.Value, loc) 157 | } 158 | 159 | return time.Time{}, fmt.Errorf("ical: cannot process: (%q) %s", valueType, prop.Value) 160 | } 161 | 162 | func (prop *Prop) SetDate(t time.Time) { 163 | prop.SetValueType(ValueDate) 164 | prop.Value = t.Format(dateFormat) 165 | } 166 | 167 | func (prop *Prop) SetDateTime(t time.Time) { 168 | prop.SetValueType(ValueDateTime) 169 | switch t.Location() { 170 | case nil, time.UTC: 171 | prop.Value = t.Format(datetimeUTCFormat) 172 | default: 173 | prop.Params.Set(PropTimezoneID, t.Location().String()) 174 | prop.Value = t.Format(datetimeFormat) 175 | } 176 | } 177 | 178 | type durationParser struct { 179 | s string 180 | } 181 | 182 | func (p *durationParser) consume(c byte) bool { 183 | if len(p.s) == 0 || p.s[0] != c { 184 | return false 185 | } 186 | p.s = p.s[1:] 187 | return true 188 | } 189 | 190 | func (p *durationParser) parseCount() (time.Duration, error) { 191 | // Find the first non-digit 192 | i := strings.IndexFunc(p.s, func(r rune) bool { 193 | return r < '0' || r > '9' 194 | }) 195 | if i == 0 { 196 | return 0, fmt.Errorf("ical: invalid duration: expected a digit") 197 | } 198 | if i < 0 { 199 | i = len(p.s) 200 | } 201 | 202 | n, err := strconv.ParseUint(p.s[:i], 10, 64) 203 | if err != nil { 204 | return 0, fmt.Errorf("ical: invalid duration: %v", err) 205 | } 206 | p.s = p.s[i:] 207 | return time.Duration(n), nil 208 | } 209 | 210 | func (p *durationParser) parseDuration() (time.Duration, error) { 211 | neg := p.consume('-') 212 | if !neg { 213 | _ = p.consume('+') 214 | } 215 | 216 | if !p.consume('P') { 217 | return 0, fmt.Errorf("ical: invalid duration: expected 'P'") 218 | } 219 | 220 | var dur time.Duration 221 | isTime := false 222 | for len(p.s) > 0 { 223 | if p.consume('T') { 224 | isTime = true 225 | } 226 | 227 | n, err := p.parseCount() 228 | if err != nil { 229 | return 0, err 230 | } 231 | 232 | if !isTime { 233 | if p.consume('D') { 234 | dur += n * 24 * time.Hour 235 | } else if p.consume('W') { 236 | dur += n * 7 * 24 * time.Hour 237 | } else { 238 | return 0, fmt.Errorf("ical: invalid duration: expected 'D' or 'W'") 239 | } 240 | } else { 241 | if p.consume('H') { 242 | dur += n * time.Hour 243 | } else if p.consume('M') { 244 | dur += n * time.Minute 245 | } else if p.consume('S') { 246 | dur += n * time.Second 247 | } else { 248 | return 0, fmt.Errorf("ical: invalid duration: expected 'H', 'M' or 'S'") 249 | } 250 | } 251 | } 252 | 253 | if neg { 254 | dur = -dur 255 | } 256 | return dur, nil 257 | } 258 | 259 | func (prop *Prop) Duration() (time.Duration, error) { 260 | if err := prop.expectValueType(ValueDuration); err != nil { 261 | return 0, err 262 | } 263 | p := durationParser{strings.ToUpper(prop.Value)} 264 | return p.parseDuration() 265 | } 266 | 267 | func (prop *Prop) SetDuration(dur time.Duration) { 268 | prop.SetValueType(ValueDuration) 269 | 270 | sec := dur.Milliseconds() / 1000 271 | neg := sec < 0 272 | if sec < 0 { 273 | sec = -sec 274 | } 275 | 276 | var s string 277 | if neg { 278 | s += "-" 279 | } 280 | s += "PT" 281 | s += strconv.FormatInt(sec, 10) 282 | s += "S" 283 | 284 | prop.Value = s 285 | } 286 | 287 | func (prop *Prop) Float() (float64, error) { 288 | if err := prop.expectValueType(ValueFloat); err != nil { 289 | return 0, err 290 | } 291 | return strconv.ParseFloat(prop.Value, 64) 292 | } 293 | 294 | func (prop *Prop) Int() (int, error) { 295 | if err := prop.expectValueType(ValueInt); err != nil { 296 | return 0, err 297 | } 298 | return strconv.Atoi(prop.Value) 299 | } 300 | 301 | func (prop *Prop) TextList() ([]string, error) { 302 | if err := prop.expectValueType(ValueText); err != nil { 303 | return nil, err 304 | } 305 | 306 | var l []string 307 | var sb strings.Builder 308 | for i := 0; i < len(prop.Value); i++ { 309 | switch c := prop.Value[i]; c { 310 | case '\\': 311 | i++ 312 | if i >= len(prop.Value) { 313 | return nil, fmt.Errorf("ical: malformed text: antislash at end of text") 314 | } 315 | switch c := prop.Value[i]; c { 316 | case '\\', ';', ',': 317 | sb.WriteByte(c) 318 | case 'n', 'N': 319 | sb.WriteByte('\n') 320 | default: 321 | return nil, fmt.Errorf("ical: malformed text: invalid escape sequence '\\%v'", c) 322 | } 323 | case ',': 324 | l = append(l, sb.String()) 325 | sb.Reset() 326 | default: 327 | sb.WriteByte(c) 328 | } 329 | } 330 | l = append(l, sb.String()) 331 | 332 | return l, nil 333 | } 334 | 335 | func (prop *Prop) SetTextList(l []string) { 336 | prop.SetValueType(ValueText) 337 | 338 | var sb strings.Builder 339 | for i, text := range l { 340 | if i > 0 { 341 | sb.WriteByte(',') 342 | } 343 | 344 | sb.Grow(len(text)) 345 | for _, r := range text { 346 | switch r { 347 | case '\\', ';', ',': 348 | sb.WriteByte('\\') 349 | sb.WriteRune(r) 350 | case '\n': 351 | sb.WriteString("\\n") 352 | default: 353 | sb.WriteRune(r) 354 | } 355 | } 356 | } 357 | prop.Value = sb.String() 358 | } 359 | 360 | func (prop *Prop) Text() (string, error) { 361 | l, err := prop.TextList() 362 | if err != nil { 363 | return "", err 364 | } 365 | if len(l) == 0 { 366 | return "", nil 367 | } 368 | return l[0], nil 369 | } 370 | 371 | func (prop *Prop) SetText(text string) { 372 | prop.SetTextList([]string{text}) 373 | } 374 | 375 | // URI parses the property value as a URI or binary. If the value is binary, a 376 | // data URI is returned. 377 | func (prop *Prop) URI() (*url.URL, error) { 378 | switch t := prop.ValueType(); t { 379 | case ValueDefault, ValueURI: 380 | return url.Parse(prop.Value) 381 | case ValueBinary: 382 | mediaType := prop.Params.Get(ParamFormatType) 383 | return &url.URL{ 384 | Scheme: "data", 385 | Opaque: mediaType + ";base64," + prop.Value, 386 | }, nil 387 | default: 388 | return nil, fmt.Errorf("ical: expected URI or BINARY, got %q", t) 389 | } 390 | } 391 | 392 | func (prop *Prop) SetURI(u *url.URL) { 393 | prop.SetValueType(ValueURI) 394 | prop.Value = u.String() 395 | } 396 | 397 | // TODO: Period, Time, UTCOffset 398 | 399 | // Props is a set of component properties. 400 | type Props map[string][]Prop 401 | 402 | func (props Props) Values(name string) []Prop { 403 | return props[strings.ToUpper(name)] 404 | } 405 | 406 | func (props Props) Get(name string) *Prop { 407 | if l := props[strings.ToUpper(name)]; len(l) > 0 { 408 | return &l[0] 409 | } 410 | return nil 411 | } 412 | 413 | func (props Props) Set(prop *Prop) { 414 | props[prop.Name] = []Prop{*prop} 415 | } 416 | 417 | func (props Props) Add(prop *Prop) { 418 | props[prop.Name] = append(props[prop.Name], *prop) 419 | } 420 | 421 | func (props Props) Del(name string) { 422 | delete(props, name) 423 | } 424 | 425 | func (props Props) Text(name string) (string, error) { 426 | if prop := props.Get(name); prop != nil { 427 | return prop.Text() 428 | } 429 | return "", nil 430 | } 431 | 432 | func (props Props) SetText(name, text string) { 433 | prop := NewProp(name) 434 | prop.SetText(text) 435 | props.Set(prop) 436 | } 437 | 438 | func (props Props) DateTime(name string, loc *time.Location) (time.Time, error) { 439 | if prop := props.Get(name); prop != nil { 440 | return prop.DateTime(loc) 441 | } 442 | return time.Time{}, nil 443 | } 444 | 445 | func (props Props) SetDate(name string, t time.Time) { 446 | prop := NewProp(name) 447 | prop.SetDate(t) 448 | props.Set(prop) 449 | } 450 | 451 | func (props Props) SetDateTime(name string, t time.Time) { 452 | prop := NewProp(name) 453 | prop.SetDateTime(t) 454 | props.Set(prop) 455 | } 456 | 457 | func (props Props) SetURI(name string, u *url.URL) { 458 | prop := NewProp(name) 459 | prop.SetURI(u) 460 | props.Set(prop) 461 | } 462 | 463 | func (props Props) URI(name string) (*url.URL, error) { 464 | if prop := props.Get(name); prop != nil { 465 | return prop.URI() 466 | } 467 | return nil, nil 468 | } 469 | 470 | // Returns an ROption based on the events RRULE. 471 | // 472 | // This object can then be used to construct `RRule` instances for different 473 | // fields, for example, an rrule based on `DTSTART`: 474 | // 475 | // roption, err := props.RecurrenceRule() 476 | // if err != nil { 477 | // log.Fatalf("error parsing rrule:", err) 478 | // } 479 | // if roption == nil { 480 | // log.Fatalf("props have no RRULE") 481 | // } 482 | // 483 | // dtstart, err := props.DateTime("DTSTART", nil) 484 | // if err != nil { 485 | // log.Fatalf("error parsing dtstart:", err) 486 | // } 487 | // roption.Dtstart = dtstart 488 | // 489 | // return rrule.NewRRule(*roption) 490 | // 491 | // This object can then be used to calculate the `DTSTART` of all recurrances. 492 | func (props Props) RecurrenceRule() (*rrule.ROption, error) { 493 | prop := props.Get(PropRecurrenceRule) 494 | if prop == nil { 495 | return nil, nil 496 | } 497 | if err := prop.expectValueType(ValueRecurrence); err != nil { 498 | return nil, err 499 | } 500 | 501 | roption, err := rrule.StrToROption(prop.Value) 502 | if err != nil { 503 | return nil, fmt.Errorf("ical: error parsing rrule: %v", err) 504 | } 505 | 506 | return roption, nil 507 | } 508 | 509 | func (props Props) SetRecurrenceRule(rule *rrule.ROption) { 510 | if rule != nil { 511 | prop := NewProp(PropRecurrenceRule) 512 | prop.SetValueType(ValueRecurrence) 513 | prop.Value = rule.RRuleString() 514 | props.Set(prop) 515 | } else { 516 | props.Del(PropRecurrenceRule) 517 | } 518 | } 519 | 520 | // Component is an iCalendar component: collections of properties that express 521 | // a particular calendar semantic. A components can be an events, a to-do, a 522 | // journal entry, timezone information, free/busy time information, or an 523 | // alarm. 524 | type Component struct { 525 | Name string 526 | Props Props 527 | Children []*Component 528 | } 529 | 530 | // NewComponent creates a new component with the specified name. 531 | func NewComponent(name string) *Component { 532 | return &Component{ 533 | Name: strings.ToUpper(name), 534 | Props: make(Props), 535 | } 536 | } 537 | -------------------------------------------------------------------------------- /ical_test.go: -------------------------------------------------------------------------------- 1 | package ical 2 | 3 | import ( 4 | "net/url" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/teambition/rrule-go" 11 | ) 12 | 13 | func toCRLF(s string) string { 14 | return strings.ReplaceAll(s, "\n", "\r\n") 15 | } 16 | 17 | var exampleCalendarStr = toCRLF(`BEGIN:VCALENDAR 18 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN 19 | VERSION:2.0 20 | BEGIN:VEVENT 21 | CATEGORIES:CONFERENCE 22 | DESCRIPTION;ALTREP="cid:part1.0001@example.org":Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\n Atlanta\, Georgia 23 | DTEND:19960920T220000Z 24 | DTSTAMP:19960704T120000Z 25 | DTSTART:19960918T143000Z 26 | ORGANIZER:mailto:jsmith@example.com 27 | RRULE:FREQ=YEARLY;BYDAY=3SU;BYMONTH=3 28 | STATUS:CONFIRMED 29 | SUMMARY;FOO=bar,"b:az":Networld+Interop Conference 30 | UID:uid1@example.com 31 | END:VEVENT 32 | END:VCALENDAR 33 | `) 34 | 35 | var exampleCalendar = &Calendar{&Component{ 36 | Name: "VCALENDAR", 37 | Props: Props{ 38 | "PRODID": []Prop{{ 39 | Name: "PRODID", 40 | Params: Params{}, 41 | Value: "-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN", 42 | }}, 43 | "VERSION": []Prop{{ 44 | Name: "VERSION", 45 | Params: Params{}, 46 | Value: "2.0", 47 | }}, 48 | }, 49 | Children: []*Component{ 50 | { 51 | Name: "VEVENT", 52 | Props: Props{ 53 | "DTSTAMP": []Prop{{ 54 | Name: "DTSTAMP", 55 | Params: Params{}, 56 | Value: "19960704T120000Z", 57 | }}, 58 | "UID": []Prop{{ 59 | Name: "UID", 60 | Params: Params{}, 61 | Value: "uid1@example.com", 62 | }}, 63 | "RRULE": []Prop{{ 64 | Name: "RRULE", 65 | Params: Params{}, 66 | Value: "FREQ=YEARLY;BYDAY=3SU;BYMONTH=3", 67 | }}, 68 | "ORGANIZER": []Prop{{ 69 | Name: "ORGANIZER", 70 | Params: Params{}, 71 | Value: "mailto:jsmith@example.com", 72 | }}, 73 | "DTSTART": []Prop{{ 74 | Name: "DTSTART", 75 | Params: Params{}, 76 | Value: "19960918T143000Z", 77 | }}, 78 | "DTEND": []Prop{{ 79 | Name: "DTEND", 80 | Params: Params{}, 81 | Value: "19960920T220000Z", 82 | }}, 83 | "STATUS": []Prop{{ 84 | Name: "STATUS", 85 | Params: Params{}, 86 | Value: "CONFIRMED", 87 | }}, 88 | "CATEGORIES": []Prop{{ 89 | Name: "CATEGORIES", 90 | Params: Params{}, 91 | Value: "CONFERENCE", 92 | }}, 93 | "SUMMARY": []Prop{{ 94 | Name: "SUMMARY", 95 | Params: Params{ 96 | "FOO": []string{"bar", "b:az"}, 97 | }, 98 | Value: "Networld+Interop Conference", 99 | }}, 100 | "DESCRIPTION": []Prop{{ 101 | Name: "DESCRIPTION", 102 | Params: Params{ 103 | "ALTREP": []string{"cid:part1.0001@example.org"}, 104 | }, 105 | Value: `Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\n Atlanta\, Georgia`, 106 | }}, 107 | }, 108 | }, 109 | }, 110 | }} 111 | 112 | func TestCalendar(t *testing.T) { 113 | events := exampleCalendar.Events() 114 | if len(events) != 1 { 115 | t.Fatalf("len(Calendar.Events()) = %v, want 1", len(events)) 116 | } 117 | event := events[0] 118 | 119 | wantSummary := "Networld+Interop Conference" 120 | if summary, err := event.Props.Text(PropSummary); err != nil { 121 | t.Errorf("Event.Props.Text(PropSummary) = %v", err) 122 | } else if summary != wantSummary { 123 | t.Errorf("Event.Props.Text(PropSummary) = %v, want %v", summary, wantSummary) 124 | } 125 | 126 | wantDesc := "Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\n Atlanta, Georgia" 127 | if desc, err := event.Props.Text(PropDescription); err != nil { 128 | t.Errorf("Event.Props.Text(PropDescription) = %v", err) 129 | } else if desc != wantDesc { 130 | t.Errorf("Event.Props.Text(PropDescription) = %v, want %v", desc, wantDesc) 131 | } 132 | 133 | wantDTStamp := time.Date(1996, 07, 04, 12, 0, 0, 0, time.UTC) 134 | if dtStamp, err := event.Props.DateTime(PropDateTimeStamp, nil); err != nil { 135 | t.Errorf("Event.Props.DateTime(PropDateTimeStamp) = %v", err) 136 | } else if dtStamp != wantDTStamp { 137 | t.Errorf("Event.Props.DateTime(PropDateTimeStamp) = %v, want %v", dtStamp, wantDTStamp) 138 | } 139 | 140 | wantDTStart := time.Date(1996, 9, 18, 14, 30, 0, 0, time.UTC) 141 | if dtStart, err := event.DateTimeStart(nil); err != nil { 142 | t.Errorf("Event.DateTimeStart() = %v", err) 143 | } else if dtStart != wantDTStart { 144 | t.Errorf("Event.DateTimeStart() = %v, want %v", dtStart, wantDTStart) 145 | } 146 | 147 | wantDTEnd := time.Date(1996, 9, 20, 22, 0, 0, 0, time.UTC) 148 | if dtEnd, err := event.DateTimeEnd(nil); err != nil { 149 | t.Errorf("Event.DateTimeEnd() = %v", err) 150 | } else if dtEnd != wantDTEnd { 151 | t.Errorf("Event.DateTimeEnd() = %v, want %v", dtEnd, wantDTEnd) 152 | } 153 | } 154 | 155 | func TestGetDate(t *testing.T) { 156 | localTimezone, err := time.LoadLocation("Europe/Paris") 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | 161 | testCases := []struct { 162 | Alias string 163 | Value string 164 | ValueType ValueType 165 | TZID string 166 | Location *time.Location 167 | ExpectedDate time.Time 168 | }{ 169 | { 170 | Alias: "datetime-local-nil", 171 | Value: "20200923T195100", 172 | ValueType: ValueDateTime, 173 | TZID: "Europe/Paris", 174 | Location: nil, 175 | ExpectedDate: time.Date(2020, time.September, 23, 19, 51, 0, 0, localTimezone), 176 | }, 177 | { 178 | Alias: "datetime-nil-local", 179 | Value: "20200923T195100", 180 | ValueType: ValueDateTime, 181 | TZID: "", 182 | Location: localTimezone, 183 | ExpectedDate: time.Date(2020, time.September, 23, 19, 51, 0, 0, localTimezone), 184 | }, 185 | { 186 | Alias: "datetime-nil-nil", 187 | Value: "20200923T195100", 188 | ValueType: ValueDateTime, 189 | TZID: "", 190 | Location: nil, 191 | ExpectedDate: time.Date(2020, time.September, 23, 19, 51, 0, 0, time.UTC), 192 | }, 193 | { 194 | Alias: "datetime-Z-no-location", 195 | Value: "20200923T195100Z", 196 | ValueType: ValueDateTime, 197 | TZID: "Europe/Paris", 198 | Location: nil, 199 | ExpectedDate: time.Date(2020, time.September, 23, 19, 51, 0, 0, time.UTC), 200 | }, 201 | { 202 | Alias: "datetime-Z-no-tzid", 203 | Value: "20200923T195100Z", 204 | ValueType: ValueDateTime, 205 | TZID: "", 206 | Location: localTimezone, 207 | ExpectedDate: time.Date(2020, time.September, 23, 19, 51, 0, 0, time.UTC), 208 | }, 209 | { 210 | Alias: "date-local-nil", 211 | Value: "20200923", 212 | ValueType: ValueDate, 213 | TZID: "Europe/Paris", 214 | Location: nil, 215 | ExpectedDate: time.Date(2020, time.September, 23, 0, 0, 0, 0, time.UTC), 216 | }, 217 | { 218 | Alias: "date-nil-local", 219 | Value: "20200923", 220 | ValueType: ValueDate, 221 | TZID: "", 222 | Location: localTimezone, 223 | ExpectedDate: time.Date(2020, time.September, 23, 0, 0, 0, 0, localTimezone), 224 | }, 225 | { 226 | Alias: "date-nil-nil", 227 | Value: "20200923", 228 | ValueType: ValueDate, 229 | TZID: "", 230 | Location: nil, 231 | ExpectedDate: time.Date(2020, time.September, 23, 0, 0, 0, 0, time.UTC), 232 | }, 233 | } 234 | 235 | for _, tCase := range testCases { 236 | t.Run(tCase.Alias, func(t *testing.T) { 237 | p := NewProp("FakeProp") 238 | p.Value = tCase.Value 239 | p.SetValueType(tCase.ValueType) 240 | if tCase.TZID != "" { 241 | p.Params.Set(PropTimezoneID, tCase.TZID) 242 | } 243 | value, err := p.DateTime(tCase.Location) 244 | if err != nil { 245 | t.Fatal(err) 246 | } 247 | if got, want := value, tCase.ExpectedDate; value.String() != tCase.ExpectedDate.String() { 248 | t.Errorf("bad date: %s, expected: %s", got, want) 249 | } 250 | }) 251 | } 252 | } 253 | 254 | func TestSetDate(t *testing.T) { 255 | localTimezone, err := time.LoadLocation("Europe/Paris") 256 | if err != nil { 257 | t.Fatal(err) 258 | } 259 | 260 | testCases := []struct { 261 | Alias string 262 | Date time.Time 263 | ExpectedDate string 264 | }{ 265 | { 266 | Alias: "UTC", 267 | Date: time.Date(2020, time.September, 20, 0, 0, 0, 0, time.UTC), 268 | ExpectedDate: "20200920", 269 | }, 270 | { 271 | Alias: "local", 272 | Date: time.Date(2020, time.September, 20, 0, 0, 0, 0, localTimezone), 273 | ExpectedDate: "20200920", 274 | }, 275 | { 276 | Alias: "non_zero_time", 277 | Date: time.Date(2020, time.September, 20, 17, 7, 0, 0, localTimezone), 278 | ExpectedDate: "20200920", 279 | }, 280 | } 281 | 282 | for _, tCase := range testCases { 283 | t.Run(tCase.Alias, func(t *testing.T) { 284 | p := NewProp("FakeProp") 285 | p.SetDate(tCase.Date) 286 | if got, want := p.Value, tCase.ExpectedDate; got != want { 287 | t.Errorf("bad date: %s, expected: %s", got, want) 288 | } 289 | if got, want := p.Params.Get(PropTimezoneID), ""; got != want { 290 | t.Errorf("bad tzid: %s, expected: %s", got, want) 291 | } 292 | }) 293 | } 294 | } 295 | 296 | func TestSetDateTime(t *testing.T) { 297 | localTimezone, err := time.LoadLocation("Europe/Paris") 298 | if err != nil { 299 | t.Fatal(err) 300 | } 301 | 302 | testCases := []struct { 303 | Alias string 304 | Date time.Time 305 | ExpectedTZID string 306 | ExpectedDate string 307 | }{ 308 | { 309 | Alias: "UTC", 310 | Date: time.Date(2020, time.September, 20, 15, 7, 0, 0, time.UTC), 311 | ExpectedTZID: "", 312 | ExpectedDate: "20200920T150700Z", 313 | }, 314 | { 315 | Alias: "local", 316 | Date: time.Date(2020, time.September, 20, 17, 7, 0, 0, localTimezone), 317 | ExpectedTZID: "Europe/Paris", 318 | ExpectedDate: "20200920T170700", 319 | }, 320 | } 321 | 322 | for _, tCase := range testCases { 323 | t.Run(tCase.Alias, func(t *testing.T) { 324 | p := NewProp("FakeProp") 325 | p.SetDateTime(tCase.Date) 326 | if got, want := p.Params.Get(PropTimezoneID), tCase.ExpectedTZID; got != want { 327 | t.Errorf("bad tzid: %s, expected: %s", got, want) 328 | } 329 | if got, want := p.Value, tCase.ExpectedDate; got != want { 330 | t.Errorf("bad date: %s, expected: %s", got, want) 331 | } 332 | }) 333 | } 334 | } 335 | 336 | func TestRoundtripURI(t *testing.T) { 337 | testCases := []struct { 338 | Alias string 339 | Expected string 340 | }{ 341 | { 342 | Alias: "empty_url", 343 | Expected: "", 344 | }, 345 | { 346 | Alias: "scheme_and_port", 347 | Expected: "https://google.com:8080", 348 | }, 349 | } 350 | 351 | for _, tCase := range testCases { 352 | t.Run(tCase.Alias, func(t *testing.T) { 353 | ue, err := url.Parse(tCase.Expected) 354 | if err != nil { 355 | t.Fatalf("%#v", err) 356 | } 357 | probs := make(Props) 358 | probs.SetURI("asdf", ue) 359 | ug, err := probs.URI("asdf") 360 | if err != nil { 361 | t.Errorf("%#v", err) 362 | } 363 | if got, want := ug.String(), ue.String(); got != want { 364 | t.Errorf("bad url: %s, expected: %s", got, want) 365 | } 366 | }) 367 | } 368 | } 369 | 370 | func TestRecurrenceRule(t *testing.T) { 371 | events := exampleCalendar.Events() 372 | if len(events) != 1 { 373 | t.Fatalf("len(Calendar.Events()) = %v, want 1", len(events)) 374 | } 375 | props := events[0].Props 376 | 377 | wantRecurrenceRule := &rrule.ROption{ 378 | Freq: rrule.YEARLY, 379 | Bymonth: []int{3}, 380 | Byweekday: []rrule.Weekday{rrule.SU.Nth(3)}, 381 | } 382 | if roption, err := props.RecurrenceRule(); err != nil { 383 | t.Errorf("Props.RecurrenceRule() = %v", err) 384 | } else if !reflect.DeepEqual(roption, wantRecurrenceRule) { 385 | t.Errorf("Props.RecurrenceRule() = %v, want %v", roption, wantRecurrenceRule) 386 | } 387 | } 388 | 389 | func TestRecurrenceRuleIsAbsent(t *testing.T) { 390 | props := Props{} 391 | 392 | roption, err := props.RecurrenceRule() 393 | if roption != nil || err != nil { 394 | t.Errorf("Props.RecurrenceRule() = %v, %v, want nil, nil", roption, err) 395 | } 396 | } 397 | 398 | func TestRecurrenceRuleSetToNil(t *testing.T) { 399 | props := Props{ 400 | "RRULE": []Prop{{ 401 | Name: "RRULE", 402 | Params: Params{}, 403 | Value: "FREQ=YEARLY;BYDAY=3SU;BYMONTH=3", 404 | }}, 405 | } 406 | 407 | props.SetRecurrenceRule(nil) 408 | 409 | roption, err := props.RecurrenceRule() 410 | if roption != nil || err != nil { 411 | t.Errorf("Props.RecurrenceRule() = %v, %v, want nil, nil", roption, err) 412 | } 413 | } 414 | 415 | func TestRecurrenceRuleRoundTrip(t *testing.T) { 416 | recurrenceRule := &rrule.ROption{ 417 | Freq: rrule.YEARLY, 418 | Bymonth: []int{3}, 419 | Byweekday: []rrule.Weekday{rrule.SU.Nth(3)}, 420 | } 421 | 422 | props := Props{} 423 | props.SetRecurrenceRule(recurrenceRule) 424 | 425 | if roption, err := props.RecurrenceRule(); err != nil { 426 | t.Errorf("Props.RecurrenceRule() = %v", err) 427 | } else if !reflect.DeepEqual(roption, recurrenceRule) { 428 | t.Errorf("Props.RecurrenceRule() = %v, want %v", roption, recurrenceRule) 429 | } 430 | } 431 | --------------------------------------------------------------------------------