diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md
new file mode 100644
index 0000000..d73773d
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.md
@@ -0,0 +1,39 @@
+---
+name: 🐞 Bug
+about: File a bug/issue
+title: '[BUG]
'
+labels: Bug, Needs Triage
+assignees: ''
+
+---
+
+
+
+### Current Behavior:
+
+
+### Expected Behavior:
+
+
+### Steps To Reproduce:
+
+
+### Minimal Example ical extract:
+
+```ical
+BEGIN:VCALENDAR
+....
+```
+
+### Anything else:
+
diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md
new file mode 100644
index 0000000..9933437
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/other.md
@@ -0,0 +1,15 @@
+---
+name: Something else
+about: Any other issue
+title: ''
+labels: Needs Triage
+assignees: ''
+
+---
+
+
+
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
new file mode 100644
index 0000000..58d632e
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -0,0 +1,35 @@
+
+
+# Pull Request Template
+
+## Description
+
+Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
+
+
+
+Fixes # (issue)
+
+## Type of change
+
+Please delete options that are not relevant.
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- This might take a while to be considered
+
+## Must haves:
+
+- [ ] I have commented in hard-to-understand areas and anywhere the function not immediately apparent
+- [ ] I have made corresponding changes to the comments (if any)
+- [ ] My changes generate no new warnings
+- [ ] I have added tests that prove the fix is effective or that my feature works
+- [ ] I have added tests that protects the code from degradation in the future
+
+## Nice to haves:
+
+- [ ] I have added additional function comments to new or existing functions
diff --git a/.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..1eb0e41 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/README.md b/README.md
index 5c4122d..9798ed0 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,18 @@ A ICS / ICal parser and serialiser for Golang.
Because the other libraries didn't quite do what I needed.
Usage, parsing:
-```
+```golang
cal, err := ParseCalendar(strings.NewReader(input))
```
-Creating:
+Usage, parsing from a URL :
+```golang
+ cal, err := ParseCalendar("an-ics-url")
```
+
+Creating:
+```golang
cal := ics.NewCalendar()
cal.SetMethod(ics.MethodRequest)
event := cal.AddEvent(fmt.Sprintf("id@domain", p.SessionKey.IntID()))
diff --git a/calendar.go b/calendar.go
index 5b3d7c4..7d9b2d8 100644
--- a/calendar.go
+++ b/calendar.go
@@ -3,9 +3,13 @@ package ics
import (
"bufio"
"bytes"
+ "context"
"errors"
"fmt"
"io"
+ "net/http"
+ "reflect"
+ "strings"
"time"
)
@@ -26,35 +30,150 @@ 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)
+ ComponentPropertyMethod = ComponentProperty(PropertyMethod)
+ ComponentPropertyRecurrenceId = ComponentProperty(PropertyRecurrenceId)
+ ComponentPropertyDuration = ComponentProperty(PropertyDuration)
+ ComponentPropertyContact = ComponentProperty(PropertyContact)
+ ComponentPropertyRequestStatus = ComponentProperty(PropertyRequestStatus)
+ ComponentPropertyRDate = ComponentProperty(PropertyRdate)
)
+// Required returns the rules from the RFC as to if they are required or not for any particular component type
+// If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference
+// iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
+func (cp ComponentProperty) Required(c Component) bool {
+ // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
+ switch cp {
+ case ComponentPropertyDtstamp, ComponentPropertyUniqueId:
+ switch c.(type) {
+ case *VEvent:
+ return true
+ }
+ case ComponentPropertyDtStart:
+ switch c := c.(type) {
+ case *VEvent:
+ return !c.HasProperty(ComponentPropertyMethod)
+ }
+ }
+ return false
+}
+
+// Exclusive returns the ComponentProperty's using the rules from the RFC as to if one or more existing properties are prohibiting this one
+// If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference
+// iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
+func (cp ComponentProperty) Exclusive(c Component) []ComponentProperty {
+ // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
+ switch cp {
+ case ComponentPropertyDtEnd:
+ switch c := c.(type) {
+ case *VEvent:
+ if c.HasProperty(ComponentPropertyDuration) {
+ return []ComponentProperty{ComponentPropertyDuration}
+ }
+ }
+ case ComponentPropertyDuration:
+ switch c := c.(type) {
+ case *VEvent:
+ if c.HasProperty(ComponentPropertyDtEnd) {
+ return []ComponentProperty{ComponentPropertyDtEnd}
+ }
+ }
+ }
+ return nil
+}
+
+// Singular returns the rules from the RFC as to if the spec states that if "Must not occur more than once"
+// iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
+func (cp ComponentProperty) Singular(c Component) bool {
+ // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
+ switch cp {
+ case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo,
+ ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority,
+ ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp,
+ ComponentPropertyUrl, ComponentPropertyRecurrenceId:
+ switch c.(type) {
+ case *VEvent:
+ return true
+ }
+ }
+ return false
+}
+
+// Optional returns the rules from the RFC as to if the spec states that if these are optional
+// iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
+func (cp ComponentProperty) Optional(c Component) bool {
+ // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
+ switch cp {
+ case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo,
+ ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority,
+ ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp,
+ ComponentPropertyUrl, ComponentPropertyRecurrenceId, ComponentPropertyRrule, ComponentPropertyAttach,
+ ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment,
+ ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo,
+ ComponentPropertyResources, ComponentPropertyRDate:
+ switch c.(type) {
+ case *VEvent:
+ return true
+ }
+ }
+ return false
+}
+
+// Multiple returns the rules from the RFC as to if the spec states explicitly if multiple are allowed
+// iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
+func (cp ComponentProperty) Multiple(c Component) bool {
+ // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
+ switch cp {
+ case ComponentPropertyAttach, ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment,
+ ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo,
+ ComponentPropertyResources, ComponentPropertyRDate:
+ switch c.(type) {
+ case *VEvent:
+ return true
+ }
+ }
+ return false
+}
+
+func ComponentPropertyExtended(s string) ComponentProperty {
+ return ComponentProperty("X-" + strings.TrimPrefix("X-", s))
+}
+
type Property string
const (
@@ -118,6 +237,14 @@ const (
type Parameter string
+func (p Parameter) IsQuoted() bool {
+ switch p {
+ case ParameterAltrep:
+ return true
+ }
+ return false
+}
+
const (
ParameterAltrep Parameter = "ALTREP"
ParameterCn Parameter = "CN"
@@ -170,7 +297,7 @@ const (
CalendarUserTypeUnknown CalendarUserType = "UNKNOWN"
)
-func (cut CalendarUserType) KeyValue(s ...interface{}) (string, []string) {
+func (cut CalendarUserType) KeyValue(_ ...interface{}) (string, []string) {
return string(ParameterCutype), []string{string(cut)}
}
@@ -195,7 +322,7 @@ const (
ParticipationStatusInProcess ParticipationStatus = "IN-PROCESS"
)
-func (ps ParticipationStatus) KeyValue(s ...interface{}) (string, []string) {
+func (ps ParticipationStatus) KeyValue(_ ...interface{}) (string, []string) {
return string(ParameterParticipationStatus), []string{string(ps)}
}
@@ -212,8 +339,8 @@ const (
ObjectStatusFinal ObjectStatus = "FINAL"
)
-func (ps ObjectStatus) KeyValue(s ...interface{}) (string, []string) {
- return string(PropertyStatus), []string{ToText(string(ps))}
+func (ps ObjectStatus) KeyValue(_ ...interface{}) (string, []string) {
+ return string(PropertyStatus), []string{string(ps)}
}
type RelationshipType string
@@ -233,7 +360,7 @@ const (
ParticipationRoleNonParticipant ParticipationRole = "NON-PARTICIPANT"
)
-func (pr ParticipationRole) KeyValue(s ...interface{}) (string, []string) {
+func (pr ParticipationRole) KeyValue(_ ...interface{}) (string, []string) {
return string(ParameterRole), []string{string(pr)}
}
@@ -290,102 +417,149 @@ func NewCalendarFor(service string) *Calendar {
return c
}
-func (calendar *Calendar) Serialize() string {
+func (cal *Calendar) Serialize(ops ...any) string {
b := bytes.NewBufferString("")
// We are intentionally ignoring the return value. _ used to communicate this to lint.
- _ = calendar.SerializeTo(b)
+ _ = cal.SerializeTo(b, ops...)
return b.String()
}
-func (calendar *Calendar) SerializeTo(w io.Writer) error {
- fmt.Fprint(w, "BEGIN:VCALENDAR", "\r\n")
- for _, p := range calendar.CalendarProperties {
- p.serialize(w)
+type WithLineLength int
+type WithNewLine string
+
+func (cal *Calendar) SerializeTo(w io.Writer, ops ...any) error {
+ serializeConfig, err := parseSerializeOps(ops)
+ if err != nil {
+ return err
+ }
+ _, _ = fmt.Fprint(w, "BEGIN:VCALENDAR", serializeConfig.NewLine)
+ for _, p := range cal.CalendarProperties {
+ err := p.serialize(w, serializeConfig)
+ if err != nil {
+ return err
+ }
}
- for _, c := range calendar.Components {
- c.serialize(w)
+ for _, c := range cal.Components {
+ err := c.SerializeTo(w, serializeConfig)
+ if err != nil {
+ return err
+ }
}
- fmt.Fprint(w, "END:VCALENDAR", "\r\n")
+ _, _ = fmt.Fprint(w, "END:VCALENDAR", serializeConfig.NewLine)
return nil
}
-func (calendar *Calendar) SetMethod(method Method, props ...PropertyParameter) {
- calendar.setProperty(PropertyMethod, ToText(string(method)), props...)
+type SerializationConfiguration struct {
+ MaxLength int
+ NewLine string
+ PropertyMaxLength int
+}
+
+func parseSerializeOps(ops []any) (*SerializationConfiguration, error) {
+ serializeConfig := defaultSerializationOptions()
+ for opi, op := range ops {
+ switch op := op.(type) {
+ case WithLineLength:
+ serializeConfig.MaxLength = int(op)
+ case WithNewLine:
+ serializeConfig.NewLine = string(op)
+ case *SerializationConfiguration:
+ return op, nil
+ case error:
+ return nil, op
+ default:
+ return nil, fmt.Errorf("unknown op %d of type %s", opi, reflect.TypeOf(op))
+ }
+ }
+ return serializeConfig, nil
+}
+
+func defaultSerializationOptions() *SerializationConfiguration {
+ serializeConfig := &SerializationConfiguration{
+ MaxLength: 75,
+ PropertyMaxLength: 75,
+ NewLine: string(NewLine),
+ }
+ return serializeConfig
+}
+
+func (cal *Calendar) SetMethod(method Method, params ...PropertyParameter) {
+ cal.setProperty(PropertyMethod, string(method), params...)
}
-func (calendar *Calendar) SetXPublishedTTL(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyXPublishedTTL, string(s), props...)
+func (cal *Calendar) SetXPublishedTTL(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyXPublishedTTL, s, params...)
}
-func (calendar *Calendar) SetVersion(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyVersion, ToText(s), props...)
+func (cal *Calendar) SetVersion(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyVersion, s, params...)
}
-func (calendar *Calendar) SetProductId(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyProductId, ToText(s), props...)
+func (cal *Calendar) SetProductId(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyProductId, s, params...)
}
-func (calendar *Calendar) SetName(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyName, string(s), props...)
- calendar.setProperty(PropertyXWRCalName, string(s), props...)
+func (cal *Calendar) SetName(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyName, s, params...)
+ cal.setProperty(PropertyXWRCalName, s, params...)
}
-func (calendar *Calendar) SetColor(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyColor, string(s), props...)
+func (cal *Calendar) SetColor(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyColor, s, params...)
}
-func (calendar *Calendar) SetXWRCalName(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyXWRCalName, string(s), props...)
+func (cal *Calendar) SetXWRCalName(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyXWRCalName, s, params...)
}
-func (calendar *Calendar) SetXWRCalDesc(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyXWRCalDesc, string(s), props...)
+func (cal *Calendar) SetXWRCalDesc(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyXWRCalDesc, s, params...)
}
-func (calendar *Calendar) SetXWRTimezone(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyXWRTimezone, string(s), props...)
+func (cal *Calendar) SetXWRTimezone(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyXWRTimezone, s, params...)
}
-func (calendar *Calendar) SetXWRCalID(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyXWRCalID, string(s), props...)
+func (cal *Calendar) SetXWRCalID(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyXWRCalID, s, params...)
}
-func (calendar *Calendar) SetDescription(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyDescription, ToText(s), props...)
+func (cal *Calendar) SetDescription(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyDescription, s, params...)
}
-func (calendar *Calendar) SetLastModified(t time.Time, props ...PropertyParameter) {
- calendar.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cal *Calendar) SetLastModified(t time.Time, params ...PropertyParameter) {
+ cal.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (calendar *Calendar) SetRefreshInterval(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyRefreshInterval, string(s), props...)
+func (cal *Calendar) SetRefreshInterval(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyRefreshInterval, s, params...)
}
-func (calendar *Calendar) SetCalscale(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyCalscale, string(s), props...)
+func (cal *Calendar) SetCalscale(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyCalscale, s, params...)
}
-func (calendar *Calendar) SetUrl(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyUrl, string(s), props...)
+func (cal *Calendar) SetUrl(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyUrl, s, params...)
}
-func (calendar *Calendar) SetTzid(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyTzid, string(s), props...)
+func (cal *Calendar) SetTzid(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyTzid, s, params...)
}
-func (calendar *Calendar) SetTimezoneId(s string, props ...PropertyParameter) {
- calendar.setProperty(PropertyTimezoneId, string(s), props...)
+func (cal *Calendar) SetTimezoneId(s string, params ...PropertyParameter) {
+ cal.setProperty(PropertyTimezoneId, s, params...)
}
-func (calendar *Calendar) setProperty(property Property, value string, props ...PropertyParameter) {
- for i := range calendar.CalendarProperties {
- if calendar.CalendarProperties[i].IANAToken == string(property) {
- calendar.CalendarProperties[i].Value = value
- calendar.CalendarProperties[i].ICalParameters = map[string][]string{}
- for _, p := range props {
+func (cal *Calendar) setProperty(property Property, value string, params ...PropertyParameter) {
+ for i := range cal.CalendarProperties {
+ if cal.CalendarProperties[i].IANAToken == string(property) {
+ cal.CalendarProperties[i].Value = value
+ cal.CalendarProperties[i].ICalParameters = map[string][]string{}
+ for _, p := range params {
k, v := p.KeyValue()
- calendar.CalendarProperties[i].ICalParameters[k] = v
+ cal.CalendarProperties[i].ICalParameters[k] = v
}
return
}
@@ -397,22 +571,11 @@ func (calendar *Calendar) setProperty(property Property, value string, props ...
ICalParameters: map[string][]string{},
},
}
- for _, p := range props {
+ for _, p := range params {
k, v := p.KeyValue()
r.ICalParameters[k] = v
}
- calendar.CalendarProperties = append(calendar.CalendarProperties, r)
-}
-
-func NewEvent(uniqueId string) *VEvent {
- e := &VEvent{
- ComponentBase{
- Properties: []IANAProperty{
- {BaseProperty{IANAToken: ToText(string(ComponentPropertyUniqueId)), Value: uniqueId}},
- },
- },
- }
- return e
+ cal.CalendarProperties = append(cal.CalendarProperties, r)
}
func (calendar *Calendar) AddEvent(id string) *VEvent {
@@ -436,6 +599,87 @@ func (calendar *Calendar) Events() (r []*VEvent) {
return
}
+func (calendar *Calendar) RemoveEvent(id string) {
+ for i := range calendar.Components {
+ switch event := calendar.Components[i].(type) {
+ case *VEvent:
+ if event.Id() == id {
+ if len(calendar.Components) > i+1 {
+ calendar.Components = append(calendar.Components[:i], calendar.Components[i+1:]...)
+ } else {
+ calendar.Components = calendar.Components[:i]
+ }
+ return
+ }
+ }
+ }
+}
+
+func WithCustomClient(client *http.Client) *http.Client {
+ return client
+}
+
+func WithCustomRequest(request *http.Request) *http.Request {
+ return request
+}
+
+func ParseCalendarFromUrl(url string, opts ...any) (*Calendar, error) {
+ var ctx context.Context
+ var req *http.Request
+ var client HttpClientLike = http.DefaultClient
+ for opti, opt := range opts {
+ switch opt := opt.(type) {
+ case *http.Client:
+ client = opt
+ case HttpClientLike:
+ client = opt
+ case func() *http.Client:
+ client = opt()
+ case *http.Request:
+ req = opt
+ case func() *http.Request:
+ req = opt()
+ case context.Context:
+ ctx = opt
+ case func() context.Context:
+ ctx = opt()
+ default:
+ return nil, fmt.Errorf("unknown optional argument %d on ParseCalendarFromUrl: %s", opti, reflect.TypeOf(opt))
+ }
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if req == nil {
+ var err error
+ req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating http request: %w", err)
+ }
+ }
+ return parseCalendarFromHttpRequest(client, req)
+}
+
+type HttpClientLike interface {
+ Do(req *http.Request) (*http.Response, error)
+}
+
+func parseCalendarFromHttpRequest(client HttpClientLike, request *http.Request) (*Calendar, error) {
+ resp, err := client.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("http request: %w", err)
+ }
+ defer func(closer io.ReadCloser) {
+ if derr := closer.Close(); derr != nil && err == nil {
+ err = fmt.Errorf("http request close: %w", derr)
+ }
+ }(resp.Body)
+ var cal *Calendar
+ cal, err = ParseCalendar(resp.Body)
+ // This allows the defer func to change the error
+ return cal, err
+}
+
func ParseCalendar(r io.Reader) (*Calendar, error) {
state := "begin"
c := &Calendar{}
@@ -444,8 +688,8 @@ func ParseCalendar(r io.Reader) (*Calendar, error) {
for ln := 0; cont; ln++ {
l, err := cs.ReadLine()
if err != nil {
- switch err {
- case io.EOF:
+ switch {
+ case errors.Is(err, io.EOF):
cont = false
default:
return c, err
@@ -456,10 +700,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) {
}
line, err := ParseProperty(*l)
if err != nil {
- return nil, fmt.Errorf("parsing line %d: %w", ln, err)
+ return nil, fmt.Errorf("%w %d: %w", ErrParsingLine, ln, err)
}
if line == nil {
- return nil, fmt.Errorf("parsing calendar line %d", ln)
+ return nil, fmt.Errorf("%w %d", ErrParsingCalendarLine, ln)
}
switch state {
case "begin":
@@ -469,10 +713,10 @@ func ParseCalendar(r io.Reader) (*Calendar, error) {
case "VCALENDAR":
state = "properties"
default:
- return nil, errors.New("malformed calendar; expected a vcalendar")
+ return nil, ErrMalformedCalendarExpectedVCalendar
}
default:
- return nil, errors.New("malformed calendar; expected begin")
+ return nil, ErrMalformedCalendarExpectedBegin
}
case "properties":
switch line.IANAToken {
@@ -481,7 +725,7 @@ func ParseCalendar(r io.Reader) (*Calendar, error) {
case "VCALENDAR":
state = "end"
default:
- return nil, errors.New("malformed calendar; expected end")
+ return nil, ErrMalformedCalendarExpectedEnd
}
case "BEGIN":
state = "components"
@@ -499,23 +743,23 @@ func ParseCalendar(r io.Reader) (*Calendar, error) {
case "VCALENDAR":
state = "end"
default:
- return nil, errors.New("malformed calendar; expected end")
+ return nil, fmt.Errorf("%w at '%s': %w", ErrMalformedCalendarExpectedEnd, line.IANAToken, err)
}
case "BEGIN":
co, err := GeneralParseComponent(cs, line)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("%w at '%s': %w", ErrMalformedCalendarExpectedEnd, line.IANAToken, err)
}
if co != nil {
c.Components = append(c.Components, co)
}
default:
- return nil, errors.New("malformed calendar; expected begin or end")
+ return nil, fmt.Errorf("%w at '%s'", ErrMalformedCalendarExpectedBeginOrEnd, line.IANAToken)
}
case "end":
- return nil, errors.New("malformed calendar; unexpected end")
+ return nil, ErrMalformedCalendarUnexpectedEnd
default:
- return nil, errors.New("malformed calendar; bad state")
+ return nil, ErrMalformedCalendarBadState
}
}
return c, nil
@@ -540,46 +784,55 @@ func (cs *CalendarStream) ReadLine() (*ContentLine, error) {
for c {
var b []byte
b, err = cs.b.ReadBytes('\n')
- if len(b) == 0 {
+ switch {
+ case len(b) == 0:
if err == nil {
continue
} else {
c = false
}
- } else if b[len(b)-1] == '\n' {
+ case b[len(b)-1] == '\n':
o := 1
if len(b) > 1 && b[len(b)-2] == '\r' {
o = 2
}
p, err := cs.b.Peek(1)
r = append(r, b[:len(b)-o]...)
- if err == io.EOF {
+ if errors.Is(err, io.EOF) {
c = false
}
- if len(p) == 0 {
+ switch {
+ case len(p) == 0:
c = false
- } else if p[0] == ' ' || p[0] == '\t' {
- cs.b.Discard(1) // nolint:errcheck
- } else {
+ case p[0] == ' ' || p[0] == '\t':
+ _, _ = cs.b.Discard(1) // nolint:errcheck
+ default:
c = false
}
- } else {
+ default:
r = append(r, b...)
}
- switch err {
- case nil:
+ switch {
+ case err == nil:
if len(r) == 0 {
c = true
}
- case io.EOF:
+ case errors.Is(err, io.EOF):
c = false
default:
+ // This must be as a result of boxing?
+ if err != nil {
+ err = fmt.Errorf("readline: %w", err)
+ }
return nil, err
}
}
if len(r) == 0 && err != nil {
- return nil, err
+ return nil, fmt.Errorf("readline: %w", err)
}
cl := ContentLine(r)
+ if err != nil {
+ err = fmt.Errorf("readline: %w", err)
+ }
return &cl, err
}
diff --git a/calendar_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..a51db82
--- /dev/null
+++ b/calendar_serialization_test.go
@@ -0,0 +1,79 @@
+//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 {
+ fp := filepath.Join(testDir, filename)
+ t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", fp), func(t *testing.T) {
+ //given
+ originalSeriailizedCal, err := os.ReadFile(fp)
+ require.NoError(t, err)
+
+ //when
+ deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal))
+ require.NoError(t, err)
+ serializedCal := deserializedCal.Serialize(WithNewLineWindows)
+
+ //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 abbd39b..9544c67 100644
--- a/calendar_test.go
+++ b/calendar_test.go
@@ -1,10 +1,15 @@
package ics
import (
+ "errors"
"github.com/stretchr/testify/assert"
+ "bytes"
+ "embed"
+ _ "embed"
+ "github.com/google/go-cmp/cmp"
"io"
- "io/ioutil"
- "os"
+ "io/fs"
+ "net/http"
"path/filepath"
"regexp"
"strings"
@@ -13,13 +18,18 @@ import (
"unicode/utf8"
)
+var (
+ //go:embed testdata/*
+ TestData embed.FS
+)
+
func TestTimeParsing(t *testing.T) {
- calFile, err := os.OpenFile("./testdata/timeparsing.ics", os.O_RDONLY, 0400)
+ calFile, err := TestData.Open("testdata/timeparsing.ics")
if err != nil {
t.Errorf("read file: %v", err)
}
cal, err := ParseCalendar(calFile)
- if err != nil {
+ if err != nil && !errors.Is(err, io.EOF) {
t.Errorf("parse calendar: %v", err)
}
@@ -128,8 +138,8 @@ CLASS:PUBLIC
for i := 0; cont; i++ {
l, err := c.ReadLine()
if err != nil {
- switch err {
- case io.EOF:
+ switch {
+ case errors.Is(err, io.EOF):
cont = false
default:
t.Logf("Unknown error; %v", err)
@@ -138,7 +148,7 @@ CLASS:PUBLIC
}
}
if l == nil {
- if err == io.EOF && i == len(expected) {
+ if errors.Is(err, io.EOF) && i == len(expected) {
cont = false
} else {
t.Logf("Nil response...")
@@ -162,19 +172,23 @@ CLASS:PUBLIC
func TestRfc5545Sec4Examples(t *testing.T) {
rnReplace := regexp.MustCompile("\r?\n")
- err := filepath.Walk("./testdata/rfc5545sec4/", func(path string, info os.FileInfo, _ error) error {
+ err := fs.WalkDir(TestData, "testdata/rfc5545sec4", func(path string, info fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
if info.IsDir() {
return nil
}
- inputBytes, err := ioutil.ReadFile(path)
+ inputBytes, err := fs.ReadFile(TestData, path)
if err != nil {
return err
}
input := rnReplace.ReplaceAllString(string(inputBytes), "\r\n")
structure, err := ParseCalendar(strings.NewReader(input))
- if assert.Nil(t, err, path) {
+ if err != nil && !errors.Is(err, io.EOF) {
+ assert.Nil(t, err, path)
// This should fail as the sample data doesn't conform to https://tools.ietf.org/html/rfc5545#page-45
// Probably due to RFC width guides
assert.NotNil(t, structure)
@@ -260,7 +274,7 @@ END:VCALENDAR
c := NewCalendar()
c.SetDescription(tc.input)
// we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
- text := strings.Replace(c.Serialize(), "\r\n", "\n", -1)
+ text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n")
assert.Equal(t, tc.output, text)
assert.True(t, utf8.ValidString(text), "Serialized .ics calendar isn't valid UTF-8 string")
@@ -315,6 +329,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
`,
},
}
@@ -322,12 +386,12 @@ END:VCALENDAR
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c, err := ParseCalendar(strings.NewReader(tc.input))
- if !assert.NoError(t, err) {
+ if !errors.Is(err, io.EOF) && !assert.NoError(t, err) {
return
}
// we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
- text := strings.Replace(c.Serialize(), "\r\n", "\n", -1)
+ text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n")
if !assert.Equal(t, tc.output, text) {
return
}
@@ -336,19 +400,19 @@ END:VCALENDAR
}
func TestIssue52(t *testing.T) {
- err := filepath.Walk("./testdata/issue52/", func(path string, info os.FileInfo, _ error) error {
+ err := fs.WalkDir(TestData, "testdata/issue52", func(path string, info fs.DirEntry, _ error) error {
if info.IsDir() {
return nil
}
_, fn := filepath.Split(path)
t.Run(fn, func(t *testing.T) {
- f, err := os.Open(path)
- if err != nil {
+ f, err := TestData.Open(path)
+ if err != nil && errors.Is(err, io.EOF) {
t.Fatalf("Error reading file: %s", err)
}
defer f.Close()
- if _, err := ParseCalendar(f); err != nil {
+ if _, err := ParseCalendar(f); err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("Error parsing file: %s", err)
}
@@ -360,3 +424,73 @@ func TestIssue52(t *testing.T) {
t.Fatalf("cannot read test directory: %v", err)
}
}
+
+func TestIssue97(t *testing.T) {
+ err := fs.WalkDir(TestData, "testdata/issue97", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if !strings.HasSuffix(d.Name(), ".ics") && !strings.HasSuffix(d.Name(), ".ics_disabled") {
+ return nil
+ }
+ t.Run(path, func(t *testing.T) {
+ if strings.HasSuffix(d.Name(), ".ics_disabled") {
+ t.Skipf("Test disabled")
+ }
+ b, err := TestData.ReadFile(path)
+ if err != nil {
+ t.Fatalf("Error reading file: %s", err)
+ }
+ ics, err := ParseCalendar(bytes.NewReader(b))
+ if err != nil {
+ t.Fatalf("Error parsing file: %s", err)
+ }
+
+ got := ics.Serialize(WithLineLength(74))
+ if diff := cmp.Diff(string(b), got, cmp.Transformer("ToUnixText", func(a string) string {
+ return strings.ReplaceAll(a, "\r\n", "\n")
+ })); diff != "" {
+ t.Errorf("ParseCalendar() mismatch (-want +got):\n%s", diff)
+ t.Errorf("Complete got:\b%s", got)
+ }
+ })
+ return nil
+ })
+
+ if err != nil {
+ t.Fatalf("cannot read test directory: %v", err)
+ }
+}
+
+type MockHttpClient struct {
+ Response *http.Response
+ Error error
+}
+
+func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) {
+ return m.Response, m.Error
+}
+
+var (
+ _ HttpClientLike = &MockHttpClient{}
+ //go:embed "testdata/rfc5545sec4/input1.ics"
+ input1TestData []byte
+)
+
+func TestIssue77(t *testing.T) {
+ url := "https://proseconsult.umontpellier.fr/jsp/custom/modules/plannings/direct_cal.jsp?data=58c99062bab31d256bee14356aca3f2423c0f022cb9660eba051b2653be722c4c7f281e4e3ad06b85d3374100ac416a4dc5c094f7d1a811b903031bde802c7f50e0bd1077f9461bed8f9a32b516a3c63525f110c026ed6da86f487dd451ca812c1c60bb40b1502b6511435cf9908feb2166c54e36382c1aa3eb0ff5cb8980cdb,1"
+
+ _, err := ParseCalendarFromUrl(url, &MockHttpClient{
+ Response: &http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewReader(input1TestData)),
+ },
+ })
+
+ if err != nil {
+ t.Fatalf("Error reading file: %s", err)
+ }
+}
diff --git a/cmd/issues/test97_1/main.go b/cmd/issues/test97_1/main.go
new file mode 100644
index 0000000..3eceab4
--- /dev/null
+++ b/cmd/issues/test97_1/main.go
@@ -0,0 +1,34 @@
+package main
+
+import (
+ "fmt"
+ ics "github.com/arran4/golang-ical"
+ "net/url"
+)
+
+func main() {
+ i := ics.NewCalendarFor("Mozilla.org/NONSGML Mozilla Calendar V1.1")
+ tz := i.AddTimezone("Europe/Berlin")
+ tz.AddProperty(ics.ComponentPropertyExtended("TZINFO"), "Europe/Berlin[2024a]")
+ tzstd := tz.AddStandard()
+ tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetto), "+010000")
+ tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetfrom), "+005328")
+ tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzname), "Europe/Berlin(STD)")
+ tzstd.AddProperty(ics.ComponentProperty(ics.PropertyDtstart), "18930401T000000")
+ tzstd.AddProperty(ics.ComponentProperty(ics.PropertyRdate), "18930401T000000")
+ vEvent := i.AddEvent("d23cef0d-9e58-43c4-9391-5ad8483ca346")
+ vEvent.AddProperty(ics.ComponentPropertyCreated, "20240929T120640Z")
+ vEvent.AddProperty(ics.ComponentPropertyLastModified, "20240929T120731Z")
+ vEvent.AddProperty(ics.ComponentPropertyDtstamp, "20240929T120731Z")
+ vEvent.AddProperty(ics.ComponentPropertySummary, "Test Event")
+ vEvent.AddProperty(ics.ComponentPropertyDtStart, "20240929T144500", ics.WithTZID("Europe/Berlin"))
+ vEvent.AddProperty(ics.ComponentPropertyDtEnd, "20240929T154500", ics.WithTZID("Europe/Berlin"))
+ vEvent.AddProperty(ics.ComponentPropertyTransp, "OPAQUE")
+ vEvent.AddProperty(ics.ComponentPropertyLocation, "Github")
+ uri := &url.URL{
+ Scheme: "data",
+ Opaque: "text/html,I%20want%20a%20custom%20linkout%20for%20Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20%3Ca%20href%3D%22https%3A%2F%2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22%3EIssue%3C%2Fa%3E.",
+ }
+ vEvent.AddProperty(ics.ComponentPropertyDescription, "I want a custom linkout for Thunderbird.\nThis is the Github Issue.", ics.WithAlternativeRepresentation(uri))
+ fmt.Println(i.Serialize())
+}
diff --git a/components.go b/components.go
index b038448..d71dc52 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, serialConfig *SerializationConfiguration) error
}
+var (
+ _ Component = (*VEvent)(nil)
+ _ Component = (*VTodo)(nil)
+ _ Component = (*VBusy)(nil)
+ _ Component = (*VJournal)(nil)
+)
+
type ComponentBase struct {
Properties []IANAProperty
Components []Component
@@ -30,17 +42,35 @@ 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 {
- p.serialize(writer)
+
+func (cb *ComponentBase) serializeThis(writer io.Writer, componentType ComponentType, serialConfig *SerializationConfiguration) error {
+ _, _ = fmt.Fprint(writer, "BEGIN:"+componentType, serialConfig.NewLine)
+ for _, p := range cb.Properties {
+ err := p.serialize(writer, serialConfig)
+ if err != nil {
+ return err
+ }
+ }
+ for _, c := range cb.Components {
+ err := c.SerializeTo(writer, serialConfig)
+ if err != nil {
+ return err
+ }
}
- for _, c := range base.Components {
- c.serialize(writer)
+ _, err := fmt.Fprint(writer, "END:"+componentType, serialConfig.NewLine)
+ return err
+}
+
+func NewComponent(uniqueId string) ComponentBase {
+ return ComponentBase{
+ Properties: []IANAProperty{
+ {BaseProperty{IANAToken: string(ComponentPropertyUniqueId), Value: uniqueId}},
+ },
}
- fmt.Fprint(writer, "END:"+componentType, "\r\n")
}
+// GetProperty returns the first match for the particular property you're after. Please consider using:
+// ComponentProperty.Required to determine if GetProperty or GetProperties is more appropriate.
func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAProperty {
for i := range cb.Properties {
if cb.Properties[i].IANAToken == string(componentProperty) {
@@ -50,22 +80,58 @@ func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAP
return nil
}
-func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, props ...PropertyParameter) {
+// GetProperties returns all matches for the particular property you're after. Please consider using:
+// ComponentProperty.Singular/ComponentProperty.Multiple to determine if GetProperty or GetProperties is more appropriate.
+func (cb *ComponentBase) GetProperties(componentProperty ComponentProperty) []*IANAProperty {
+ var result []*IANAProperty
+ for i := range cb.Properties {
+ if cb.Properties[i].IANAToken == string(componentProperty) {
+ result = append(result, &cb.Properties[i])
+ }
+ }
+ return result
+}
+
+// HasProperty returns true if a component property is in the component.
+func (cb *ComponentBase) HasProperty(componentProperty ComponentProperty) bool {
+ for i := range cb.Properties {
+ if cb.Properties[i].IANAToken == string(componentProperty) {
+ return true
+ }
+ }
+ return false
+}
+
+// SetProperty replaces the first match for the particular property you're setting, otherwise adds it. Please consider using:
+// ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is
+// more appropriate.
+func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, params ...PropertyParameter) {
for i := range cb.Properties {
if cb.Properties[i].IANAToken == string(property) {
cb.Properties[i].Value = value
cb.Properties[i].ICalParameters = map[string][]string{}
- for _, p := range props {
+ for _, p := range params {
k, v := p.KeyValue()
cb.Properties[i].ICalParameters[k] = v
}
return
}
}
- cb.AddProperty(property, value, props...)
+ cb.AddProperty(property, value, params...)
+}
+
+// ReplaceProperty replaces all matches of the particular property you're setting, otherwise adds it. Returns a slice
+// of removed properties. Please consider using:
+// ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is
+// more appropriate.
+func (cb *ComponentBase) ReplaceProperty(property ComponentProperty, value string, params ...PropertyParameter) []IANAProperty {
+ removed := cb.RemoveProperty(property)
+ cb.AddProperty(property, value, params...)
+ return removed
}
-func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, props ...PropertyParameter) {
+// AddProperty appends a property
+func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, params ...PropertyParameter) {
r := IANAProperty{
BaseProperty{
IANAToken: string(property),
@@ -73,25 +139,51 @@ func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, p
ICalParameters: map[string][]string{},
},
}
- for _, p := range props {
+ for _, p := range params {
k, v := p.KeyValue()
r.ICalParameters[k] = v
}
cb.Properties = append(cb.Properties, r)
}
-type VEvent struct {
- ComponentBase
+// RemoveProperty removes from the component all properties that is of a particular property type, returning an slice of
+// removed entities
+func (cb *ComponentBase) RemoveProperty(removeProp ComponentProperty) []IANAProperty {
+ var keptProperties []IANAProperty
+ var removedProperties []IANAProperty
+ for i := range cb.Properties {
+ if cb.Properties[i].IANAToken != string(removeProp) {
+ keptProperties = append(keptProperties, cb.Properties[i])
+ } else {
+ removedProperties = append(removedProperties, cb.Properties[i])
+ }
+ }
+ cb.Properties = keptProperties
+ return removedProperties
}
-func (c *VEvent) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VEVENT")
+// RemovePropertyByValue removes from the component all properties that has a particular property type and value,
+// return a count of removed properties
+func (cb *ComponentBase) RemovePropertyByValue(removeProp ComponentProperty, value string) []IANAProperty {
+ return cb.RemovePropertyByFunc(removeProp, func(p IANAProperty) bool {
+ return p.Value == value
+ })
}
-func (c *VEvent) Serialize() string {
- b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VEVENT")
- return b.String()
+// RemovePropertyByFunc removes from the component all properties that has a particular property type and the function
+// remove returns true for
+func (cb *ComponentBase) RemovePropertyByFunc(removeProp ComponentProperty, remove func(p IANAProperty) bool) []IANAProperty {
+ var keptProperties []IANAProperty
+ var removedProperties []IANAProperty
+ for i := range cb.Properties {
+ if cb.Properties[i].IANAToken != string(removeProp) && remove(cb.Properties[i]) {
+ keptProperties = append(keptProperties, cb.Properties[i])
+ } else {
+ removedProperties = append(removedProperties, cb.Properties[i])
+ }
+ }
+ cb.Properties = keptProperties
+ return removedProperties
}
const (
@@ -101,42 +193,46 @@ const (
icalDateFormatLocal = "20060102"
)
-var (
- timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$")
-)
+var timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$")
-func (event *VEvent) SetCreatedTime(t time.Time, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cb *ComponentBase) SetCreatedTime(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (event *VEvent) SetDtStampTime(t time.Time, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cb *ComponentBase) SetDtStampTime(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (event *VEvent) SetModifiedAt(t time.Time, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cb *ComponentBase) SetModifiedAt(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (event *VEvent) SetSequence(seq int, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), props...)
+func (cb *ComponentBase) SetSequence(seq int, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), params...)
}
-func (event *VEvent) SetStartAt(t time.Time, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cb *ComponentBase) SetStartAt(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (event *VEvent) SetAllDayStartAt(t time.Time, props ...PropertyParameter) {
- props = append(props, WithValue(string(ValueDataTypeDate)))
- event.SetProperty(ComponentPropertyDtStart, t.Format(icalDateFormatLocal), props...)
+func (cb *ComponentBase) SetAllDayStartAt(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(
+ ComponentPropertyDtStart,
+ t.Format(icalDateFormatLocal),
+ append(params, WithValue(string(ValueDataTypeDate)))...,
+ )
}
-func (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...)
+func (cb *ComponentBase) SetEndAt(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), params...)
}
-func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) {
- props = append(props, WithValue(string(ValueDataTypeDate)))
- event.SetProperty(ComponentPropertyDtEnd, t.Format(icalDateFormatLocal), props...)
+func (cb *ComponentBase) SetAllDayEndAt(t time.Time, params ...PropertyParameter) {
+ cb.SetProperty(
+ ComponentPropertyDtEnd,
+ t.Format(icalDateFormatLocal),
+ append(params, WithValue(string(ValueDataTypeDate)))...,
+ )
}
// SetDuration updates the duration of an event.
@@ -144,31 +240,50 @@ func (event *VEvent) SetAllDayEndAt(t time.Time, props ...PropertyParameter) {
// The duration defines the length of a event relative to start or end time.
//
// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected.
-func (event *VEvent) SetDuration(d time.Duration) error {
- t, err := event.GetStartAt()
- if err == nil {
- event.SetEndAt(t.Add(d))
- return nil
- } else {
- t, err = event.GetEndAt()
+func (cb *ComponentBase) SetDuration(d time.Duration) error {
+ startProp := cb.GetProperty(ComponentPropertyDtStart)
+ if startProp != nil {
+ t, err := cb.GetStartAt()
if err == nil {
- event.SetStartAt(t.Add(-d))
+ v, _ := startProp.parameterValue(ParameterValue)
+ if v == string(ValueDataTypeDate) {
+ cb.SetAllDayEndAt(t.Add(d))
+ } else {
+ cb.SetEndAt(t.Add(d))
+ }
+ return nil
+ }
+ }
+ endProp := cb.GetProperty(ComponentPropertyDtEnd)
+ if endProp != nil {
+ t, err := cb.GetEndAt()
+ if err == nil {
+ v, _ := endProp.parameterValue(ParameterValue)
+ if v == string(ValueDataTypeDate) {
+ cb.SetAllDayStartAt(t.Add(-d))
+ } else {
+ cb.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) GetEndAt() (time.Time, error) {
+ return cb.getTimeProp(ComponentPropertyDtEnd, false)
+}
+
+func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) {
+ timeProp := cb.GetProperty(componentProperty)
if timeProp == nil {
- return time.Time{}, errors.New("property not found")
+ return time.Time{}, fmt.Errorf("%w: %s", ErrPropertyNotFound, componentProperty)
}
timeVal := timeProp.BaseProperty.Value
matched := timeStampVariations.FindStringSubmatch(timeVal)
if matched == nil {
- return time.Time{}, fmt.Errorf("time value not matched, got '%s'", timeVal)
+ return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueNotMatched, timeVal)
}
tOrZGrp := matched[2]
zGrp := matched[4]
@@ -179,7 +294,7 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD
var propLoc *time.Location
if tzIdOk {
if len(tzId) != 1 {
- return time.Time{}, errors.New("expected only one TZID")
+ return time.Time{}, ErrExpectedOneTZID
}
var tzErr error
propLoc, tzErr = time.LoadLocation(tzId[0])
@@ -202,20 +317,21 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD
}
}
- return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal)
+ return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButUnsupportedAllDayTimeStamp, timeVal)
}
- if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z" {
+ switch {
+ case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z":
return time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC)
- } else if grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "" {
+ case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "":
if propLoc == nil {
return time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local)
} else {
return time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc)
}
- } else if grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "" {
+ case grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "":
return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC)
- } else if grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "" {
+ case grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "":
if propLoc == nil {
return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local)
} else {
@@ -223,281 +339,653 @@ func (event *VEvent) getTimeProp(componentProperty ComponentProperty, expectAllD
}
}
- return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal)
+ return time.Time{}, fmt.Errorf("%w, got '%s'", ErrTimeValueMatchedButNotSupported, 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, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertySummary, s, params...)
+}
-func (event *VEvent) SetTimeTransparency(v TimeTransparency, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyTransp, string(v), props...)
+func (cb *ComponentBase) SetStatus(s ObjectStatus, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyStatus, string(s), params...)
}
-func (event *VEvent) SetSummary(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertySummary, ToText(s), props...)
+func (cb *ComponentBase) SetDescription(s string, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyDescription, s, params...)
}
-func (event *VEvent) SetStatus(s ObjectStatus, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyStatus, ToText(string(s)), props...)
+func (cb *ComponentBase) SetLocation(s string, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyLocation, s, params...)
}
-func (event *VEvent) SetDescription(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyDescription, ToText(s), props...)
+func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), params...)
}
-func (event *VEvent) SetLocation(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyLocation, ToText(s), props...)
+func (cb *ComponentBase) SetURL(s string, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyUrl, s, params...)
}
-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, params ...PropertyParameter) {
+ if !strings.HasPrefix(s, "mailto:") {
+ s = "mailto:" + s
+ }
+
+ cb.SetProperty(ComponentPropertyOrganizer, s, params...)
}
-func (event *VEvent) SetURL(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyUrl, s, props...)
+func (cb *ComponentBase) SetColor(s string, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyColor, s, params...)
}
-func (event *VEvent) SetOrganizer(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyOrganizer, s, props...)
+func (cb *ComponentBase) SetClass(c Classification, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyClass, string(c), params...)
}
-func (event *VEvent) SetColor(s string, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyColor, s, props...)
+func (cb *ComponentBase) setPriority(p int, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), params...)
}
-func (event *VEvent) SetClass(c Classification, props ...PropertyParameter) {
- event.SetProperty(ComponentPropertyClass, string(c), props...)
+func (cb *ComponentBase) setResources(r string, params ...PropertyParameter) {
+ cb.SetProperty(ComponentPropertyResources, r, params...)
}
-func (event *VEvent) AddAttendee(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyAttendee, "mailto:"+s, props...)
+func (cb *ComponentBase) AddAttendee(s string, params ...PropertyParameter) {
+ if !strings.HasPrefix(s, "mailto:") {
+ s = "mailto:" + s
+ }
+
+ cb.AddProperty(ComponentPropertyAttendee, s, params...)
}
-func (event *VEvent) AddExdate(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyExdate, s, props...)
+func (cb *ComponentBase) AddExdate(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyExdate, s, params...)
}
-func (event *VEvent) AddExrule(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyExrule, s, props...)
+func (cb *ComponentBase) AddExrule(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyExrule, s, params...)
}
-func (event *VEvent) AddRdate(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyRdate, s, props...)
+func (cb *ComponentBase) AddRdate(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyRdate, s, params...)
}
-func (event *VEvent) AddRrule(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyRrule, s, props...)
+func (cb *ComponentBase) AddRrule(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyRrule, s, params...)
}
-func (event *VEvent) AddAttachment(s string, props ...PropertyParameter) {
- event.AddProperty(ComponentPropertyAttach, s, props...)
+func (cb *ComponentBase) AddAttachment(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyAttach, s, params...)
}
-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, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyComment, s, params...)
+}
+
+func (cb *ComponentBase) AddCategory(s string, params ...PropertyParameter) {
+ cb.AddProperty(ComponentPropertyCategories, s, params...)
+}
+
type Attendee struct {
IANAProperty
}
-func (attendee *Attendee) Email() string {
- if strings.HasPrefix(attendee.Value, "mailto:") {
- return attendee.Value[len("mailto:"):]
+func (p *Attendee) Email() string {
+ if strings.HasPrefix(p.Value, "mailto:") {
+ return p.Value[len("mailto:"):]
}
- return attendee.Value
+ return p.Value
}
-func (attendee *Attendee) ParticipationStatus() ParticipationStatus {
- return ParticipationStatus(attendee.getPropertyFirst(ParameterParticipationStatus))
+func (p *Attendee) ParticipationStatus() ParticipationStatus {
+ return ParticipationStatus(p.getPropertyFirst(ParameterParticipationStatus))
}
-func (attendee *Attendee) getPropertyFirst(parameter Parameter) string {
- vs := attendee.getProperty(parameter)
+func (p *Attendee) getPropertyFirst(parameter Parameter) string {
+ vs := p.getProperty(parameter)
if len(vs) > 0 {
return vs[0]
}
return ""
}
-func (attendee *Attendee) getProperty(parameter Parameter) []string {
- if vs, ok := attendee.ICalParameters[string(parameter)]; ok {
+func (p *Attendee) getProperty(parameter Parameter) []string {
+ if vs, ok := p.ICalParameters[string(parameter)]; ok {
return vs
}
return nil
}
-func (event *VEvent) Attendees() (r []*Attendee) {
- r = []*Attendee{}
- for i := range event.Properties {
- switch event.Properties[i].IANAToken {
+func (cb *ComponentBase) Attendees() []*Attendee {
+ var r []*Attendee
+ 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)
}
}
- return
+ return r
}
-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) {
- r = []*VAlarm{}
- for i := range event.Components {
- switch alarm := event.Components[i].(type) {
+func (cb *ComponentBase) addVAlarm(a *VAlarm) {
+ cb.Components = append(cb.Components, a)
+}
+
+func (cb *ComponentBase) alarms() []*VAlarm {
+ var r []*VAlarm
+ for i := range cb.Components {
+ switch alarm := cb.Components[i].(type) {
case *VAlarm:
r = append(r, alarm)
}
}
- return
+ return r
+}
+
+type VEvent struct {
+ ComponentBase
+}
+
+func (event *VEvent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return event.ComponentBase.serializeThis(w, ComponentVEvent, serialConfig)
+}
+
+func (event *VEvent) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := event.serialize(serialConfig)
+ return s
+}
+
+func (event *VEvent) serialize(serialConfig *SerializationConfiguration) (string, error) {
+ b := &bytes.Buffer{}
+ err := event.ComponentBase.serializeThis(b, ComponentVEvent, serialConfig)
+ return b.String(), err
+}
+
+func NewEvent(uniqueId string) *VEvent {
+ e := &VEvent{
+ NewComponent(uniqueId),
+ }
+ return e
+}
+
+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...)
+}
+
+// TODO use generics
+func (event *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
+ event.setGeo(lat, lng, params...)
+}
+
+func (event *VEvent) SetPriority(p int, params ...PropertyParameter) {
+ event.setPriority(p, params...)
+}
+
+func (event *VEvent) SetResources(r string, params ...PropertyParameter) {
+ event.setResources(r, params...)
+}
+
+func (event *VEvent) AddAlarm() *VAlarm {
+ return event.addAlarm()
+}
+
+func (event *VEvent) AddVAlarm(a *VAlarm) {
+ event.addVAlarm(a)
+}
+
+func (event *VEvent) Alarms() []*VAlarm {
+ return event.alarms()
+}
+
+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, params ...PropertyParameter) {
+ event.SetProperty(ComponentPropertyTransp, string(v), params...)
}
type VTodo struct {
ComponentBase
}
-func (c *VTodo) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VTODO")
+func (todo *VTodo) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return todo.ComponentBase.serializeThis(w, ComponentVTodo, serialConfig)
}
-func (c *VTodo) Serialize() string {
+func (todo *VTodo) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := todo.serialize(serialConfig)
+ return s
+}
+
+func (todo *VTodo) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VTODO")
- return b.String()
+ err := todo.ComponentBase.serializeThis(b, ComponentVTodo, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+func NewTodo(uniqueId string) *VTodo {
+ e := &VTodo{
+ NewComponent(uniqueId),
+ }
+ return e
+}
+
+func (cal *Calendar) AddTodo(id string) *VTodo {
+ e := NewTodo(id)
+ cal.Components = append(cal.Components, e)
+ return e
+}
+
+func (cal *Calendar) AddVTodo(e *VTodo) {
+ cal.Components = append(cal.Components, e)
+}
+
+func (cal *Calendar) Todos() []*VTodo {
+ var r []*VTodo
+ for i := range cal.Components {
+ switch todo := cal.Components[i].(type) {
+ case *VTodo:
+ r = append(r, todo)
+ }
+ }
+ return r
+}
+
+func (todo *VTodo) SetCompletedAt(t time.Time, params ...PropertyParameter) {
+ todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), params...)
+}
+
+func (todo *VTodo) SetAllDayCompletedAt(t time.Time, params ...PropertyParameter) {
+ params = append(params, WithValue(string(ValueDataTypeDate)))
+ todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), params...)
+}
+
+func (todo *VTodo) SetDueAt(t time.Time, params ...PropertyParameter) {
+ todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), params...)
+}
+
+func (todo *VTodo) SetAllDayDueAt(t time.Time, params ...PropertyParameter) {
+ params = append(params, WithValue(string(ValueDataTypeDate)))
+ todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), params...)
+}
+
+func (todo *VTodo) SetPercentComplete(p int, params ...PropertyParameter) {
+ todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), params...)
+}
+
+func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
+ todo.setGeo(lat, lng, params...)
+}
+
+func (todo *VTodo) SetPriority(p int, params ...PropertyParameter) {
+ todo.setPriority(p, params...)
+}
+
+func (todo *VTodo) SetResources(r string, params ...PropertyParameter) {
+ todo.setResources(r, params...)
+}
+
+// SetDuration updates the duration of an event.
+// This function will set either the end or start time of an event depending what is already given.
+// The duration defines the length of a event relative to start or end time.
+//
+// Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected.
+func (todo *VTodo) SetDuration(d time.Duration) error {
+ t, err := todo.GetStartAt()
+ if err == nil {
+ todo.SetDueAt(t.Add(d))
+ return nil
+ } else {
+ t, err = todo.GetDueAt()
+ if err == nil {
+ todo.SetStartAt(t.Add(-d))
+ return nil
+ }
+ }
+ return errors.New("start or end not yet defined")
+}
+
+func (todo *VTodo) AddAlarm() *VAlarm {
+ return todo.addAlarm()
+}
+
+func (todo *VTodo) AddVAlarm(a *VAlarm) {
+ todo.addVAlarm(a)
+}
+
+func (todo *VTodo) Alarms() []*VAlarm {
+ return todo.alarms()
+}
+
+// TODO verify that due is only relevant to VTodo if not move to ComponentBase.
+func (todo *VTodo) GetDueAt() (time.Time, error) {
+ return todo.getTimeProp(ComponentPropertyDue, false)
+}
+
+func (todo *VEvent) GetAllDayDueAt() (time.Time, error) {
+ return todo.getTimeProp(ComponentPropertyDue, true)
}
type VJournal struct {
ComponentBase
}
-func (c *VJournal) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VJOURNAL")
+func (journal *VJournal) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return journal.ComponentBase.serializeThis(w, ComponentVJournal, serialConfig)
}
-func (c *VJournal) Serialize() string {
+func (journal *VJournal) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := journal.serialize(serialConfig)
+ return s
+}
+
+func (journal *VJournal) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VJOURNAL")
- return b.String()
+ err := journal.ComponentBase.serializeThis(b, ComponentVJournal, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+func NewJournal(uniqueId string) *VJournal {
+ e := &VJournal{
+ NewComponent(uniqueId),
+ }
+ return e
+}
+
+func (cal *Calendar) AddJournal(id string) *VJournal {
+ e := NewJournal(id)
+ cal.Components = append(cal.Components, e)
+ return e
+}
+
+func (cal *Calendar) AddVJournal(e *VJournal) {
+ cal.Components = append(cal.Components, e)
+}
+
+func (cal *Calendar) Journals() []*VJournal {
+ var r []*VJournal
+ for i := range cal.Components {
+ switch journal := cal.Components[i].(type) {
+ case *VJournal:
+ r = append(r, journal)
+ }
+ }
+ return r
}
type VBusy struct {
ComponentBase
}
-func (c *VBusy) Serialize() string {
+func (busy *VBusy) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := busy.serialize(serialConfig)
+ return s
+}
+
+func (busy *VBusy) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VBUSY")
- return b.String()
+ err := busy.ComponentBase.serializeThis(b, ComponentVFreeBusy, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
}
-func (c *VBusy) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VBUSY")
+func (busy *VBusy) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return busy.ComponentBase.serializeThis(w, ComponentVFreeBusy, serialConfig)
+}
+
+func NewBusy(uniqueId string) *VBusy {
+ e := &VBusy{
+ NewComponent(uniqueId),
+ }
+ return e
+}
+
+func (cal *Calendar) AddBusy(id string) *VBusy {
+ e := NewBusy(id)
+ cal.Components = append(cal.Components, e)
+ return e
+}
+
+func (cal *Calendar) AddVBusy(e *VBusy) {
+ cal.Components = append(cal.Components, e)
+}
+
+func (cal *Calendar) Busys() []*VBusy {
+ var r []*VBusy
+ for i := range cal.Components {
+ switch busy := cal.Components[i].(type) {
+ case *VBusy:
+ r = append(r, busy)
+ }
+ }
+ return r
}
type VTimezone struct {
ComponentBase
}
-func (c *VTimezone) Serialize() string {
+func (timezone *VTimezone) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := timezone.serialize(serialConfig)
+ return s
+}
+
+func (timezone *VTimezone) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VTIMEZONE")
- return b.String()
+ err := timezone.ComponentBase.serializeThis(b, ComponentVTimezone, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+func (timezone *VTimezone) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return timezone.ComponentBase.serializeThis(w, ComponentVTimezone, serialConfig)
+}
+
+func (timezone *VTimezone) AddStandard() *Standard {
+ e := NewStandard()
+ timezone.Components = append(timezone.Components, e)
+ return e
+}
+
+func NewTimezone(tzId string) *VTimezone {
+ e := &VTimezone{
+ ComponentBase{
+ Properties: []IANAProperty{
+ {BaseProperty{IANAToken: string(ComponentPropertyTzid), Value: tzId}},
+ },
+ },
+ }
+ return e
}
-func (c *VTimezone) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VTIMEZONE")
+func (cal *Calendar) AddTimezone(id string) *VTimezone {
+ e := NewTimezone(id)
+ cal.Components = append(cal.Components, e)
+ return e
+}
+
+func (cal *Calendar) AddVTimezone(e *VTimezone) {
+ cal.Components = append(cal.Components, e)
+}
+
+func (cal *Calendar) Timezones() []*VTimezone {
+ var r []*VTimezone
+ for i := range cal.Components {
+ switch timezone := cal.Components[i].(type) {
+ case *VTimezone:
+ r = append(r, timezone)
+ }
+ }
+ return r
}
type VAlarm struct {
ComponentBase
}
-func (c *VAlarm) Serialize() string {
+func (c *VAlarm) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := c.serialize(serialConfig)
+ return s
+}
+
+func (c *VAlarm) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "VALARM")
- return b.String()
+ err := c.ComponentBase.serializeThis(b, ComponentVAlarm, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
+}
+
+func (c *VAlarm) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return c.ComponentBase.serializeThis(w, ComponentVAlarm, serialConfig)
+}
+
+func NewAlarm(tzId string) *VAlarm {
+ // Todo How did this come about?
+ e := &VAlarm{}
+ return e
}
-func (c *VAlarm) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "VALARM")
+func (cal *Calendar) AddVAlarm(e *VAlarm) {
+ cal.Components = append(cal.Components, e)
+}
+
+func (cal *Calendar) Alarms() []*VAlarm {
+ var r []*VAlarm
+ for i := range cal.Components {
+ switch alarm := cal.Components[i].(type) {
+ case *VAlarm:
+ r = append(r, alarm)
+ }
+ }
+ return r
}
-func (alarm *VAlarm) SetAction(a Action, props ...PropertyParameter) {
- alarm.SetProperty(ComponentPropertyAction, string(a), props...)
+func (c *VAlarm) SetAction(a Action, params ...PropertyParameter) {
+ c.SetProperty(ComponentPropertyAction, string(a), params...)
}
-func (alarm *VAlarm) SetTrigger(s string, props ...PropertyParameter) {
- alarm.SetProperty(ComponentPropertyTrigger, s, props...)
+func (c *VAlarm) SetTrigger(s string, params ...PropertyParameter) {
+ c.SetProperty(ComponentPropertyTrigger, s, params...)
}
type Standard struct {
ComponentBase
}
-func (c *Standard) Serialize() string {
+func NewStandard() *Standard {
+ e := &Standard{
+ ComponentBase{},
+ }
+ return e
+}
+
+func (standard *Standard) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := standard.serialize(serialConfig)
+ return s
+}
+
+func (standard *Standard) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "STANDARD")
- return b.String()
+ err := standard.ComponentBase.serializeThis(b, ComponentStandard, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
}
-func (c *Standard) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "STANDARD")
+func (standard *Standard) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return standard.ComponentBase.serializeThis(w, ComponentStandard, serialConfig)
}
type Daylight struct {
ComponentBase
}
-func (c *Daylight) Serialize() string {
+func (daylight *Daylight) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := daylight.serialize(serialConfig)
+ return s
+}
+
+func (daylight *Daylight) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, "DAYLIGHT")
- return b.String()
+ err := daylight.ComponentBase.serializeThis(b, ComponentDaylight, serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
}
-func (c *Daylight) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, "DAYLIGHT")
+func (daylight *Daylight) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return daylight.ComponentBase.serializeThis(w, ComponentDaylight, serialConfig)
}
type GeneralComponent struct {
@@ -505,141 +993,195 @@ type GeneralComponent struct {
Token string
}
-func (c *GeneralComponent) Serialize() string {
+func (general *GeneralComponent) Serialize(serialConfig *SerializationConfiguration) string {
+ s, _ := general.serialize(serialConfig)
+ return s
+}
+
+func (general *GeneralComponent) serialize(serialConfig *SerializationConfiguration) (string, error) {
b := &bytes.Buffer{}
- c.ComponentBase.serializeThis(b, c.Token)
- return b.String()
+ err := general.ComponentBase.serializeThis(b, ComponentType(general.Token), serialConfig)
+ if err != nil {
+ return "", err
+ }
+ return b.String(), nil
}
-func (c *GeneralComponent) serialize(w io.Writer) {
- c.ComponentBase.serializeThis(w, c.Token)
+func (general *GeneralComponent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
+ return general.ComponentBase.serializeThis(w, ComponentType(general.Token), serialConfig)
}
func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Component, error) {
var co Component
- switch startLine.Value {
- case "VCALENDAR":
- return nil, errors.New("malformed calendar; vcalendar not where expected")
- case "VEVENT":
- co = ParseVEvent(cs, startLine)
- case "VTODO":
- co = ParseVTodo(cs, startLine)
- case "VJOURNAL":
- co = ParseVJournal(cs, startLine)
- case "VFREEBUSY":
- co = ParseVBusy(cs, startLine)
- case "VTIMEZONE":
- co = ParseVTimezone(cs, startLine)
- case "VALARM":
- co = ParseVAlarm(cs, startLine)
- case "STANDARD":
- co = ParseStandard(cs, startLine)
- case "DAYLIGHT":
- co = ParseDaylight(cs, startLine)
+ var err error
+ switch ComponentType(startLine.Value) {
+ case ComponentVCalendar:
+ return nil, ErrMalformedCalendarVCalendarNotWhereExpected
+ case ComponentVEvent:
+ co, err = ParseVEventWithError(cs, startLine)
+ case ComponentVTodo:
+ co, err = ParseVTodoWithError(cs, startLine)
+ case ComponentVJournal:
+ co, err = ParseVJournalWithError(cs, startLine)
+ case ComponentVFreeBusy:
+ co, err = ParseVBusyWithError(cs, startLine)
+ case ComponentVTimezone:
+ co, err = ParseVTimezoneWithError(cs, startLine)
+ case ComponentVAlarm:
+ co, err = ParseVAlarmWithError(cs, startLine)
+ case ComponentStandard:
+ co, err = ParseStandardWithError(cs, startLine)
+ case ComponentDaylight:
+ co, err = ParseDaylightWithError(cs, startLine)
default:
- co = ParseGeneralComponent(cs, startLine)
+ co, err = ParseGeneralComponentWithError(cs, startLine)
}
- return co, nil
+ return co, err
}
func ParseVEvent(cs *CalendarStream, startLine *BaseProperty) *VEvent {
+ ev, _ := ParseVEventWithError(cs, startLine)
+ return ev
+}
+
+func ParseVEventWithError(cs *CalendarStream, startLine *BaseProperty) (*VEvent, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, fmt.Errorf("failed to parse event: %w", err)
}
rr := &VEvent{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseVTodo(cs *CalendarStream, startLine *BaseProperty) *VTodo {
+ c, _ := ParseVTodoWithError(cs, startLine)
+ return c
+}
+
+func ParseVTodoWithError(cs *CalendarStream, startLine *BaseProperty) (*VTodo, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &VTodo{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseVJournal(cs *CalendarStream, startLine *BaseProperty) *VJournal {
+ c, _ := ParseVJournalWithError(cs, startLine)
+ return c
+}
+
+func ParseVJournalWithError(cs *CalendarStream, startLine *BaseProperty) (*VJournal, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &VJournal{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseVBusy(cs *CalendarStream, startLine *BaseProperty) *VBusy {
+ c, _ := ParseVBusyWithError(cs, startLine)
+ return c
+}
+
+func ParseVBusyWithError(cs *CalendarStream, startLine *BaseProperty) (*VBusy, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &VBusy{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseVTimezone(cs *CalendarStream, startLine *BaseProperty) *VTimezone {
+ c, _ := ParseVTimezoneWithError(cs, startLine)
+ return c
+}
+
+func ParseVTimezoneWithError(cs *CalendarStream, startLine *BaseProperty) (*VTimezone, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &VTimezone{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseVAlarm(cs *CalendarStream, startLine *BaseProperty) *VAlarm {
+ c, _ := ParseVAlarmWithError(cs, startLine)
+ return c
+}
+
+func ParseVAlarmWithError(cs *CalendarStream, startLine *BaseProperty) (*VAlarm, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &VAlarm{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseStandard(cs *CalendarStream, startLine *BaseProperty) *Standard {
+ c, _ := ParseStandardWithError(cs, startLine)
+ return c
+}
+
+func ParseStandardWithError(cs *CalendarStream, startLine *BaseProperty) (*Standard, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &Standard{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseDaylight(cs *CalendarStream, startLine *BaseProperty) *Daylight {
+ c, _ := ParseDaylightWithError(cs, startLine)
+ return c
+}
+
+func ParseDaylightWithError(cs *CalendarStream, startLine *BaseProperty) (*Daylight, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &Daylight{
ComponentBase: r,
}
- return rr
+ return rr, nil
}
func ParseGeneralComponent(cs *CalendarStream, startLine *BaseProperty) *GeneralComponent {
+ c, _ := ParseGeneralComponentWithError(cs, startLine)
+ return c
+}
+
+func ParseGeneralComponentWithError(cs *CalendarStream, startLine *BaseProperty) (*GeneralComponent, error) {
r, err := ParseComponent(cs, startLine)
if err != nil {
- return nil
+ return nil, err
}
rr := &GeneralComponent{
ComponentBase: r,
Token: startLine.Value,
}
- return rr
+ return rr, nil
}
func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, error) {
@@ -648,8 +1190,8 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase,
for ln := 0; cont; ln++ {
l, err := cs.ReadLine()
if err != nil {
- switch err {
- case io.EOF:
+ switch {
+ case errors.Is(err, io.EOF):
cont = false
default:
return cb, err
@@ -660,10 +1202,10 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase,
}
line, err := ParseProperty(*l)
if err != nil {
- return cb, fmt.Errorf("parsing component property %d: %w", ln, err)
+ return cb, fmt.Errorf("%w %d: %w", ErrParsingComponentProperty, ln, err)
}
if line == nil {
- return cb, errors.New("parsing component line")
+ return cb, ErrParsingComponentLine
}
switch line.IANAToken {
case "END":
@@ -671,7 +1213,7 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase,
case startLine.Value:
return cb, nil
default:
- return cb, errors.New("unbalanced end")
+ return cb, ErrUnbalancedEnd
}
case "BEGIN":
co, err := GeneralParseComponent(cs, line)
@@ -685,5 +1227,5 @@ func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase,
cb.Properties = append(cb.Properties, IANAProperty{*line})
}
}
- return cb, errors.New("ran out of lines")
+ return cb, ErrOutOfLines
}
diff --git a/components_test.go b/components_test.go
index d7d8902..faedc40 100644
--- a/components_test.go
+++ b/components_test.go
@@ -52,7 +52,7 @@ END:VEVENT
err := e.SetDuration(duration)
// we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
- text := strings.Replace(e.Serialize(), "\r\n", "\n", -1)
+ text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
assert.Equal(t, tc.output, text)
assert.Equal(t, nil, err)
@@ -64,16 +64,36 @@ func TestSetAllDay(t *testing.T) {
date, _ := time.Parse(time.RFC822, time.RFC822)
testCases := []struct {
- name string
- start time.Time
- end time.Time
- output string
+ name string
+ start time.Time
+ end time.Time
+ duration time.Duration
+ output string
}{
{
- name: "test set duration - start",
+ name: "test set all day - start",
start: date,
output: `BEGIN:VEVENT
-UID:test-duration
+UID:test-allday
+DTSTART;VALUE=DATE:20060102
+END:VEVENT
+`,
+ },
+ {
+ name: "test set all day - end",
+ end: date,
+ output: `BEGIN:VEVENT
+UID:test-allday
+DTEND;VALUE=DATE:20060102
+END:VEVENT
+`,
+ },
+ {
+ name: "test set all day - duration",
+ start: date,
+ duration: time.Hour * 24,
+ output: `BEGIN:VEVENT
+UID:test-allday
DTSTART;VALUE=DATE:20060102
DTEND;VALUE=DATE:20060103
END:VEVENT
@@ -83,12 +103,88 @@ END:VEVENT
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- e := NewEvent("test-duration")
- e.SetAllDayStartAt(date)
- e.SetAllDayEndAt(date.AddDate(0, 0, 1))
+ e := NewEvent("test-allday")
+ if !tc.start.IsZero() {
+ e.SetAllDayStartAt(tc.start)
+ }
+ if !tc.end.IsZero() {
+ e.SetAllDayEndAt(tc.end)
+ }
+ if tc.duration != 0 {
+ err := e.SetDuration(tc.duration)
+ assert.NoError(t, err)
+ }
- // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
- text := strings.Replace(e.Serialize(), "\r\n", "\n", -1)
+ text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
+
+ assert.Equal(t, tc.output, text)
+ })
+ }
+}
+
+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(defaultSerializationOptions()), "ORGANIZER:mailto:org1@provider.com") {
+ t.Errorf("expected single mailto: prefix for email org1")
+ }
+
+ e.SetOrganizer("mailto:org2@provider.com")
+ if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org2@provider.com") {
+ t.Errorf("expected single mailto: prefix for email org2")
+ }
+
+ e.AddAttendee("att1@provider.com")
+ if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att1@provider.com") {
+ t.Errorf("expected single mailto: prefix for email att1")
+ }
+
+ e.AddAttendee("mailto:att2@provider.com")
+ if !strings.Contains(e.Serialize(defaultSerializationOptions()), "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.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
assert.Equal(t, tc.output, text)
})
diff --git a/errors.go b/errors.go
new file mode 100644
index 0000000..be34efd
--- /dev/null
+++ b/errors.go
@@ -0,0 +1,50 @@
+package ics
+
+import (
+ "errors"
+ "fmt"
+)
+
+var (
+ ErrUnexpectedParamValueLength = errors.New("unexpected end of param value")
+
+ ErrMalformedCalendar = errors.New("malformed calendar")
+
+ ErrMalformedCalendarExpectedVCalendar = fmt.Errorf("%w: expected a vcalendar", ErrMalformedCalendar)
+ ErrMalformedCalendarExpectedBegin = fmt.Errorf("%w: expected begin", ErrMalformedCalendar)
+ ErrMalformedCalendarExpectedEnd = fmt.Errorf("%w: expected a end", ErrMalformedCalendar)
+ ErrMalformedCalendarExpectedBeginOrEnd = fmt.Errorf("%w: expected begin or end", ErrMalformedCalendar)
+
+ ErrMissingPropertyParamOperator = fmt.Errorf("%w: missing property param operator", ErrMalformedCalendar)
+ ErrUnexpectedEndOfProperty = fmt.Errorf("%w: unexpected end of property", ErrMalformedCalendar)
+ ErrMalformedCalendarUnexpectedEnd = fmt.Errorf("%w: unexpected end", ErrMalformedCalendar)
+ ErrMalformedCalendarBadState = fmt.Errorf("%w: bad state", ErrMalformedCalendar)
+ ErrMalformedCalendarVCalendarNotWhereExpected = fmt.Errorf("%w: vcalendar not where expected", ErrMalformedCalendar)
+
+ ErrStartOrEndNotYetDefined = errors.New("start or end not yet defined")
+ // ErrPropertyNotFound is the error returned if the requested valid
+ // ErrorPropertyNotFound is the error returned if the requested valid
+ // property is not set.
+ ErrPropertyNotFound = errors.New("property not found")
+ ErrExpectedOneTZID = errors.New("expected one TZID")
+
+ ErrTimeValueNotMatched = errors.New("time value not matched")
+ ErrTimeValueMatchedButUnsupportedAllDayTimeStamp = errors.New("time value matched but unsupported all-day timestamp")
+ ErrTimeValueMatchedButNotSupported = errors.New("time value matched but not supported")
+
+ ErrParsingComponentProperty = errors.New("parsing component property")
+ ErrParsingComponentLine = errors.New("parsing component line")
+ ErrParsingLine = errors.New("parsing line")
+ ErrParsingCalendarLine = errors.New("parsing calendar line")
+ ErrParsingProperty = errors.New("parsing property")
+ ErrParse = errors.New("parse error")
+
+ ErrMissingPropertyValue = errors.New("missing property value")
+
+ ErrUnexpectedASCIIChar = errors.New("unexpected char ascii")
+ ErrUnexpectedDoubleQuoteInPropertyParamValue = errors.New("unexpected double quote in property param value")
+
+ ErrUnbalancedEnd = errors.New("unbalanced end")
+ ErrOutOfLines = errors.New("ran out of lines")
+ ErrorPropertyNotFound = errors.New("property not found")
+)
diff --git a/go.mod b/go.mod
index f2e229b..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-20210107192922-496545a6307b // indirect
+ gopkg.in/yaml.v3 v3.0.0 // indirect
)
diff --git a/go.sum b/go.sum
index 6f1958c..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=
@@ -17,5 +19,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
-gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/os.go b/os.go
new file mode 100644
index 0000000..1cebf9a
--- /dev/null
+++ b/os.go
@@ -0,0 +1,6 @@
+package ics
+
+const (
+ WithNewLineUnix WithNewLine = "\n"
+ WithNewLineWindows WithNewLine = "\r\n"
+)
diff --git a/os_unix.go b/os_unix.go
new file mode 100644
index 0000000..fe1bc84
--- /dev/null
+++ b/os_unix.go
@@ -0,0 +1,5 @@
+package ics
+
+const (
+ NewLine = WithNewLineUnix
+)
diff --git a/os_windows.go b/os_windows.go
new file mode 100644
index 0000000..81ebfce
--- /dev/null
+++ b/os_windows.go
@@ -0,0 +1,5 @@
+package ics
+
+const (
+ NewLineString = WithNewLineWindows
+)
diff --git a/property.go b/property.go
index 62418ad..ed80ee8 100644
--- a/property.go
+++ b/property.go
@@ -5,7 +5,9 @@ import (
"fmt"
"io"
"log"
+ "net/url"
"regexp"
+ "sort"
"strconv"
"strings"
"unicode/utf8"
@@ -26,7 +28,7 @@ type KeyValues struct {
Value []string
}
-func (kv *KeyValues) KeyValue(s ...interface{}) (string, []string) {
+func (kv *KeyValues) KeyValue(_ ...interface{}) (string, []string) {
return kv.Key, kv.Value
}
@@ -37,6 +39,21 @@ func WithCN(cn string) PropertyParameter {
}
}
+func WithTZID(tzid string) PropertyParameter {
+ return &KeyValues{
+ Key: string(ParameterTzid),
+ Value: []string{tzid},
+ }
+}
+
+// WithAlternativeRepresentation takes what must be a valid URI in quotation marks
+func WithAlternativeRepresentation(uri *url.URL) PropertyParameter {
+ return &KeyValues{
+ Key: string(ParameterAltrep),
+ Value: []string{uri.String()},
+ }
+}
+
func WithEncoding(encType string) PropertyParameter {
return &KeyValues{
Key: string(ParameterEncoding),
@@ -67,62 +84,178 @@ func WithRSVP(b bool) PropertyParameter {
func trimUT8StringUpTo(maxLength int, s string) string {
length := 0
- lastSpace := -1
+ lastWordBoundary := -1
+ var lastRune rune
for i, r := range s {
- if r == ' ' {
- lastSpace = i
+ if r == ' ' || r == '<' {
+ lastWordBoundary = i
+ } else if lastRune == '>' {
+ lastWordBoundary = i
}
-
+ lastRune = r
newLength := length + utf8.RuneLen(r)
if newLength > maxLength {
break
}
length = newLength
}
- if lastSpace > 0 {
- return s[:lastSpace]
+ if lastWordBoundary > 0 {
+ return s[:lastWordBoundary]
}
return s[:length]
}
-func (property *BaseProperty) serialize(w io.Writer) {
+func (bp *BaseProperty) parameterValue(param Parameter) (string, error) {
+ v, ok := bp.ICalParameters[string(param)]
+ if !ok || len(v) == 0 {
+ return "", fmt.Errorf("parameter %q not found in property", param)
+ }
+ if len(v) != 1 {
+ return "", fmt.Errorf("expected only one value for parameter %q in property, found %d", param, len(v))
+ }
+ return v[0], nil
+}
+
+func (bp *BaseProperty) GetValueType() ValueDataType {
+ for k, v := range bp.ICalParameters {
+ if Parameter(k) == ParameterValue && len(v) == 1 {
+ return ValueDataType(v[0])
+ }
+ }
+
+ // defaults from spec if unspecified
+ switch Property(bp.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 (bp *BaseProperty) serialize(w io.Writer, serialConfig *SerializationConfiguration) error {
b := bytes.NewBufferString("")
- fmt.Fprint(b, property.IANAToken)
- for k, vs := range property.ICalParameters {
- fmt.Fprint(b, ";")
- fmt.Fprint(b, k)
- fmt.Fprint(b, "=")
+ _, _ = fmt.Fprint(b, bp.IANAToken)
+
+ var keys []string
+ for k := range bp.ICalParameters {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ vs := bp.ICalParameters[k]
+ _, _ = fmt.Fprint(b, ";")
+ _, _ = fmt.Fprint(b, k)
+ _, _ = fmt.Fprint(b, "=")
for vi, v := range vs {
if vi > 0 {
- fmt.Fprint(b, ",")
+ _, _ = fmt.Fprint(b, ",")
}
- if strings.ContainsAny(v, ";:\\\",") {
- v = strings.Replace(v, ";", "\\;", -1)
- v = strings.Replace(v, ":", "\\:", -1)
- v = strings.Replace(v, "\\", "\\\\", -1)
- v = strings.Replace(v, "\"", "\\\"", -1)
- v = strings.Replace(v, ",", "\\,", -1)
+ if Parameter(k).IsQuoted() {
+ v = quotedValueString(v)
+ _, _ = fmt.Fprint(b, v)
+ } else {
+ v = escapeValueString(v)
+ _, _ = fmt.Fprint(b, v)
}
- fmt.Fprint(b, v)
}
}
- fmt.Fprint(b, ":")
- fmt.Fprint(b, property.Value)
+ _, _ = fmt.Fprint(b, ":")
+ propertyValue := bp.Value
+ if bp.GetValueType() == ValueDataTypeText {
+ propertyValue = ToText(propertyValue)
+ }
+ _, _ = fmt.Fprint(b, propertyValue)
r := b.String()
- if len(r) > 75 {
- l := trimUT8StringUpTo(75, r)
- fmt.Fprint(w, l, "\r\n")
+ if len(r) > serialConfig.MaxLength {
+ l := trimUT8StringUpTo(serialConfig.MaxLength, r)
+ _, err := fmt.Fprint(w, l, serialConfig.NewLine)
+ if err != nil {
+ return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
+ }
r = r[len(l):]
- for len(r) > 74 {
- l := trimUT8StringUpTo(74, r)
- fmt.Fprint(w, " ", l, "\r\n")
+ for len(r) > serialConfig.MaxLength-1 {
+ l := trimUT8StringUpTo(serialConfig.MaxLength-1, r)
+ _, err = fmt.Fprint(w, " ", l, serialConfig.NewLine)
+ if err != nil {
+ return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
+ }
r = r[len(l):]
}
- fmt.Fprint(w, " ")
+ _, err = fmt.Fprint(w, " ")
+ if err != nil {
+ return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
+ }
}
- fmt.Fprint(w, r, "\r\n")
+ _, err := fmt.Fprint(w, r, serialConfig.NewLine)
+ if err != nil {
+ return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
+ }
+ return nil
+}
+
+func escapeValueString(v string) string {
+ changed := 0
+ result := ""
+ for i, r := range v {
+ switch r {
+ case ',', '"', ';', ':', '\\', '\'':
+ result = result + v[changed:i] + "\\" + string(r)
+ changed = i + 1
+ }
+ }
+ if changed == 0 {
+ return v
+ }
+ return result + v[changed:]
+}
+
+func quotedValueString(v string) string {
+ changed := 0
+ result := ""
+ for i, r := range v {
+ switch r {
+ case '"', '\\':
+ result = result + v[changed:i] + "\\" + string(r)
+ changed = i + 1
+ }
+ }
+ if changed == 0 {
+ return `"` + v + `"`
+ }
+ return `"` + result + v[changed:] + `"`
}
type IANAProperty struct {
@@ -174,7 +307,7 @@ func ParseProperty(contentLine ContentLine) (*BaseProperty, error) {
t := r.IANAToken
r, np, err = parsePropertyParam(r, string(contentLine), p+1)
if err != nil {
- return nil, fmt.Errorf("parsing property %s: %w", t, err)
+ return nil, fmt.Errorf("%w %s: %w", ErrParsingProperty, t, err)
}
if r == nil {
return nil, nil
@@ -194,11 +327,14 @@ 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("%w for %s in %s", ErrMissingPropertyParamOperator, k, r.IANAToken)
+ }
switch rune(contentLine[p]) {
case '=':
p += 1
default:
- return nil, p, fmt.Errorf("missing property value for %s in %s", k, r.IANAToken)
+ return nil, p, fmt.Errorf("%w for %s in %s", ErrMissingPropertyValue, k, r.IANAToken)
}
for {
if p >= len(contentLine) {
@@ -207,9 +343,12 @@ func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProper
var err error
v, p, err = parsePropertyParamValue(contentLine, p)
if err != nil {
- return nil, 0, fmt.Errorf("parse error: %w %s in %s", err, k, r.IANAToken)
+ return nil, 0, fmt.Errorf("%w: %w %s in %s", ErrParse, err, k, r.IANAToken)
}
r.ICalParameters[k] = append(r.ICalParameters[k], v)
+ if p >= len(contentLine) {
+ return nil, p, fmt.Errorf("%w %s", ErrUnexpectedEndOfProperty, r.IANAToken)
+ }
switch rune(contentLine[p]) {
case ',':
p += 1
@@ -253,11 +392,14 @@ func parsePropertyParamValue(s string, p int) (string, int, error) {
for ; p < len(s) && !done; p++ {
switch s[p] {
case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08:
- return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p])
+ return "", 0, fmt.Errorf("%w:%d in property param value", ErrUnexpectedASCIIChar, s[p])
case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B,
0x1C, 0x1D, 0x1E, 0x1F:
- return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p])
+ return "", 0, fmt.Errorf("%w:%d in property param value", ErrUnexpectedASCIIChar, s[p])
case '\\':
+ if p+2 >= len(s) {
+ return "", 0, ErrUnexpectedParamValueLength
+ }
r = append(r, []byte(FromText(string(s[p+1:p+2])))...)
p++
continue
@@ -276,7 +418,7 @@ func parsePropertyParamValue(s string, p int) (string, int, error) {
done = true
continue
}
- return "", 0, fmt.Errorf("unexpected double quote in property param value")
+ return "", 0, ErrUnexpectedDoubleQuoteInPropertyParamValue
}
r = append(r, s[p])
}
@@ -288,7 +430,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/property_test.go b/property_test.go
index 84e2d04..5610c34 100644
--- a/property_test.go
+++ b/property_test.go
@@ -185,3 +185,71 @@ func Test_parsePropertyParamValue(t *testing.T) {
})
}
}
+
+func Test_trimUT8StringUpTo(t *testing.T) {
+ tests := []struct {
+ name string
+ maxLength int
+ s string
+ want string
+ }{
+ {
+ name: "simply break at spaces",
+ s: "simply break at spaces",
+ maxLength: 14,
+ want: "simply break",
+ },
+ {
+ name: "(Don't) Break after punctuation 1", // See if we can change this.
+ s: "hi.are.",
+ maxLength: len("hi.are"),
+ want: "hi.are",
+ },
+ {
+ name: "Break after punctuation 2",
+ s: "Hi how are you?",
+ maxLength: len("Hi how are you"),
+ want: "Hi how are",
+ },
+ {
+ name: "HTML opening tag breaking",
+ s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.",
+ maxLength: len("I want a custom linkout for Thunderbird.
This is the Github<"),
+ want: "I want a custom linkout for Thunderbird.
This is the Github",
+ },
+ {
+ name: "HTML closing tag breaking",
+ s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.",
+ maxLength: len("I want a custom linkout for Thunderbird.
") + 1,
+ want: "I want a custom linkout for Thunderbird.
",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equalf(t, tt.want, trimUT8StringUpTo(tt.maxLength, tt.s), "trimUT8StringUpTo(%v, %v)", tt.maxLength, tt.s)
+ })
+ }
+}
+
+func TestFixValueStrings(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"hello", "hello"},
+ {"hello;world", "hello\\;world"},
+ {"path\\to:file", "path\\\\to\\:file"},
+ {"name:\"value\"", "name\\:\\\"value\\\""},
+ {"key,value", "key\\,value"},
+ {";:\\\",", "\\;\\:\\\\\\\"\\,"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ result := escapeValueString(tt.input)
+ if result != tt.expected {
+ t.Errorf("got %q, want %q", result, tt.expected)
+ }
+ })
+ }
+}
diff --git a/testdata/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/issue97/google.ics b/testdata/issue97/google.ics
new file mode 100644
index 0000000..9408b3a
--- /dev/null
+++ b/testdata/issue97/google.ics
@@ -0,0 +1,23 @@
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+X-WR-CALNAME:Test
+X-WR-TIMEZONE:Europe/Berlin
+BEGIN:VEVENT
+DTSTART:20240929T124500Z
+DTEND:20240929T134500Z
+DTSTAMP:20240929T121653Z
+UID:al23c5kr943d42u3bqoqrkf455@google.com
+CREATED:20240929T121642Z
+DESCRIPTION:I want a custom linkout for Thunderbird.
This is the Github
+ Issue.
+LAST-MODIFIED:20240929T121642Z
+LOCATION:GitHub
+SEQUENCE:0
+STATUS:CONFIRMED
+SUMMARY:Test Event
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
diff --git a/testdata/issue97/thunderbird.ics_disabled b/testdata/issue97/thunderbird.ics_disabled
new file mode 100644
index 0000000..88fd37e
--- /dev/null
+++ b/testdata/issue97/thunderbird.ics_disabled
@@ -0,0 +1,37 @@
+BEGIN:VCALENDAR
+PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:Europe/Berlin
+X-TZINFO:Europe/Berlin[2024a]
+BEGIN:STANDARD
+TZOFFSETTO:+010000
+TZOFFSETFROM:+005328
+TZNAME:Europe/Berlin(STD)
+DTSTART:18930401T000000
+RDATE:18930401T000000
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+CREATED:20240929T120640Z
+LAST-MODIFIED:20240929T120731Z
+DTSTAMP:20240929T120731Z
+UID:d23cef0d-9e58-43c4-9391-5ad8483ca346
+SUMMARY:Test Event
+DTSTART;TZID=Europe/Berlin:20240929T144500
+DTEND;TZID=Europe/Berlin:20240929T154500
+TRANSP:OPAQUE
+LOCATION:Github
+DESCRIPTION;ALTREP="data:text/html,I%20want%20
+ a%20custom%20linkout%20for%20
+ Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20
+ %3Ca%20href%3D%22https%3A%2F
+ %2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22
+ %3EIssue%3C%2Fa%3E.":I
+ want a custom linkout for Thunderbird.\nThis is the Github Issue.
+END:VEVENT
+END:VCALENDAR
+
+
+
+Disabled due to wordwrapping differences
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
+