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/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 05effc0..0e4f212 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,28 +1,39 @@ on: [push, pull_request] name: Test jobs: - test: + version: + name: Test + permissions: + contents: read strategy: matrix: - go-version: [1.14.x, 1.15.x, 1.16.x, 1.17.x] - os: [ubuntu-latest, macos-latest, windows-latest] + go-version: ['oldstable', 'stable'] + os: [ubuntu-latest, macos-13, 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 + go-version: "${{ matrix.go-version }}" + - name: 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: - 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 - run: go test ./... + go-version-file: "${{ matrix.go-version-file }}" + - name: Go Test + run: go test -race ./... diff --git a/.gitignore b/.gitignore index 85e7c1d..400eb8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /.idea/ +/testdata/serialization/actual 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 ./... +``` + + diff --git a/calendar.go b/calendar.go index 0d99af3..49f16dc 100644 --- a/calendar.go +++ b/calendar.go @@ -27,33 +27,41 @@ 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) + ComponentPropertyTzid = ComponentProperty(PropertyTzid) + ComponentPropertyComment = ComponentProperty(PropertyComment) + ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo) ) type Property string @@ -214,7 +222,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 @@ -299,60 +307,60 @@ 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) + c.SerializeTo(w) } - fmt.Fprint(w, "END:VCALENDAR", "\r\n") + _, _ = fmt.Fprint(w, "END:VCALENDAR", "\r\n") return nil } 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) { @@ -360,23 +368,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) { @@ -608,7 +616,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 } 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_serialization_test.go b/calendar_serialization_test.go new file mode 100644 index 0000000..eec5871 --- /dev/null +++ b/calendar_serialization_test.go @@ -0,0 +1,78 @@ +//go:build go1.16 +// +build go1.16 + +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)) + require.NoError(t, err) + serializedCal := deserializedCal.Serialize() + + //then + 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 { + 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) + } + }) + + 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)) + require.NoError(t, err) + + //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/calendar_test.go b/calendar_test.go index f008406..c61e961 100644 --- a/calendar_test.go +++ b/calendar_test.go @@ -2,7 +2,6 @@ package ics import ( "io" - "io/ioutil" "os" "path/filepath" "regexp" @@ -168,7 +167,7 @@ func TestRfc5545Sec4Examples(t *testing.T) { return nil } - inputBytes, err := ioutil.ReadFile(path) + inputBytes, err := os.ReadFile(path) if err != nil { return err } @@ -316,6 +315,56 @@ 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 +`, + }, + { + 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 `, }, } diff --git a/components.go b/components.go index b038448..29e3916 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 @@ -30,15 +42,24 @@ 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 { + +func (cb ComponentBase) serializeThis(writer io.Writer, componentType string) { + _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, "\r\n") + for _, p := range cb.Properties { p.serialize(writer) } - for _, c := range base.Components { - c.serialize(writer) + for _, c := range cb.Components { + c.SerializeTo(writer) + } + _, _ = fmt.Fprint(writer, "END:"+componentType, "\r\n") +} + +func NewComponent(uniqueId string) ComponentBase { + return ComponentBase{ + Properties: []IANAProperty{ + {BaseProperty{IANAToken: string(ComponentPropertyUniqueId), Value: uniqueId}}, + }, } - fmt.Fprint(writer, "END:"+componentType, "\r\n") } func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAProperty { @@ -80,18 +101,16 @@ 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() +// 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 ( @@ -105,64 +124,40 @@ 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 (cb *ComponentBase) SetCreatedTime(t time.Time, props ...PropertyParameter) { + cb.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) SetDtStampTime(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtstamp, 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) SetModifiedAt(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetSequence(seq int, props ...PropertyParameter) { - event.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...) +func (cb *ComponentBase) SetSequence(seq int, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...) } -func (event *VEvent) SetStartAt(t time.Time, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...) +func (cb *ComponentBase) SetStartAt(t time.Time, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...) } -func (event *VEvent) SetAllDayStartAt(t time.Time, props ...PropertyParameter) { +func (cb *ComponentBase) SetAllDayStartAt(t time.Time, props ...PropertyParameter) { props = append(props, WithValue(string(ValueDataTypeDate))) - event.SetProperty(ComponentPropertyDtStart, t.Format(icalDateFormatLocal), props...) + cb.SetProperty(ComponentPropertyDtStart, t.Format(icalDateFormatLocal), 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) { +func (cb *ComponentBase) SetAllDayEndAt(t time.Time, props ...PropertyParameter) { props = append(props, WithValue(string(ValueDataTypeDate))) - event.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), props...) + cb.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), 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") + return time.Time{}, fmt.Errorf("%w: %s", ErrorPropertyNotFound, componentProperty) } timeVal := timeProp.BaseProperty.Value @@ -226,103 +221,116 @@ 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) GetLastModifiedAt() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyLastModified, false) } -func (event *VEvent) GetAllDayEndAt() (time.Time, error) { - return event.getTimeProp(ComponentPropertyDtEnd, true) +func (cb *ComponentBase) GetDtStampTime() (time.Time, error) { + return cb.getTimeProp(ComponentPropertyDtstamp, false) } -type TimeTransparency string - -const ( - TransparencyOpaque TimeTransparency = "OPAQUE" // default - TransparencyTransparent TimeTransparency = "TRANSPARENT" -) +func (cb *ComponentBase) SetSummary(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertySummary, s, props...) +} -func (event *VEvent) SetTimeTransparency(v TimeTransparency, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyTransp, string(v), props...) +func (cb *ComponentBase) SetStatus(s ObjectStatus, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyStatus, string(s), props...) } -func (event *VEvent) SetSummary(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertySummary, ToText(s), props...) +func (cb *ComponentBase) SetDescription(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyDescription, s, props...) } -func (event *VEvent) SetStatus(s ObjectStatus, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...) +func (cb *ComponentBase) SetLocation(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyLocation, s, props...) } -func (event *VEvent) SetDescription(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyDescription, ToText(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) SetLocation(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyLocation, ToText(s), props...) +func (cb *ComponentBase) SetURL(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyUrl, 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) SetOrganizer(s string, props ...PropertyParameter) { + if !strings.HasPrefix(s, "mailto:") { + s = "mailto:" + s + } + + cb.SetProperty(ComponentPropertyOrganizer, s, props...) } -func (event *VEvent) SetURL(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyUrl, s, props...) +func (cb *ComponentBase) SetColor(s string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyColor, s, props...) } -func (event *VEvent) SetOrganizer(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyOrganizer, s, props...) +func (cb *ComponentBase) SetClass(c Classification, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyClass, string(c), props...) } -func (event *VEvent) SetColor(s string, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyColor, s, props...) +func (cb *ComponentBase) setPriority(p int, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), props...) } -func (event *VEvent) SetClass(c Classification, props ...PropertyParameter) { - event.SetProperty(ComponentPropertyClass, string(c), props...) +func (cb *ComponentBase) setResources(r string, props ...PropertyParameter) { + cb.SetProperty(ComponentPropertyResources, r, props...) } -func (event *VEvent) AddAttendee(s string, props ...PropertyParameter) { - event.AddProperty(ComponentPropertyAttendee, "mailto:"+s, props...) +func (cb *ComponentBase) AddAttendee(s string, props ...PropertyParameter) { + if !strings.HasPrefix(s, "mailto:") { + s = "mailto:" + s + } + + cb.AddProperty(ComponentPropertyAttendee, 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"), ) } +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 } @@ -353,13 +361,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) } @@ -367,26 +375,30 @@ 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) } 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) { + cb.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) } @@ -394,25 +406,256 @@ func (event *VEvent) Alarms() (r []*VAlarm) { return } +type VEvent struct { + ComponentBase +} + +func (event *VEvent) SerializeTo(w io.Writer) { + event.ComponentBase.serializeThis(w, "VEVENT") +} + +func (event *VEvent) Serialize() string { + b := &bytes.Buffer{} + event.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) 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...) +} + +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...) +} + +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) 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) +} + +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 } -func (c *VTodo) serialize(w io.Writer) { - c.ComponentBase.serializeThis(w, "VTODO") +func (todo *VTodo) SerializeTo(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() } +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) { + props = append(props, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), 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) { + props = append(props, WithValue(string(ValueDataTypeDate))) + todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), props...) +} + +func (todo *VTodo) SetPercentComplete(p int, props ...PropertyParameter) { + todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), 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) 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) +} + +func (todo *VTodo) GetAllDayDueAt() (time.Time, error) { + return todo.getTimeProp(ComponentPropertyDue, true) +} + type VJournal struct { ComponentBase } -func (c *VJournal) serialize(w io.Writer) { +func (c *VJournal) SerializeTo(w io.Writer) { c.ComponentBase.serializeThis(w, "VJOURNAL") } @@ -422,18 +665,74 @@ 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 } 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") +func (c *VBusy) SerializeTo(w io.Writer) { + c.ComponentBase.serializeThis(w, "VFREEBUSY") +} + +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 { @@ -446,10 +745,42 @@ 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") } +func NewTimezone(tzId string) *VTimezone { + e := &VTimezone{ + ComponentBase{ + Properties: []IANAProperty{ + {BaseProperty{IANAToken: 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 } @@ -460,10 +791,30 @@ 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") } +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...) } @@ -482,7 +833,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") } @@ -496,7 +847,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") } @@ -511,7 +862,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) } diff --git a/components_test.go b/components_test.go index d7d8902..ffa7c44 100644 --- a/components_test.go +++ b/components_test.go @@ -94,3 +94,72 @@ 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) + } +} + +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") + } +} + +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 new file mode 100644 index 0000000..dfb34ca --- /dev/null +++ b/errors.go @@ -0,0 +1,9 @@ +package ics + +import "errors" + +var ( + // ErrorPropertyNotFound is the error returned if the requested valid + // property is not set. + ErrorPropertyNotFound = errors.New("property not found") +) diff --git a/go.mod b/go.mod index 8daa44b..8be604a 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +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/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/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/property.go b/property.go index 62418ad..67c3b15 100644 --- a/property.go +++ b/property.go @@ -2,10 +2,12 @@ package ics import ( "bytes" + "errors" "fmt" "io" "log" "regexp" + "sort" "strconv" "strings" "unicode/utf8" @@ -86,10 +88,64 @@ 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) { + default: + fallthrough + 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, PropertyTzurl, PropertyUrl: + return ValueDataTypeUri + + case PropertyGeo: + return ValueDataTypeFloat + + case PropertyPercentComplete, PropertyPriority, PropertyRepeat, PropertySequence: + return ValueDataTypeInteger + + case PropertyCompleted, PropertyDtend, PropertyDue, PropertyDtstart, PropertyRecurrenceId, PropertyExdate, + PropertyRdate, PropertyCreated, PropertyDtstamp, PropertyLastModified: + return ValueDataTypeDateTime + + case PropertyDuration, PropertyTrigger: + return ValueDataTypeDuration + + case PropertyFreebusy: + return ValueDataTypePeriod + + case PropertyTzoffsetfrom, PropertyTzoffsetto: + return ValueDataTypeUtcOffset + + case PropertyAttendee, PropertyOrganizer: + return ValueDataTypeCalAddress + + case PropertyRrule: + return ValueDataTypeRecur + } +} + 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, "=") @@ -98,9 +154,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) } @@ -108,7 +164,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) @@ -194,6 +254,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 @@ -210,6 +273,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 @@ -258,6 +324,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 @@ -288,7 +357,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/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=\\") 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") 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") 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..5dc38ea --- /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..5dc38ea --- /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..b5d73e2 --- /dev/null +++ b/testdata/serialization/expected/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/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 +