diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..d73773d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,39 @@ +--- +name: 🐞 Bug +about: File a bug/issue +title: '[BUG] ' +labels: Bug, Needs Triage +assignees: '' + +--- + +<!-- +Note: Please search to see if an issue already exists for the bug you encountered. +--> + +### Current Behavior: +<!-- A concise description of what you're experiencing. --> + +### Expected Behavior: +<!-- A concise description of what you expected to happen. --> + +### Steps To Reproduce: +<!-- +Example: steps to reproduce the behavior: +1. In this environment... +1. With this config... +1. Run '...' +1. See error... +--> + +### Minimal Example ical extract: + +```ical +BEGIN:VCALENDAR +.... +``` + +### Anything else: +<!-- +Links? References? Anything that will give us more context about the issue that you are encountering! +--> diff --git a/.github/ISSUE_TEMPLATE/other.md b/.github/ISSUE_TEMPLATE/other.md new file mode 100644 index 0000000..9933437 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.md @@ -0,0 +1,15 @@ +--- +name: Something else +about: Any other issue +title: '<title>' +labels: Needs Triage +assignees: '' + +--- + +<!-- +Note: Please search to see if an issue already exists for the bug you encountered. + +Please include smallest possible sized examples. +--> + diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..58d632e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,35 @@ +<!-- + +Thanks for contributing! + +--> + +# Pull Request Template + +## Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +<!-- The following line will automatically close the issues if done correctly --> + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- This might take a while to be considered + +## Must haves: + +- [ ] I have commented in hard-to-understand areas and anywhere the function not immediately apparent +- [ ] I have made corresponding changes to the comments (if any) +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove the fix is effective or that my feature works +- [ ] I have added tests that protects the code from degradation in the future + +## Nice to haves: + +- [ ] I have added additional function comments to new or existing functions diff --git a/.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.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", + maxLength: len("I want a custom linkout for Thunderbird.<br>This is the Github<"), + want: "I want a custom linkout for Thunderbird.<br>This is the Github", + }, + { + name: "HTML closing tag breaking", + s: "I want a custom linkout for Thunderbird.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", + maxLength: len("I want a custom linkout for Thunderbird.<br>") + 1, + want: "I want a custom linkout for Thunderbird.<br>", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, trimUT8StringUpTo(tt.maxLength, tt.s), "trimUT8StringUpTo(%v, %v)", tt.maxLength, tt.s) + }) + } +} + +func TestFixValueStrings(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"hello;world", "hello\\;world"}, + {"path\\to:file", "path\\\\to\\:file"}, + {"name:\"value\"", "name\\:\\\"value\\\""}, + {"key,value", "key\\,value"}, + {";:\\\",", "\\;\\:\\\\\\\"\\,"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := escapeValueString(tt.input) + if result != tt.expected { + t.Errorf("got %q, want %q", result, tt.expected) + } + }) + } +} diff --git a/testdata/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.<br>This is the Github + <a href="https://github.com/arran4/golang-ical/issues/97">Issue</a>. +LAST-MODIFIED:20240929T121642Z +LOCATION:GitHub +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Test Event +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/testdata/issue97/thunderbird.ics_disabled b/testdata/issue97/thunderbird.ics_disabled new file mode 100644 index 0000000..88fd37e --- /dev/null +++ b/testdata/issue97/thunderbird.ics_disabled @@ -0,0 +1,37 @@ +BEGIN:VCALENDAR +PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-TZINFO:Europe/Berlin[2024a] +BEGIN:STANDARD +TZOFFSETTO:+010000 +TZOFFSETFROM:+005328 +TZNAME:Europe/Berlin(STD) +DTSTART:18930401T000000 +RDATE:18930401T000000 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +CREATED:20240929T120640Z +LAST-MODIFIED:20240929T120731Z +DTSTAMP:20240929T120731Z +UID:d23cef0d-9e58-43c4-9391-5ad8483ca346 +SUMMARY:Test Event +DTSTART;TZID=Europe/Berlin:20240929T144500 +DTEND;TZID=Europe/Berlin:20240929T154500 +TRANSP:OPAQUE +LOCATION:Github +DESCRIPTION;ALTREP="data:text/html,I%20want%20 + a%20custom%20linkout%20for%20 + Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20 + %3Ca%20href%3D%22https%3A%2F + %2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22 + %3EIssue%3C%2Fa%3E.":I + want a custom linkout for Thunderbird.\nThis is the Github Issue. +END:VEVENT +END:VCALENDAR + + + +Disabled due to wordwrapping differences 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 +