├── .editorconfig
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug.md
│ └── other.md
├── PULL_REQUEST_TEMPLATE
│ └── pull_request_template.md
└── workflows
│ ├── golangci-lint.yml
│ ├── goleaks.yml
│ ├── govun.yml
│ └── test.yml
├── .gitignore
├── CONTRIBUTING.md
├── FUNDING.yml
├── LICENSE
├── README.md
├── calendar.go
├── calendar_fuzz_test.go
├── calendar_serialization_test.go
├── calendar_test.go
├── cmd
└── issues
│ └── test97_1
│ └── main.go
├── components.go
├── components_test.go
├── errors.go
├── go.mod
├── go.sum
├── os.go
├── os_unix.go
├── os_windows.go
├── property.go
├── property_test.go
└── testdata
├── fuzz
└── FuzzParseCalendar
│ ├── 5940bf4f62ecac30
│ ├── 5f69bd55acfce1af
│ └── 8856e23652c60ed6
├── issue52
├── issue52_notworking.ics
└── issue52_working.ics
├── issue97
├── google.ics
└── thunderbird.ics_disabled
├── rfc5545sec4
├── input1.ics
├── input2.ics
├── input3.ics
├── input4.ics
├── input5.ics
└── input6.ics
├── serialization
├── expected
│ ├── input1.ics
│ ├── input2.ics
│ ├── input3.ics
│ ├── input4.ics
│ ├── input5.ics
│ ├── input6.ics
│ └── input7.ics
├── input1.ics
├── input2.ics
├── input3.ics
├── input4.ics
├── input5.ics
├── input6.ics
└── input7.ics
└── timeparsing.ics
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | insert_final_newline = true
5 | charset = utf-8
6 | trim_trailing_whitespace = true
7 | indent_style = space
8 | indent_size = 2
9 | end_of_line = lf
10 |
11 | [{Makefile,go.mod,go.sum,*.go,.gitmodules}]
12 | indent_style = tab
13 | indent_size = 4
14 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.go text eol=lf
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: 🐞 Bug
3 | about: File a bug/issue
4 | title: '[BUG]
'
5 | labels: Bug, Needs Triage
6 | assignees: ''
7 |
8 | ---
9 |
10 |
13 |
14 | ### Current Behavior:
15 |
16 |
17 | ### Expected Behavior:
18 |
19 |
20 | ### Steps To Reproduce:
21 |
28 |
29 | ### Minimal Example ical extract:
30 |
31 | ```ical
32 | BEGIN:VCALENDAR
33 | ....
34 | ```
35 |
36 | ### Anything else:
37 |
40 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/other.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Something else
3 | about: Any other issue
4 | title: ''
5 | labels: Needs Triage
6 | assignees: ''
7 |
8 | ---
9 |
10 |
15 |
16 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
6 |
7 | # Pull Request Template
8 |
9 | ## Description
10 |
11 | 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.
12 |
13 |
14 |
15 | Fixes # (issue)
16 |
17 | ## Type of change
18 |
19 | Please delete options that are not relevant.
20 |
21 | - [ ] Bug fix (non-breaking change which fixes an issue)
22 | - [ ] New feature (non-breaking change which adds functionality)
23 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- This might take a while to be considered
24 |
25 | ## Must haves:
26 |
27 | - [ ] I have commented in hard-to-understand areas and anywhere the function not immediately apparent
28 | - [ ] I have made corresponding changes to the comments (if any)
29 | - [ ] My changes generate no new warnings
30 | - [ ] I have added tests that prove the fix is effective or that my feature works
31 | - [ ] I have added tests that protects the code from degradation in the future
32 |
33 | ## Nice to haves:
34 |
35 | - [ ] I have added additional function comments to new or existing functions
36 |
--------------------------------------------------------------------------------
/.github/workflows/golangci-lint.yml:
--------------------------------------------------------------------------------
1 | name: golangci-lint
2 | on:
3 | push:
4 | tags:
5 | - v*
6 | branches:
7 | - master
8 | - main
9 | pull_request:
10 | jobs:
11 | golangci:
12 | name: Lint
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: read # allow read access to the content for analysis.
16 | checks: write # allow write access to checks to allow the action to annotate code in the PR.
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | - name: golangci-lint
21 | uses: golangci/golangci-lint-action@v6
22 | with:
23 | version: latest
24 |
--------------------------------------------------------------------------------
/.github/workflows/goleaks.yml:
--------------------------------------------------------------------------------
1 | name: gitleaks
2 | on: [push,pull_request]
3 | jobs:
4 | gitleaks:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v1
8 | - name: gitleaks-action
9 | uses: zricethezav/gitleaks-action@master
10 | env:
11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
12 |
--------------------------------------------------------------------------------
/.github/workflows/govun.yml:
--------------------------------------------------------------------------------
1 | name: Go vunderability check
2 | on: [push, pull_request]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v4
8 | - name: Setup Golang
9 | uses: actions/setup-go@v5
10 | - id: govulncheck
11 | uses: golang/govulncheck-action@v1
12 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | on: [push, pull_request]
2 | name: Test
3 | jobs:
4 | version:
5 | name: Test
6 | permissions:
7 | contents: read
8 | strategy:
9 | matrix:
10 | go-version: ['oldstable', 'stable']
11 | os: [ubuntu-latest, macos-13, windows-latest]
12 | runs-on: ${{ matrix.os }}
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | - name: Setup Golang
17 | uses: actions/setup-go@v5
18 | with:
19 | go-version: "${{ matrix.go-version }}"
20 | - name: Go Test
21 | run: go test -race ./...
22 | module:
23 | name: Test
24 | permissions:
25 | contents: read
26 | strategy:
27 | matrix:
28 | go-version-file: ['go.mod']
29 | os: [ubuntu-latest, macos-13, windows-latest]
30 | runs-on: ${{ matrix.os }}
31 | steps:
32 | - name: Checkout
33 | uses: actions/checkout@v4
34 | - name: Setup Golang
35 | uses: actions/setup-go@v5
36 | with:
37 | go-version-file: "${{ matrix.go-version-file }}"
38 | - name: Go Test
39 | run: go test -race ./...
40 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.idea/
2 | /testdata/serialization/actual
3 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Linting
4 | Make sure your code has been linted using [golangci-lint](https://github.com/golangci/golangci-lint?tab=readme-ov-file#install-golangci-lint)
5 |
6 | ```shell
7 | $ golangci-lint run
8 | ```
9 |
10 | ## Tests
11 |
12 | If you want to submit a bug fix or new feature, make sure that all tests are passing.
13 | ```shell
14 | $ go test ./...
15 | ```
16 |
17 |
18 |
--------------------------------------------------------------------------------
/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [arran4]
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # golang-ical
2 | A ICS / ICal parser and serialiser for Golang.
3 |
4 | [](https://godoc.org/github.com/arran4/golang-ical)
5 |
6 | Because the other libraries didn't quite do what I needed.
7 |
8 | Usage, parsing:
9 | ```golang
10 | cal, err := ParseCalendar(strings.NewReader(input))
11 |
12 | ```
13 |
14 | Usage, parsing from a URL:
15 | ```golang
16 | cal, err := ParseCalendarFromUrl("https://your-ics-url")
17 | ```
18 |
19 | Creating:
20 | ```golang
21 | cal := ics.NewCalendar()
22 | cal.SetMethod(ics.MethodRequest)
23 | event := cal.AddEvent(fmt.Sprintf("id@domain", p.SessionKey.IntID()))
24 | event.SetCreatedTime(time.Now())
25 | event.SetDtStampTime(time.Now())
26 | event.SetModifiedAt(time.Now())
27 | event.SetStartAt(time.Now())
28 | event.SetEndAt(time.Now())
29 | event.SetSummary("Summary")
30 | event.SetLocation("Address")
31 | event.SetDescription("Description")
32 | event.SetURL("https://URL/")
33 | event.AddRrule(fmt.Sprintf("FREQ=YEARLY;BYMONTH=%d;BYMONTHDAY=%d", time.Now().Month(), time.Now().Day()))
34 | event.SetOrganizer("sender@domain", ics.WithCN("This Machine"))
35 | event.AddAttendee("reciever or participant", ics.CalendarUserTypeIndividual, ics.ParticipationStatusNeedsAction, ics.ParticipationRoleReqParticipant, ics.WithRSVP(true))
36 | return cal.Serialize()
37 | ```
38 |
39 | Helper methods created as needed feel free to send a P.R. with more.
40 |
41 | # Notice
42 |
43 | Looking for a co-maintainer.
44 |
--------------------------------------------------------------------------------
/calendar.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "reflect"
11 | "strings"
12 | "time"
13 | )
14 |
15 | type ComponentType string
16 |
17 | const (
18 | ComponentVCalendar ComponentType = "VCALENDAR"
19 | ComponentVEvent ComponentType = "VEVENT"
20 | ComponentVTodo ComponentType = "VTODO"
21 | ComponentVJournal ComponentType = "VJOURNAL"
22 | ComponentVFreeBusy ComponentType = "VFREEBUSY"
23 | ComponentVTimezone ComponentType = "VTIMEZONE"
24 | ComponentVAlarm ComponentType = "VALARM"
25 | ComponentStandard ComponentType = "STANDARD"
26 | ComponentDaylight ComponentType = "DAYLIGHT"
27 | )
28 |
29 | type ComponentProperty Property
30 |
31 | const (
32 | ComponentPropertyUniqueId = ComponentProperty(PropertyUid) // TEXT
33 | ComponentPropertyDtstamp = ComponentProperty(PropertyDtstamp)
34 | ComponentPropertyOrganizer = ComponentProperty(PropertyOrganizer)
35 | ComponentPropertyAttendee = ComponentProperty(PropertyAttendee)
36 | ComponentPropertyAttach = ComponentProperty(PropertyAttach)
37 | ComponentPropertyDescription = ComponentProperty(PropertyDescription) // TEXT
38 | ComponentPropertyCategories = ComponentProperty(PropertyCategories) // TEXT
39 | ComponentPropertyClass = ComponentProperty(PropertyClass) // TEXT
40 | ComponentPropertyColor = ComponentProperty(PropertyColor) // TEXT
41 | ComponentPropertyCreated = ComponentProperty(PropertyCreated)
42 | ComponentPropertySummary = ComponentProperty(PropertySummary) // TEXT
43 | ComponentPropertyDtStart = ComponentProperty(PropertyDtstart)
44 | ComponentPropertyDtEnd = ComponentProperty(PropertyDtend)
45 | ComponentPropertyLocation = ComponentProperty(PropertyLocation) // TEXT
46 | ComponentPropertyStatus = ComponentProperty(PropertyStatus) // TEXT
47 | ComponentPropertyFreebusy = ComponentProperty(PropertyFreebusy)
48 | ComponentPropertyLastModified = ComponentProperty(PropertyLastModified)
49 | ComponentPropertyUrl = ComponentProperty(PropertyUrl)
50 | ComponentPropertyGeo = ComponentProperty(PropertyGeo)
51 | ComponentPropertyTransp = ComponentProperty(PropertyTransp)
52 | ComponentPropertySequence = ComponentProperty(PropertySequence)
53 | ComponentPropertyExdate = ComponentProperty(PropertyExdate)
54 | ComponentPropertyExrule = ComponentProperty(PropertyExrule)
55 | ComponentPropertyRdate = ComponentProperty(PropertyRdate)
56 | ComponentPropertyRrule = ComponentProperty(PropertyRrule)
57 | ComponentPropertyAction = ComponentProperty(PropertyAction)
58 | ComponentPropertyTrigger = ComponentProperty(PropertyTrigger)
59 | ComponentPropertyPriority = ComponentProperty(PropertyPriority)
60 | ComponentPropertyResources = ComponentProperty(PropertyResources)
61 | ComponentPropertyCompleted = ComponentProperty(PropertyCompleted)
62 | ComponentPropertyDue = ComponentProperty(PropertyDue)
63 | ComponentPropertyPercentComplete = ComponentProperty(PropertyPercentComplete)
64 | ComponentPropertyTzid = ComponentProperty(PropertyTzid)
65 | ComponentPropertyComment = ComponentProperty(PropertyComment)
66 | ComponentPropertyRelatedTo = ComponentProperty(PropertyRelatedTo)
67 | ComponentPropertyMethod = ComponentProperty(PropertyMethod)
68 | ComponentPropertyRecurrenceId = ComponentProperty(PropertyRecurrenceId)
69 | ComponentPropertyDuration = ComponentProperty(PropertyDuration)
70 | ComponentPropertyContact = ComponentProperty(PropertyContact)
71 | ComponentPropertyRequestStatus = ComponentProperty(PropertyRequestStatus)
72 | ComponentPropertyRDate = ComponentProperty(PropertyRdate)
73 | )
74 |
75 | // Required returns the rules from the RFC as to if they are required or not for any particular component type
76 | // If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference
77 | // iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
78 | func (cp ComponentProperty) Required(c Component) bool {
79 | // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
80 | switch cp {
81 | case ComponentPropertyDtstamp, ComponentPropertyUniqueId:
82 | switch c.(type) {
83 | case *VEvent:
84 | return true
85 | }
86 | case ComponentPropertyDtStart:
87 | switch c := c.(type) {
88 | case *VEvent:
89 | return !c.HasProperty(ComponentPropertyMethod)
90 | }
91 | }
92 | return false
93 | }
94 |
95 | // Exclusive returns the ComponentProperty's using the rules from the RFC as to if one or more existing properties are prohibiting this one
96 | // If unspecified or incomplete, it returns false. -- This list is incomplete verify source. Happy to take PRs with reference
97 | // iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
98 | func (cp ComponentProperty) Exclusive(c Component) []ComponentProperty {
99 | // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
100 | switch cp {
101 | case ComponentPropertyDtEnd:
102 | switch c := c.(type) {
103 | case *VEvent:
104 | if c.HasProperty(ComponentPropertyDuration) {
105 | return []ComponentProperty{ComponentPropertyDuration}
106 | }
107 | }
108 | case ComponentPropertyDuration:
109 | switch c := c.(type) {
110 | case *VEvent:
111 | if c.HasProperty(ComponentPropertyDtEnd) {
112 | return []ComponentProperty{ComponentPropertyDtEnd}
113 | }
114 | }
115 | }
116 | return nil
117 | }
118 |
119 | // Singular returns the rules from the RFC as to if the spec states that if "Must not occur more than once"
120 | // iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
121 | func (cp ComponentProperty) Singular(c Component) bool {
122 | // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
123 | switch cp {
124 | case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo,
125 | ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority,
126 | ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp,
127 | ComponentPropertyUrl, ComponentPropertyRecurrenceId:
128 | switch c.(type) {
129 | case *VEvent:
130 | return true
131 | }
132 | }
133 | return false
134 | }
135 |
136 | // Optional returns the rules from the RFC as to if the spec states that if these are optional
137 | // iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
138 | func (cp ComponentProperty) Optional(c Component) bool {
139 | // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
140 | switch cp {
141 | case ComponentPropertyClass, ComponentPropertyCreated, ComponentPropertyDescription, ComponentPropertyGeo,
142 | ComponentPropertyLastModified, ComponentPropertyLocation, ComponentPropertyOrganizer, ComponentPropertyPriority,
143 | ComponentPropertySequence, ComponentPropertyStatus, ComponentPropertySummary, ComponentPropertyTransp,
144 | ComponentPropertyUrl, ComponentPropertyRecurrenceId, ComponentPropertyRrule, ComponentPropertyAttach,
145 | ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment,
146 | ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo,
147 | ComponentPropertyResources, ComponentPropertyRDate:
148 | switch c.(type) {
149 | case *VEvent:
150 | return true
151 | }
152 | }
153 | return false
154 | }
155 |
156 | // Multiple returns the rules from the RFC as to if the spec states explicitly if multiple are allowed
157 | // iana-prop and x-props are not covered as it would always be true and require an exhaustive list.
158 | func (cp ComponentProperty) Multiple(c Component) bool {
159 | // https://www.rfc-editor.org/rfc/rfc5545#section-3.6.1
160 | switch cp {
161 | case ComponentPropertyAttach, ComponentPropertyAttendee, ComponentPropertyCategories, ComponentPropertyComment,
162 | ComponentPropertyContact, ComponentPropertyExdate, ComponentPropertyRequestStatus, ComponentPropertyRelatedTo,
163 | ComponentPropertyResources, ComponentPropertyRDate:
164 | switch c.(type) {
165 | case *VEvent:
166 | return true
167 | }
168 | }
169 | return false
170 | }
171 |
172 | func ComponentPropertyExtended(s string) ComponentProperty {
173 | return ComponentProperty("X-" + strings.TrimPrefix("X-", s))
174 | }
175 |
176 | type Property string
177 |
178 | const (
179 | PropertyCalscale Property = "CALSCALE" // TEXT
180 | PropertyMethod Property = "METHOD" // TEXT
181 | PropertyProductId Property = "PRODID" // TEXT
182 | PropertyVersion Property = "VERSION" // TEXT
183 | PropertyXPublishedTTL Property = "X-PUBLISHED-TTL"
184 | PropertyRefreshInterval Property = "REFRESH-INTERVAL;VALUE=DURATION"
185 | PropertyAttach Property = "ATTACH"
186 | PropertyCategories Property = "CATEGORIES" // TEXT
187 | PropertyClass Property = "CLASS" // TEXT
188 | PropertyColor Property = "COLOR" // TEXT
189 | PropertyComment Property = "COMMENT" // TEXT
190 | PropertyDescription Property = "DESCRIPTION" // TEXT
191 | PropertyXWRCalDesc Property = "X-WR-CALDESC"
192 | PropertyGeo Property = "GEO"
193 | PropertyLocation Property = "LOCATION" // TEXT
194 | PropertyPercentComplete Property = "PERCENT-COMPLETE"
195 | PropertyPriority Property = "PRIORITY"
196 | PropertyResources Property = "RESOURCES" // TEXT
197 | PropertyStatus Property = "STATUS" // TEXT
198 | PropertySummary Property = "SUMMARY" // TEXT
199 | PropertyCompleted Property = "COMPLETED"
200 | PropertyDtend Property = "DTEND"
201 | PropertyDue Property = "DUE"
202 | PropertyDtstart Property = "DTSTART"
203 | PropertyDuration Property = "DURATION"
204 | PropertyFreebusy Property = "FREEBUSY"
205 | PropertyTransp Property = "TRANSP" // TEXT
206 | PropertyTzid Property = "TZID" // TEXT
207 | PropertyTzname Property = "TZNAME" // TEXT
208 | PropertyTzoffsetfrom Property = "TZOFFSETFROM"
209 | PropertyTzoffsetto Property = "TZOFFSETTO"
210 | PropertyTzurl Property = "TZURL"
211 | PropertyAttendee Property = "ATTENDEE"
212 | PropertyContact Property = "CONTACT" // TEXT
213 | PropertyOrganizer Property = "ORGANIZER"
214 | PropertyRecurrenceId Property = "RECURRENCE-ID"
215 | PropertyRelatedTo Property = "RELATED-TO" // TEXT
216 | PropertyUrl Property = "URL"
217 | PropertyUid Property = "UID" // TEXT
218 | PropertyExdate Property = "EXDATE"
219 | PropertyExrule Property = "EXRULE"
220 | PropertyRdate Property = "RDATE"
221 | PropertyRrule Property = "RRULE"
222 | PropertyAction Property = "ACTION" // TEXT
223 | PropertyRepeat Property = "REPEAT"
224 | PropertyTrigger Property = "TRIGGER"
225 | PropertyCreated Property = "CREATED"
226 | PropertyDtstamp Property = "DTSTAMP"
227 | PropertyLastModified Property = "LAST-MODIFIED"
228 | PropertyRequestStatus Property = "REQUEST-STATUS" // TEXT
229 | PropertyName Property = "NAME"
230 | PropertyXWRCalName Property = "X-WR-CALNAME"
231 | PropertyXWRTimezone Property = "X-WR-TIMEZONE"
232 | PropertySequence Property = "SEQUENCE"
233 | PropertyXWRCalID Property = "X-WR-RELCALID"
234 | PropertyTimezoneId Property = "TIMEZONE-ID"
235 | )
236 |
237 | type Parameter string
238 |
239 | func (p Parameter) IsQuoted() bool {
240 | switch p {
241 | case ParameterAltrep:
242 | return true
243 | }
244 | return false
245 | }
246 |
247 | const (
248 | ParameterAltrep Parameter = "ALTREP"
249 | ParameterCn Parameter = "CN"
250 | ParameterCutype Parameter = "CUTYPE"
251 | ParameterDelegatedFrom Parameter = "DELEGATED-FROM"
252 | ParameterDelegatedTo Parameter = "DELEGATED-TO"
253 | ParameterDir Parameter = "DIR"
254 | ParameterEncoding Parameter = "ENCODING"
255 | ParameterFmttype Parameter = "FMTTYPE"
256 | ParameterFbtype Parameter = "FBTYPE"
257 | ParameterLanguage Parameter = "LANGUAGE"
258 | ParameterMember Parameter = "MEMBER"
259 | ParameterParticipationStatus Parameter = "PARTSTAT"
260 | ParameterRange Parameter = "RANGE"
261 | ParameterRelated Parameter = "RELATED"
262 | ParameterReltype Parameter = "RELTYPE"
263 | ParameterRole Parameter = "ROLE"
264 | ParameterRsvp Parameter = "RSVP"
265 | ParameterSentBy Parameter = "SENT-BY"
266 | ParameterTzid Parameter = "TZID"
267 | ParameterValue Parameter = "VALUE"
268 | )
269 |
270 | type ValueDataType string
271 |
272 | const (
273 | ValueDataTypeBinary ValueDataType = "BINARY"
274 | ValueDataTypeBoolean ValueDataType = "BOOLEAN"
275 | ValueDataTypeCalAddress ValueDataType = "CAL-ADDRESS"
276 | ValueDataTypeDate ValueDataType = "DATE"
277 | ValueDataTypeDateTime ValueDataType = "DATE-TIME"
278 | ValueDataTypeDuration ValueDataType = "DURATION"
279 | ValueDataTypeFloat ValueDataType = "FLOAT"
280 | ValueDataTypeInteger ValueDataType = "INTEGER"
281 | ValueDataTypePeriod ValueDataType = "PERIOD"
282 | ValueDataTypeRecur ValueDataType = "RECUR"
283 | ValueDataTypeText ValueDataType = "TEXT"
284 | ValueDataTypeTime ValueDataType = "TIME"
285 | ValueDataTypeUri ValueDataType = "URI"
286 | ValueDataTypeUtcOffset ValueDataType = "UTC-OFFSET"
287 | )
288 |
289 | type CalendarUserType string
290 |
291 | const (
292 | CalendarUserTypeIndividual CalendarUserType = "INDIVIDUAL"
293 | CalendarUserTypeGroup CalendarUserType = "GROUP"
294 | CalendarUserTypeResource CalendarUserType = "RESOURCE"
295 | CalendarUserTypeRoom CalendarUserType = "ROOM"
296 | CalendarUserTypeUnknown CalendarUserType = "UNKNOWN"
297 | )
298 |
299 | func (cut CalendarUserType) KeyValue(_ ...interface{}) (string, []string) {
300 | return string(ParameterCutype), []string{string(cut)}
301 | }
302 |
303 | type FreeBusyTimeType string
304 |
305 | const (
306 | FreeBusyTimeTypeFree FreeBusyTimeType = "FREE"
307 | FreeBusyTimeTypeBusy FreeBusyTimeType = "BUSY"
308 | FreeBusyTimeTypeBusyUnavailable FreeBusyTimeType = "BUSY-UNAVAILABLE"
309 | FreeBusyTimeTypeBusyTentative FreeBusyTimeType = "BUSY-TENTATIVE"
310 | )
311 |
312 | type ParticipationStatus string
313 |
314 | const (
315 | ParticipationStatusNeedsAction ParticipationStatus = "NEEDS-ACTION"
316 | ParticipationStatusAccepted ParticipationStatus = "ACCEPTED"
317 | ParticipationStatusDeclined ParticipationStatus = "DECLINED"
318 | ParticipationStatusTentative ParticipationStatus = "TENTATIVE"
319 | ParticipationStatusDelegated ParticipationStatus = "DELEGATED"
320 | ParticipationStatusCompleted ParticipationStatus = "COMPLETED"
321 | ParticipationStatusInProcess ParticipationStatus = "IN-PROCESS"
322 | )
323 |
324 | func (ps ParticipationStatus) KeyValue(_ ...interface{}) (string, []string) {
325 | return string(ParameterParticipationStatus), []string{string(ps)}
326 | }
327 |
328 | type ObjectStatus string
329 |
330 | const (
331 | ObjectStatusTentative ObjectStatus = "TENTATIVE"
332 | ObjectStatusConfirmed ObjectStatus = "CONFIRMED"
333 | ObjectStatusCancelled ObjectStatus = "CANCELLED"
334 | ObjectStatusNeedsAction ObjectStatus = "NEEDS-ACTION"
335 | ObjectStatusCompleted ObjectStatus = "COMPLETED"
336 | ObjectStatusInProcess ObjectStatus = "IN-PROCESS"
337 | ObjectStatusDraft ObjectStatus = "DRAFT"
338 | ObjectStatusFinal ObjectStatus = "FINAL"
339 | )
340 |
341 | func (ps ObjectStatus) KeyValue(_ ...interface{}) (string, []string) {
342 | return string(PropertyStatus), []string{string(ps)}
343 | }
344 |
345 | type RelationshipType string
346 |
347 | const (
348 | RelationshipTypeChild RelationshipType = "CHILD"
349 | RelationshipTypeParent RelationshipType = "PARENT"
350 | RelationshipTypeSibling RelationshipType = "SIBLING"
351 | )
352 |
353 | type ParticipationRole string
354 |
355 | const (
356 | ParticipationRoleChair ParticipationRole = "CHAIR"
357 | ParticipationRoleReqParticipant ParticipationRole = "REQ-PARTICIPANT"
358 | ParticipationRoleOptParticipant ParticipationRole = "OPT-PARTICIPANT"
359 | ParticipationRoleNonParticipant ParticipationRole = "NON-PARTICIPANT"
360 | )
361 |
362 | func (pr ParticipationRole) KeyValue(_ ...interface{}) (string, []string) {
363 | return string(ParameterRole), []string{string(pr)}
364 | }
365 |
366 | type Action string
367 |
368 | const (
369 | ActionAudio Action = "AUDIO"
370 | ActionDisplay Action = "DISPLAY"
371 | ActionEmail Action = "EMAIL"
372 | ActionProcedure Action = "PROCEDURE"
373 | )
374 |
375 | type Classification string
376 |
377 | const (
378 | ClassificationPublic Classification = "PUBLIC"
379 | ClassificationPrivate Classification = "PRIVATE"
380 | ClassificationConfidential Classification = "CONFIDENTIAL"
381 | )
382 |
383 | type Method string
384 |
385 | const (
386 | MethodPublish Method = "PUBLISH"
387 | MethodRequest Method = "REQUEST"
388 | MethodReply Method = "REPLY"
389 | MethodAdd Method = "ADD"
390 | MethodCancel Method = "CANCEL"
391 | MethodRefresh Method = "REFRESH"
392 | MethodCounter Method = "COUNTER"
393 | MethodDeclinecounter Method = "DECLINECOUNTER"
394 | )
395 |
396 | type CalendarProperty struct {
397 | BaseProperty
398 | }
399 |
400 | type Calendar struct {
401 | Components []Component
402 | CalendarProperties []CalendarProperty
403 | }
404 |
405 | func NewCalendar() *Calendar {
406 | return NewCalendarFor("arran4")
407 | }
408 |
409 | func NewCalendarFor(service string) *Calendar {
410 | c := &Calendar{
411 | Components: []Component{},
412 | CalendarProperties: []CalendarProperty{},
413 | }
414 | c.SetVersion("2.0")
415 | c.SetProductId("-//" + service + "//Golang ICS Library")
416 | return c
417 | }
418 |
419 | func (cal *Calendar) Serialize(ops ...any) string {
420 | b := &strings.Builder{}
421 | // We are intentionally ignoring the return value. _ used to communicate this to lint.
422 | _ = cal.SerializeTo(b, ops...)
423 | return b.String()
424 | }
425 |
426 | type WithLineLength int
427 | type WithNewLine string
428 |
429 | func (cal *Calendar) SerializeTo(w io.Writer, ops ...any) error {
430 | serializeConfig, err := parseSerializeOps(ops)
431 | if err != nil {
432 | return err
433 | }
434 | _, _ = io.WriteString(w, "BEGIN:VCALENDAR"+serializeConfig.NewLine)
435 | for _, p := range cal.CalendarProperties {
436 | err := p.serialize(w, serializeConfig)
437 | if err != nil {
438 | return err
439 | }
440 | }
441 | for _, c := range cal.Components {
442 | err := c.SerializeTo(w, serializeConfig)
443 | if err != nil {
444 | return err
445 | }
446 | }
447 | _, _ = io.WriteString(w, "END:VCALENDAR"+serializeConfig.NewLine)
448 | return nil
449 | }
450 |
451 | type SerializationConfiguration struct {
452 | MaxLength int
453 | NewLine string
454 | PropertyMaxLength int
455 | }
456 |
457 | func parseSerializeOps(ops []any) (*SerializationConfiguration, error) {
458 | serializeConfig := defaultSerializationOptions()
459 | for opi, op := range ops {
460 | switch op := op.(type) {
461 | case WithLineLength:
462 | serializeConfig.MaxLength = int(op)
463 | case WithNewLine:
464 | serializeConfig.NewLine = string(op)
465 | case *SerializationConfiguration:
466 | return op, nil
467 | case error:
468 | return nil, op
469 | default:
470 | return nil, fmt.Errorf("unknown op %d of type %s", opi, reflect.TypeOf(op))
471 | }
472 | }
473 | return serializeConfig, nil
474 | }
475 |
476 | func defaultSerializationOptions() *SerializationConfiguration {
477 | serializeConfig := &SerializationConfiguration{
478 | MaxLength: 75,
479 | PropertyMaxLength: 75,
480 | NewLine: string(NewLine),
481 | }
482 | return serializeConfig
483 | }
484 |
485 | func (cal *Calendar) SetMethod(method Method, params ...PropertyParameter) {
486 | cal.setProperty(PropertyMethod, string(method), params...)
487 | }
488 |
489 | func (cal *Calendar) SetXPublishedTTL(s string, params ...PropertyParameter) {
490 | cal.setProperty(PropertyXPublishedTTL, s, params...)
491 | }
492 |
493 | func (cal *Calendar) SetVersion(s string, params ...PropertyParameter) {
494 | cal.setProperty(PropertyVersion, s, params...)
495 | }
496 |
497 | func (cal *Calendar) SetProductId(s string, params ...PropertyParameter) {
498 | cal.setProperty(PropertyProductId, s, params...)
499 | }
500 |
501 | func (cal *Calendar) SetName(s string, params ...PropertyParameter) {
502 | cal.setProperty(PropertyName, s, params...)
503 | cal.setProperty(PropertyXWRCalName, s, params...)
504 | }
505 |
506 | func (cal *Calendar) SetColor(s string, params ...PropertyParameter) {
507 | cal.setProperty(PropertyColor, s, params...)
508 | }
509 |
510 | func (cal *Calendar) SetXWRCalName(s string, params ...PropertyParameter) {
511 | cal.setProperty(PropertyXWRCalName, s, params...)
512 | }
513 |
514 | func (cal *Calendar) SetXWRCalDesc(s string, params ...PropertyParameter) {
515 | cal.setProperty(PropertyXWRCalDesc, s, params...)
516 | }
517 |
518 | func (cal *Calendar) SetXWRTimezone(s string, params ...PropertyParameter) {
519 | cal.setProperty(PropertyXWRTimezone, s, params...)
520 | }
521 |
522 | func (cal *Calendar) SetXWRCalID(s string, params ...PropertyParameter) {
523 | cal.setProperty(PropertyXWRCalID, s, params...)
524 | }
525 |
526 | func (cal *Calendar) SetDescription(s string, params ...PropertyParameter) {
527 | cal.setProperty(PropertyDescription, s, params...)
528 | }
529 |
530 | func (cal *Calendar) SetLastModified(t time.Time, params ...PropertyParameter) {
531 | cal.setProperty(PropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...)
532 | }
533 |
534 | func (cal *Calendar) SetRefreshInterval(s string, params ...PropertyParameter) {
535 | cal.setProperty(PropertyRefreshInterval, s, params...)
536 | }
537 |
538 | func (cal *Calendar) SetCalscale(s string, params ...PropertyParameter) {
539 | cal.setProperty(PropertyCalscale, s, params...)
540 | }
541 |
542 | func (cal *Calendar) SetUrl(s string, params ...PropertyParameter) {
543 | cal.setProperty(PropertyUrl, s, params...)
544 | }
545 |
546 | func (cal *Calendar) SetTzid(s string, params ...PropertyParameter) {
547 | cal.setProperty(PropertyTzid, s, params...)
548 | }
549 |
550 | func (cal *Calendar) SetTimezoneId(s string, params ...PropertyParameter) {
551 | cal.setProperty(PropertyTimezoneId, s, params...)
552 | }
553 |
554 | func (cal *Calendar) setProperty(property Property, value string, params ...PropertyParameter) {
555 | for i := range cal.CalendarProperties {
556 | if cal.CalendarProperties[i].IANAToken == string(property) {
557 | cal.CalendarProperties[i].Value = value
558 | cal.CalendarProperties[i].ICalParameters = map[string][]string{}
559 | for _, p := range params {
560 | k, v := p.KeyValue()
561 | cal.CalendarProperties[i].ICalParameters[k] = v
562 | }
563 | return
564 | }
565 | }
566 | r := CalendarProperty{
567 | BaseProperty{
568 | IANAToken: string(property),
569 | Value: value,
570 | ICalParameters: map[string][]string{},
571 | },
572 | }
573 | for _, p := range params {
574 | k, v := p.KeyValue()
575 | r.ICalParameters[k] = v
576 | }
577 | cal.CalendarProperties = append(cal.CalendarProperties, r)
578 | }
579 |
580 | func (calendar *Calendar) AddEvent(id string) *VEvent {
581 | e := NewEvent(id)
582 | calendar.Components = append(calendar.Components, e)
583 | return e
584 | }
585 |
586 | func (calendar *Calendar) AddVEvent(e *VEvent) {
587 | calendar.Components = append(calendar.Components, e)
588 | }
589 |
590 | func (calendar *Calendar) Events() (r []*VEvent) {
591 | r = []*VEvent{}
592 | for i := range calendar.Components {
593 | switch event := calendar.Components[i].(type) {
594 | case *VEvent:
595 | r = append(r, event)
596 | }
597 | }
598 | return
599 | }
600 |
601 | func (calendar *Calendar) RemoveEvent(id string) {
602 | for i := range calendar.Components {
603 | switch event := calendar.Components[i].(type) {
604 | case *VEvent:
605 | if event.Id() == id {
606 | if len(calendar.Components) > i+1 {
607 | calendar.Components = append(calendar.Components[:i], calendar.Components[i+1:]...)
608 | } else {
609 | calendar.Components = calendar.Components[:i]
610 | }
611 | return
612 | }
613 | }
614 | }
615 | }
616 |
617 | func WithCustomClient(client *http.Client) *http.Client {
618 | return client
619 | }
620 |
621 | func WithCustomRequest(request *http.Request) *http.Request {
622 | return request
623 | }
624 |
625 | func ParseCalendarFromUrl(url string, opts ...any) (*Calendar, error) {
626 | var ctx context.Context
627 | var req *http.Request
628 | var client HttpClientLike = http.DefaultClient
629 | for opti, opt := range opts {
630 | switch opt := opt.(type) {
631 | case *http.Client:
632 | client = opt
633 | case HttpClientLike:
634 | client = opt
635 | case func() *http.Client:
636 | client = opt()
637 | case *http.Request:
638 | req = opt
639 | case func() *http.Request:
640 | req = opt()
641 | case context.Context:
642 | ctx = opt
643 | case func() context.Context:
644 | ctx = opt()
645 | default:
646 | return nil, fmt.Errorf("unknown optional argument %d on ParseCalendarFromUrl: %s", opti, reflect.TypeOf(opt))
647 | }
648 | }
649 | if ctx == nil {
650 | ctx = context.Background()
651 | }
652 | if req == nil {
653 | var err error
654 | req, err = http.NewRequestWithContext(ctx, "GET", url, nil)
655 | if err != nil {
656 | return nil, fmt.Errorf("creating http request: %w", err)
657 | }
658 | }
659 | return parseCalendarFromHttpRequest(client, req)
660 | }
661 |
662 | type HttpClientLike interface {
663 | Do(req *http.Request) (*http.Response, error)
664 | }
665 |
666 | func parseCalendarFromHttpRequest(client HttpClientLike, request *http.Request) (*Calendar, error) {
667 | resp, err := client.Do(request)
668 | if err != nil {
669 | return nil, fmt.Errorf("http request: %w", err)
670 | }
671 | defer func(closer io.ReadCloser) {
672 | if derr := closer.Close(); derr != nil && err == nil {
673 | err = fmt.Errorf("http request close: %w", derr)
674 | }
675 | }(resp.Body)
676 | var cal *Calendar
677 | cal, err = ParseCalendar(resp.Body)
678 | // This allows the defer func to change the error
679 | return cal, err
680 | }
681 |
682 | func ParseCalendar(r io.Reader) (*Calendar, error) {
683 | state := "begin"
684 | c := &Calendar{}
685 | cs := NewCalendarStream(r)
686 | cont := true
687 | for ln := 0; cont; ln++ {
688 | l, err := cs.ReadLine()
689 | if err != nil {
690 | switch err {
691 | case io.EOF:
692 | cont = false
693 | default:
694 | return c, err
695 | }
696 | }
697 | if l == nil || len(*l) == 0 {
698 | continue
699 | }
700 | line, err := ParseProperty(*l)
701 | if err != nil {
702 | return nil, fmt.Errorf("parsing line %d: %w", ln, err)
703 | }
704 | if line == nil {
705 | return nil, fmt.Errorf("parsing calendar line %d", ln)
706 | }
707 | switch state {
708 | case "begin":
709 | switch line.IANAToken {
710 | case "BEGIN":
711 | switch line.Value {
712 | case "VCALENDAR":
713 | state = "properties"
714 | default:
715 | return nil, errors.New("malformed calendar; expected a vcalendar")
716 | }
717 | default:
718 | return nil, errors.New("malformed calendar; expected begin")
719 | }
720 | case "properties":
721 | switch line.IANAToken {
722 | case "END":
723 | switch line.Value {
724 | case "VCALENDAR":
725 | state = "end"
726 | default:
727 | return nil, errors.New("malformed calendar; expected end")
728 | }
729 | case "BEGIN":
730 | state = "components"
731 | default: // TODO put in all the supported types for type switching etc.
732 | c.CalendarProperties = append(c.CalendarProperties, CalendarProperty{*line})
733 | }
734 | if state != "components" {
735 | break
736 | }
737 | fallthrough
738 | case "components":
739 | switch line.IANAToken {
740 | case "END":
741 | switch line.Value {
742 | case "VCALENDAR":
743 | state = "end"
744 | default:
745 | return nil, errors.New("malformed calendar; expected end")
746 | }
747 | case "BEGIN":
748 | co, err := GeneralParseComponent(cs, line)
749 | if err != nil {
750 | return nil, err
751 | }
752 | if co != nil {
753 | c.Components = append(c.Components, co)
754 | }
755 | default:
756 | return nil, errors.New("malformed calendar; expected begin or end")
757 | }
758 | case "end":
759 | return nil, errors.New("malformed calendar; unexpected end")
760 | default:
761 | return nil, errors.New("malformed calendar; bad state")
762 | }
763 | }
764 | return c, nil
765 | }
766 |
767 | type CalendarStream struct {
768 | r io.Reader
769 | b *bufio.Reader
770 | }
771 |
772 | func NewCalendarStream(r io.Reader) *CalendarStream {
773 | return &CalendarStream{
774 | r: r,
775 | b: bufio.NewReader(r),
776 | }
777 | }
778 |
779 | func (cs *CalendarStream) ReadLine() (*ContentLine, error) {
780 | r := []byte{}
781 | c := true
782 | var err error
783 | for c {
784 | var b []byte
785 | b, err = cs.b.ReadBytes('\n')
786 | switch {
787 | case len(b) == 0:
788 | if err == nil {
789 | continue
790 | } else {
791 | c = false
792 | }
793 | case b[len(b)-1] == '\n':
794 | o := 1
795 | if len(b) > 1 && b[len(b)-2] == '\r' {
796 | o = 2
797 | }
798 | p, err := cs.b.Peek(1)
799 | r = append(r, b[:len(b)-o]...)
800 | if err == io.EOF {
801 | c = false
802 | }
803 | switch {
804 | case len(p) == 0:
805 | c = false
806 | case p[0] == ' ' || p[0] == '\t':
807 | _, _ = cs.b.Discard(1) // nolint:errcheck
808 | default:
809 | c = false
810 | }
811 | default:
812 | r = append(r, b...)
813 | }
814 | switch err {
815 | case nil:
816 | if len(r) == 0 {
817 | c = true
818 | }
819 | case io.EOF:
820 | c = false
821 | default:
822 | return nil, err
823 | }
824 | }
825 | if len(r) == 0 && err != nil {
826 | return nil, err
827 | }
828 | cl := ContentLine(r)
829 | return &cl, err
830 | }
831 |
--------------------------------------------------------------------------------
/calendar_fuzz_test.go:
--------------------------------------------------------------------------------
1 | //go:build go1.18
2 | // +build go1.18
3 |
4 | package ics
5 |
6 | import (
7 | "bytes"
8 | "os"
9 | "testing"
10 |
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | func FuzzParseCalendar(f *testing.F) {
15 | ics, err := os.ReadFile("testdata/timeparsing.ics")
16 | require.NoError(f, err)
17 | f.Add(ics)
18 | f.Fuzz(func(t *testing.T, ics []byte) {
19 | _, err := ParseCalendar(bytes.NewReader(ics))
20 | t.Log(err)
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/calendar_serialization_test.go:
--------------------------------------------------------------------------------
1 | //go:build go1.16
2 | // +build go1.16
3 |
4 | package ics
5 |
6 | import (
7 | "bytes"
8 | "fmt"
9 | "os"
10 | "path/filepath"
11 | "strings"
12 | "testing"
13 |
14 | "github.com/google/go-cmp/cmp"
15 | "github.com/stretchr/testify/require"
16 | )
17 |
18 | func TestCalendar_ReSerialization(t *testing.T) {
19 | testDir := "testdata/serialization"
20 | expectedDir := filepath.Join(testDir, "expected")
21 | actualDir := filepath.Join(testDir, "actual")
22 |
23 | testFileNames := []string{
24 | "input1.ics",
25 | "input2.ics",
26 | "input3.ics",
27 | "input4.ics",
28 | "input5.ics",
29 | "input6.ics",
30 | "input7.ics",
31 | }
32 |
33 | for _, filename := range testFileNames {
34 | fp := filepath.Join(testDir, filename)
35 | t.Run(fmt.Sprintf("compare serialized -> deserialized -> serialized: %s", fp), func(t *testing.T) {
36 | //given
37 | originalSeriailizedCal, err := os.ReadFile(fp)
38 | require.NoError(t, err)
39 |
40 | //when
41 | deserializedCal, err := ParseCalendar(bytes.NewReader(originalSeriailizedCal))
42 | require.NoError(t, err)
43 | serializedCal := deserializedCal.Serialize(WithNewLineWindows)
44 |
45 | //then
46 | expectedCal, err := os.ReadFile(filepath.Join(expectedDir, filename))
47 | require.NoError(t, err)
48 | if diff := cmp.Diff(string(expectedCal), serializedCal); diff != "" {
49 | err = os.MkdirAll(actualDir, 0755)
50 | if err != nil {
51 | t.Logf("failed to create actual dir: %v", err)
52 | }
53 | err = os.WriteFile(filepath.Join(actualDir, filename), []byte(serializedCal), 0644)
54 | if err != nil {
55 | t.Logf("failed to write actual file: %v", err)
56 | }
57 | t.Error(diff)
58 | }
59 | })
60 |
61 | t.Run(fmt.Sprintf("compare deserialized -> serialized -> deserialized: %s", filename), func(t *testing.T) {
62 | //given
63 | loadIcsContent, err := os.ReadFile(filepath.Join(testDir, filename))
64 | require.NoError(t, err)
65 | originalDeserializedCal, err := ParseCalendar(bytes.NewReader(loadIcsContent))
66 | require.NoError(t, err)
67 |
68 | //when
69 | serializedCal := originalDeserializedCal.Serialize()
70 | deserializedCal, err := ParseCalendar(strings.NewReader(serializedCal))
71 | require.NoError(t, err)
72 |
73 | //then
74 | if diff := cmp.Diff(originalDeserializedCal, deserializedCal); diff != "" {
75 | t.Error(diff)
76 | }
77 | })
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/calendar_test.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "bytes"
5 | "embed"
6 | _ "embed"
7 | "io"
8 | "io/fs"
9 | "net/http"
10 | "path/filepath"
11 | "regexp"
12 | "strings"
13 | "testing"
14 | "time"
15 | "unicode/utf8"
16 |
17 | "github.com/google/go-cmp/cmp"
18 | "github.com/stretchr/testify/assert"
19 | "github.com/stretchr/testify/require"
20 | )
21 |
22 | var (
23 | //go:embed testdata/*
24 | TestData embed.FS
25 | )
26 |
27 | func TestTimeParsing(t *testing.T) {
28 | calFile, err := TestData.Open("testdata/timeparsing.ics")
29 | if err != nil {
30 | t.Errorf("read file: %v", err)
31 | }
32 | cal, err := ParseCalendar(calFile)
33 | if err != nil {
34 | t.Errorf("parse calendar: %v", err)
35 | }
36 |
37 | cphLoc, locErr := time.LoadLocation("Europe/Copenhagen")
38 | if locErr != nil {
39 | t.Errorf("could not load location")
40 | }
41 |
42 | var tests = []struct {
43 | name string
44 | uid string
45 | start time.Time
46 | end time.Time
47 | allDayStart time.Time
48 | allDayEnd time.Time
49 | }{
50 | {
51 | "FORM 1",
52 | "be7c9690-d42a-40ef-b82f-1634dc5033b4",
53 | time.Date(1998, 1, 18, 23, 0, 0, 0, time.Local),
54 | time.Date(1998, 1, 19, 23, 0, 0, 0, time.Local),
55 | time.Date(1998, 1, 18, 0, 0, 0, 0, time.Local),
56 | time.Date(1998, 1, 19, 0, 0, 0, 0, time.Local),
57 | },
58 | {
59 | "FORM 2",
60 | "53634aed-1b7d-4d85-aa38-ede76a2e4fe3",
61 | time.Date(2022, 1, 22, 17, 0, 0, 0, time.UTC),
62 | time.Date(2022, 1, 22, 20, 0, 0, 0, time.UTC),
63 | time.Date(2022, 1, 22, 0, 0, 0, 0, time.UTC),
64 | time.Date(2022, 1, 22, 0, 0, 0, 0, time.UTC),
65 | },
66 | {
67 | "FORM 3",
68 | "269cf715-4e14-4a10-8753-f2feeb9d060e",
69 | time.Date(2021, 12, 7, 14, 0, 0, 0, cphLoc),
70 | time.Date(2021, 12, 7, 15, 0, 0, 0, cphLoc),
71 | time.Date(2021, 12, 7, 0, 0, 0, 0, cphLoc),
72 | time.Date(2021, 12, 7, 0, 0, 0, 0, cphLoc),
73 | },
74 | {
75 | "Unknown local date, with 'VALUE'",
76 | "fb54680e-7f69-46d3-9632-00aed2469f7b",
77 | time.Date(2021, 6, 27, 0, 0, 0, 0, time.Local),
78 | time.Date(2021, 6, 28, 0, 0, 0, 0, time.Local),
79 | time.Date(2021, 6, 27, 0, 0, 0, 0, time.Local),
80 | time.Date(2021, 6, 28, 0, 0, 0, 0, time.Local),
81 | },
82 | {
83 | "Unknown UTC date",
84 | "62475ad0-a76c-4fab-8e68-f99209afcca6",
85 | time.Date(2021, 5, 27, 0, 0, 0, 0, time.UTC),
86 | time.Date(2021, 5, 28, 0, 0, 0, 0, time.UTC),
87 | time.Date(2021, 5, 27, 0, 0, 0, 0, time.UTC),
88 | time.Date(2021, 5, 28, 0, 0, 0, 0, time.UTC),
89 | },
90 | }
91 |
92 | assertTime := func(evtUid string, exp time.Time, timeFunc func() (given time.Time, err error)) {
93 | given, err := timeFunc()
94 | if err == nil {
95 | if !exp.Equal(given) {
96 | t.Errorf("no match on '%s', expected=%v != given=%v", evtUid, exp, given)
97 | }
98 | } else {
99 | t.Errorf("get time on uid '%s', %v", evtUid, err)
100 | }
101 | }
102 | eventMap := map[string]*VEvent{}
103 | for _, e := range cal.Events() {
104 | eventMap[e.Id()] = e
105 | }
106 |
107 | for _, tt := range tests {
108 | t.Run(tt.uid, func(t *testing.T) {
109 | evt, ok := eventMap[tt.uid]
110 | if !ok {
111 | t.Errorf("Test %#v, event UID not found, %s", tt.name, tt.uid)
112 | return
113 | }
114 |
115 | assertTime(tt.uid, tt.start, evt.GetStartAt)
116 | assertTime(tt.uid, tt.end, evt.GetEndAt)
117 | assertTime(tt.uid, tt.allDayStart, evt.GetAllDayStartAt)
118 | assertTime(tt.uid, tt.allDayEnd, evt.GetAllDayEndAt)
119 | })
120 | }
121 | }
122 |
123 | func TestCalendarStream(t *testing.T) {
124 | i := `
125 | ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:
126 | mailto:employee-A@example.com
127 | DESCRIPTION:Project XYZ Review Meeting
128 | CATEGORIES:MEETING
129 | CLASS:PUBLIC
130 | `
131 | expected := []ContentLine{
132 | ContentLine("ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@example.com"),
133 | ContentLine("DESCRIPTION:Project XYZ Review Meeting"),
134 | ContentLine("CATEGORIES:MEETING"),
135 | ContentLine("CLASS:PUBLIC"),
136 | }
137 | c := NewCalendarStream(strings.NewReader(i))
138 | cont := true
139 | for i := 0; cont; i++ {
140 | l, err := c.ReadLine()
141 | if err != nil {
142 | switch err {
143 | case io.EOF:
144 | cont = false
145 | default:
146 | t.Logf("Unknown error; %v", err)
147 | t.Fail()
148 | return
149 | }
150 | }
151 | if l == nil {
152 | if err == io.EOF && i == len(expected) {
153 | cont = false
154 | } else {
155 | t.Logf("Nil response...")
156 | t.Fail()
157 | return
158 | }
159 | }
160 | if i < len(expected) {
161 | if string(*l) != string(expected[i]) {
162 | t.Logf("Got %s expected %s", string(*l), string(expected[i]))
163 | t.Fail()
164 | }
165 | } else if l != nil {
166 | t.Logf("Larger than expected")
167 | t.Fail()
168 | return
169 | }
170 | }
171 | }
172 |
173 | func TestRfc5545Sec4Examples(t *testing.T) {
174 | rnReplace := regexp.MustCompile("\r?\n")
175 |
176 | err := fs.WalkDir(TestData, "testdata/rfc5545sec4", func(path string, info fs.DirEntry, err error) error {
177 | if err != nil {
178 | return err
179 | }
180 | if info.IsDir() {
181 | return nil
182 | }
183 |
184 | inputBytes, err := fs.ReadFile(TestData, path)
185 | if err != nil {
186 | return err
187 | }
188 |
189 | input := rnReplace.ReplaceAllString(string(inputBytes), "\r\n")
190 | structure, err := ParseCalendar(strings.NewReader(input))
191 | if assert.Nil(t, err, path) {
192 | // This should fail as the sample data doesn't conform to https://tools.ietf.org/html/rfc5545#page-45
193 | // Probably due to RFC width guides
194 | assert.NotNil(t, structure)
195 |
196 | output := structure.Serialize()
197 | assert.NotEqual(t, input, output)
198 | }
199 |
200 | return nil
201 | })
202 |
203 | if err != nil {
204 | t.Fatalf("cannot read test directory: %v", err)
205 | }
206 | }
207 |
208 | func TestLineFolding(t *testing.T) {
209 | testCases := []struct {
210 | name string
211 | input string
212 | output string
213 | }{
214 | {
215 | name: "fold lines at nearest space",
216 | input: "some really long line with spaces to fold on and the line should fold",
217 | output: `BEGIN:VCALENDAR
218 | VERSION:2.0
219 | PRODID:-//arran4//Golang ICS Library
220 | DESCRIPTION:some really long line with spaces to fold on and the line
221 | should fold
222 | END:VCALENDAR
223 | `,
224 | },
225 | {
226 | name: "fold lines if no space",
227 | input: "somereallylonglinewithnospacestofoldonandthelineshouldfoldtothenextline",
228 | output: `BEGIN:VCALENDAR
229 | VERSION:2.0
230 | PRODID:-//arran4//Golang ICS Library
231 | DESCRIPTION:somereallylonglinewithnospacestofoldonandthelineshouldfoldtothe
232 | nextline
233 | END:VCALENDAR
234 | `,
235 | },
236 | {
237 | name: "fold lines at nearest space",
238 | input: "some really long line with spaces howeverthelastpartofthelineisactuallytoolongtofitonsowehavetofoldpartwaythrough",
239 | output: `BEGIN:VCALENDAR
240 | VERSION:2.0
241 | PRODID:-//arran4//Golang ICS Library
242 | DESCRIPTION:some really long line with spaces
243 | howeverthelastpartofthelineisactuallytoolongtofitonsowehavetofoldpartwayt
244 | hrough
245 | END:VCALENDAR
246 | `,
247 | },
248 | {
249 | name: "75 chars line should not fold",
250 | input: " this line is exactly 75 characters long with the property name",
251 | output: `BEGIN:VCALENDAR
252 | VERSION:2.0
253 | PRODID:-//arran4//Golang ICS Library
254 | DESCRIPTION: this line is exactly 75 characters long with the property name
255 | END:VCALENDAR
256 | `,
257 | },
258 | {
259 | name: "runes should not be split",
260 | // the 75 bytes mark is in the middle of a rune
261 | input: "éé界世界世界世界世界世界世界世界世界世界世界世界世界",
262 | output: `BEGIN:VCALENDAR
263 | VERSION:2.0
264 | PRODID:-//arran4//Golang ICS Library
265 | DESCRIPTION:éé界世界世界世界世界世界世界世界世界世界
266 | 世界世界世界
267 | END:VCALENDAR
268 | `,
269 | },
270 | }
271 |
272 | for _, tc := range testCases {
273 | t.Run(tc.name, func(t *testing.T) {
274 | c := NewCalendar()
275 | c.SetDescription(tc.input)
276 | // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
277 | text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n")
278 |
279 | assert.Equal(t, tc.output, text)
280 | assert.True(t, utf8.ValidString(text), "Serialized .ics calendar isn't valid UTF-8 string")
281 | })
282 | }
283 | }
284 |
285 | func TestParseCalendar(t *testing.T) {
286 | testCases := []struct {
287 | name string
288 | input string
289 | output string
290 | }{
291 | {
292 | name: "test custom fields in calendar",
293 | input: `BEGIN:VCALENDAR
294 | VERSION:2.0
295 | X-CUSTOM-FIELD:test
296 | PRODID:-//arran4//Golang ICS Library
297 | DESCRIPTION:test
298 | END:VCALENDAR
299 | `,
300 | output: `BEGIN:VCALENDAR
301 | VERSION:2.0
302 | X-CUSTOM-FIELD:test
303 | PRODID:-//arran4//Golang ICS Library
304 | DESCRIPTION:test
305 | END:VCALENDAR
306 | `,
307 | },
308 | {
309 | name: "test multiline description - multiple custom fields suppress",
310 | input: `BEGIN:VCALENDAR
311 | VERSION:2.0
312 | X-CUSTOM-FIELD:test
313 | PRODID:-//arran4//Golang ICS Library
314 | DESCRIPTION:test
315 | BEGIN:VEVENT
316 | DESCRIPTION:blablablablablablablablablablablablablablablabl
317 | testtesttest
318 | CLASS:PUBLIC
319 | END:VEVENT
320 | END:VCALENDAR
321 | `,
322 | output: `BEGIN:VCALENDAR
323 | VERSION:2.0
324 | X-CUSTOM-FIELD:test
325 | PRODID:-//arran4//Golang ICS Library
326 | DESCRIPTION:test
327 | BEGIN:VEVENT
328 | DESCRIPTION:blablablablablablablablablablablablablablablabltesttesttest
329 | CLASS:PUBLIC
330 | END:VEVENT
331 | END:VCALENDAR
332 | `,
333 | },
334 | {
335 | name: "test semicolon in attendee property parameter",
336 | input: `BEGIN:VCALENDAR
337 | VERSION:2.0
338 | X-CUSTOM-FIELD:test
339 | PRODID:-//arran4//Golang ICS Library
340 | DESCRIPTION:test
341 | BEGIN:VEVENT
342 | ATTENDEE;CN=Test\;User:mailto:user@example.com
343 | CLASS:PUBLIC
344 | END:VEVENT
345 | END:VCALENDAR
346 | `,
347 | output: `BEGIN:VCALENDAR
348 | VERSION:2.0
349 | X-CUSTOM-FIELD:test
350 | PRODID:-//arran4//Golang ICS Library
351 | DESCRIPTION:test
352 | BEGIN:VEVENT
353 | ATTENDEE;CN=Test\;User:mailto:user@example.com
354 | CLASS:PUBLIC
355 | END:VEVENT
356 | END:VCALENDAR
357 | `,
358 | },
359 | {
360 | name: "test RRULE escaping",
361 | input: `BEGIN:VCALENDAR
362 | VERSION:2.0
363 | X-CUSTOM-FIELD:test
364 | PRODID:-//arran4//Golang ICS Library
365 | DESCRIPTION:test
366 | BEGIN:VEVENT
367 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=SU
368 | CLASS:PUBLIC
369 | END:VEVENT
370 | END:VCALENDAR
371 | `,
372 | output: `BEGIN:VCALENDAR
373 | VERSION:2.0
374 | X-CUSTOM-FIELD:test
375 | PRODID:-//arran4//Golang ICS Library
376 | DESCRIPTION:test
377 | BEGIN:VEVENT
378 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=SU
379 | CLASS:PUBLIC
380 | END:VEVENT
381 | END:VCALENDAR
382 | `,
383 | },
384 | }
385 |
386 | for _, tc := range testCases {
387 | t.Run(tc.name, func(t *testing.T) {
388 | c, err := ParseCalendar(strings.NewReader(tc.input))
389 | if !assert.NoError(t, err) {
390 | return
391 | }
392 |
393 | // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
394 | text := strings.ReplaceAll(c.Serialize(), "\r\n", "\n")
395 | if !assert.Equal(t, tc.output, text) {
396 | return
397 | }
398 | })
399 | }
400 | }
401 |
402 | func TestIssue52(t *testing.T) {
403 | err := fs.WalkDir(TestData, "testdata/issue52", func(path string, info fs.DirEntry, _ error) error {
404 | if info.IsDir() {
405 | return nil
406 | }
407 | _, fn := filepath.Split(path)
408 | t.Run(fn, func(t *testing.T) {
409 | f, err := TestData.Open(path)
410 | if err != nil {
411 | t.Fatalf("Error reading file: %s", err)
412 | }
413 | defer f.Close()
414 |
415 | if _, err := ParseCalendar(f); err != nil {
416 | t.Fatalf("Error parsing file: %s", err)
417 | }
418 |
419 | })
420 | return nil
421 | })
422 |
423 | if err != nil {
424 | t.Fatalf("cannot read test directory: %v", err)
425 | }
426 | }
427 |
428 | func TestIssue97(t *testing.T) {
429 | err := fs.WalkDir(TestData, "testdata/issue97", func(path string, d fs.DirEntry, err error) error {
430 | if err != nil {
431 | return err
432 | }
433 | if d.IsDir() {
434 | return nil
435 | }
436 | if !strings.HasSuffix(d.Name(), ".ics") && !strings.HasSuffix(d.Name(), ".ics_disabled") {
437 | return nil
438 | }
439 | t.Run(path, func(t *testing.T) {
440 | if strings.HasSuffix(d.Name(), ".ics_disabled") {
441 | t.Skipf("Test disabled")
442 | }
443 | b, err := TestData.ReadFile(path)
444 | if err != nil {
445 | t.Fatalf("Error reading file: %s", err)
446 | }
447 | ics, err := ParseCalendar(bytes.NewReader(b))
448 | if err != nil {
449 | t.Fatalf("Error parsing file: %s", err)
450 | }
451 |
452 | got := ics.Serialize(WithLineLength(74))
453 | if diff := cmp.Diff(string(b), got, cmp.Transformer("ToUnixText", func(a string) string {
454 | return strings.ReplaceAll(a, "\r\n", "\n")
455 | })); diff != "" {
456 | t.Errorf("ParseCalendar() mismatch (-want +got):\n%s", diff)
457 | t.Errorf("Complete got:\b%s", got)
458 | }
459 | })
460 | return nil
461 | })
462 |
463 | if err != nil {
464 | t.Fatalf("cannot read test directory: %v", err)
465 | }
466 | }
467 |
468 | type MockHttpClient struct {
469 | Response *http.Response
470 | Error error
471 | }
472 |
473 | func (m *MockHttpClient) Do(req *http.Request) (*http.Response, error) {
474 | return m.Response, m.Error
475 | }
476 |
477 | var (
478 | _ HttpClientLike = &MockHttpClient{}
479 | //go:embed "testdata/rfc5545sec4/input1.ics"
480 | input1TestData []byte
481 | )
482 |
483 | func TestIssue77(t *testing.T) {
484 | url := "https://proseconsult.umontpellier.fr/jsp/custom/modules/plannings/direct_cal.jsp?data=58c99062bab31d256bee14356aca3f2423c0f022cb9660eba051b2653be722c4c7f281e4e3ad06b85d3374100ac416a4dc5c094f7d1a811b903031bde802c7f50e0bd1077f9461bed8f9a32b516a3c63525f110c026ed6da86f487dd451ca812c1c60bb40b1502b6511435cf9908feb2166c54e36382c1aa3eb0ff5cb8980cdb,1"
485 |
486 | _, err := ParseCalendarFromUrl(url, &MockHttpClient{
487 | Response: &http.Response{
488 | StatusCode: 200,
489 | Body: io.NopCloser(bytes.NewReader(input1TestData)),
490 | },
491 | })
492 |
493 | if err != nil {
494 | t.Fatalf("Error reading file: %s", err)
495 | }
496 | }
497 |
498 | func BenchmarkSerialize(b *testing.B) {
499 | calFile, err := TestData.Open("testdata/serialization/input2.ics")
500 | require.NoError(b, err)
501 |
502 | cal, err := ParseCalendar(calFile)
503 | require.NoError(b, err)
504 |
505 | b.ResetTimer()
506 | for i := 0; i < b.N; i++ {
507 | cal.Serialize()
508 | }
509 | }
510 |
--------------------------------------------------------------------------------
/cmd/issues/test97_1/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | ics "github.com/arran4/golang-ical"
6 | "net/url"
7 | )
8 |
9 | func main() {
10 | i := ics.NewCalendarFor("Mozilla.org/NONSGML Mozilla Calendar V1.1")
11 | tz := i.AddTimezone("Europe/Berlin")
12 | tz.AddProperty(ics.ComponentPropertyExtended("TZINFO"), "Europe/Berlin[2024a]")
13 | tzstd := tz.AddStandard()
14 | tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetto), "+010000")
15 | tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzoffsetfrom), "+005328")
16 | tzstd.AddProperty(ics.ComponentProperty(ics.PropertyTzname), "Europe/Berlin(STD)")
17 | tzstd.AddProperty(ics.ComponentProperty(ics.PropertyDtstart), "18930401T000000")
18 | tzstd.AddProperty(ics.ComponentProperty(ics.PropertyRdate), "18930401T000000")
19 | vEvent := i.AddEvent("d23cef0d-9e58-43c4-9391-5ad8483ca346")
20 | vEvent.AddProperty(ics.ComponentPropertyCreated, "20240929T120640Z")
21 | vEvent.AddProperty(ics.ComponentPropertyLastModified, "20240929T120731Z")
22 | vEvent.AddProperty(ics.ComponentPropertyDtstamp, "20240929T120731Z")
23 | vEvent.AddProperty(ics.ComponentPropertySummary, "Test Event")
24 | vEvent.AddProperty(ics.ComponentPropertyDtStart, "20240929T144500", ics.WithTZID("Europe/Berlin"))
25 | vEvent.AddProperty(ics.ComponentPropertyDtEnd, "20240929T154500", ics.WithTZID("Europe/Berlin"))
26 | vEvent.AddProperty(ics.ComponentPropertyTransp, "OPAQUE")
27 | vEvent.AddProperty(ics.ComponentPropertyLocation, "Github")
28 | uri := &url.URL{
29 | Scheme: "data",
30 | 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.",
31 | }
32 | vEvent.AddProperty(ics.ComponentPropertyDescription, "I want a custom linkout for Thunderbird.\nThis is the Github Issue.", ics.WithAlternativeRepresentation(uri))
33 | fmt.Println(i.Serialize())
34 | }
35 |
--------------------------------------------------------------------------------
/components.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "encoding/base64"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "regexp"
9 | "strconv"
10 | "strings"
11 | "time"
12 | )
13 |
14 | // Component To determine what this is please use a type switch or typecast to each of:
15 | // - *VEvent
16 | // - *VTodo
17 | // - *VBusy
18 | // - *VJournal
19 | type Component interface {
20 | UnknownPropertiesIANAProperties() []IANAProperty
21 | SubComponents() []Component
22 | SerializeTo(b io.Writer, serialConfig *SerializationConfiguration) error
23 | }
24 |
25 | var (
26 | _ Component = (*VEvent)(nil)
27 | _ Component = (*VTodo)(nil)
28 | _ Component = (*VBusy)(nil)
29 | _ Component = (*VJournal)(nil)
30 | )
31 |
32 | type ComponentBase struct {
33 | Properties []IANAProperty
34 | Components []Component
35 | }
36 |
37 | func (cb *ComponentBase) UnknownPropertiesIANAProperties() []IANAProperty {
38 | return cb.Properties
39 | }
40 |
41 | func (cb *ComponentBase) SubComponents() []Component {
42 | return cb.Components
43 | }
44 |
45 | func (cb *ComponentBase) serializeThis(writer io.Writer, componentType ComponentType, serialConfig *SerializationConfiguration) error {
46 | _, _ = io.WriteString(writer, "BEGIN:"+string(componentType)+serialConfig.NewLine)
47 | for _, p := range cb.Properties {
48 | err := p.serialize(writer, serialConfig)
49 | if err != nil {
50 | return err
51 | }
52 | }
53 | for _, c := range cb.Components {
54 | err := c.SerializeTo(writer, serialConfig)
55 | if err != nil {
56 | return err
57 | }
58 | }
59 | _, err := io.WriteString(writer, "END:"+string(componentType)+serialConfig.NewLine)
60 | return err
61 | }
62 |
63 | func NewComponent(uniqueId string) ComponentBase {
64 | return ComponentBase{
65 | Properties: []IANAProperty{
66 | {BaseProperty{IANAToken: string(ComponentPropertyUniqueId), Value: uniqueId}},
67 | },
68 | }
69 | }
70 |
71 | // GetProperty returns the first match for the particular property you're after. Please consider using:
72 | // ComponentProperty.Required to determine if GetProperty or GetProperties is more appropriate.
73 | func (cb *ComponentBase) GetProperty(componentProperty ComponentProperty) *IANAProperty {
74 | for i := range cb.Properties {
75 | if cb.Properties[i].IANAToken == string(componentProperty) {
76 | return &cb.Properties[i]
77 | }
78 | }
79 | return nil
80 | }
81 |
82 | // GetProperties returns all matches for the particular property you're after. Please consider using:
83 | // ComponentProperty.Singular/ComponentProperty.Multiple to determine if GetProperty or GetProperties is more appropriate.
84 | func (cb *ComponentBase) GetProperties(componentProperty ComponentProperty) []*IANAProperty {
85 | var result []*IANAProperty
86 | for i := range cb.Properties {
87 | if cb.Properties[i].IANAToken == string(componentProperty) {
88 | result = append(result, &cb.Properties[i])
89 | }
90 | }
91 | return result
92 | }
93 |
94 | // HasProperty returns true if a component property is in the component.
95 | func (cb *ComponentBase) HasProperty(componentProperty ComponentProperty) bool {
96 | for i := range cb.Properties {
97 | if cb.Properties[i].IANAToken == string(componentProperty) {
98 | return true
99 | }
100 | }
101 | return false
102 | }
103 |
104 | // SetProperty replaces the first match for the particular property you're setting, otherwise adds it. Please consider using:
105 | // ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is
106 | // more appropriate.
107 | func (cb *ComponentBase) SetProperty(property ComponentProperty, value string, params ...PropertyParameter) {
108 | for i := range cb.Properties {
109 | if cb.Properties[i].IANAToken == string(property) {
110 | cb.Properties[i].Value = value
111 | cb.Properties[i].ICalParameters = map[string][]string{}
112 | for _, p := range params {
113 | k, v := p.KeyValue()
114 | cb.Properties[i].ICalParameters[k] = v
115 | }
116 | return
117 | }
118 | }
119 | cb.AddProperty(property, value, params...)
120 | }
121 |
122 | // ReplaceProperty replaces all matches of the particular property you're setting, otherwise adds it. Returns a slice
123 | // of removed properties. Please consider using:
124 | // ComponentProperty.Singular/ComponentProperty.Multiple to determine if AddProperty, SetProperty or ReplaceProperty is
125 | // more appropriate.
126 | func (cb *ComponentBase) ReplaceProperty(property ComponentProperty, value string, params ...PropertyParameter) []IANAProperty {
127 | removed := cb.RemoveProperty(property)
128 | cb.AddProperty(property, value, params...)
129 | return removed
130 | }
131 |
132 | // AddProperty appends a property
133 | func (cb *ComponentBase) AddProperty(property ComponentProperty, value string, params ...PropertyParameter) {
134 | r := IANAProperty{
135 | BaseProperty{
136 | IANAToken: string(property),
137 | Value: value,
138 | ICalParameters: map[string][]string{},
139 | },
140 | }
141 | for _, p := range params {
142 | k, v := p.KeyValue()
143 | r.ICalParameters[k] = v
144 | }
145 | cb.Properties = append(cb.Properties, r)
146 | }
147 |
148 | // RemoveProperty removes from the component all properties that is of a particular property type, returning an slice of
149 | // removed entities
150 | func (cb *ComponentBase) RemoveProperty(removeProp ComponentProperty) []IANAProperty {
151 | var keptProperties []IANAProperty
152 | var removedProperties []IANAProperty
153 | for i := range cb.Properties {
154 | if cb.Properties[i].IANAToken != string(removeProp) {
155 | keptProperties = append(keptProperties, cb.Properties[i])
156 | } else {
157 | removedProperties = append(removedProperties, cb.Properties[i])
158 | }
159 | }
160 | cb.Properties = keptProperties
161 | return removedProperties
162 | }
163 |
164 | // RemovePropertyByValue removes from the component all properties that has a particular property type and value,
165 | // return a count of removed properties
166 | func (cb *ComponentBase) RemovePropertyByValue(removeProp ComponentProperty, value string) []IANAProperty {
167 | return cb.RemovePropertyByFunc(removeProp, func(p IANAProperty) bool {
168 | return p.Value == value
169 | })
170 | }
171 |
172 | // RemovePropertyByFunc removes from the component all properties that has a particular property type and the function
173 | // remove returns true for
174 | func (cb *ComponentBase) RemovePropertyByFunc(removeProp ComponentProperty, remove func(p IANAProperty) bool) []IANAProperty {
175 | var keptProperties []IANAProperty
176 | var removedProperties []IANAProperty
177 | for i := range cb.Properties {
178 | if cb.Properties[i].IANAToken != string(removeProp) && remove(cb.Properties[i]) {
179 | keptProperties = append(keptProperties, cb.Properties[i])
180 | } else {
181 | removedProperties = append(removedProperties, cb.Properties[i])
182 | }
183 | }
184 | cb.Properties = keptProperties
185 | return removedProperties
186 | }
187 |
188 | const (
189 | icalTimestampFormatUtc = "20060102T150405Z"
190 | icalTimestampFormatLocal = "20060102T150405"
191 | icalDateFormatUtc = "20060102Z"
192 | icalDateFormatLocal = "20060102"
193 | )
194 |
195 | var timeStampVariations = regexp.MustCompile("^([0-9]{8})?([TZ])?([0-9]{6})?(Z)?$")
196 |
197 | func (cb *ComponentBase) SetCreatedTime(t time.Time, params ...PropertyParameter) {
198 | cb.SetProperty(ComponentPropertyCreated, t.UTC().Format(icalTimestampFormatUtc), params...)
199 | }
200 |
201 | func (cb *ComponentBase) SetDtStampTime(t time.Time, params ...PropertyParameter) {
202 | cb.SetProperty(ComponentPropertyDtstamp, t.UTC().Format(icalTimestampFormatUtc), params...)
203 | }
204 |
205 | func (cb *ComponentBase) SetModifiedAt(t time.Time, params ...PropertyParameter) {
206 | cb.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), params...)
207 | }
208 |
209 | func (cb *ComponentBase) SetSequence(seq int, params ...PropertyParameter) {
210 | cb.SetProperty(ComponentPropertySequence, strconv.Itoa(seq), params...)
211 | }
212 |
213 | func (cb *ComponentBase) SetStartAt(t time.Time, params ...PropertyParameter) {
214 | cb.SetProperty(ComponentPropertyDtStart, t.UTC().Format(icalTimestampFormatUtc), params...)
215 | }
216 |
217 | func (cb *ComponentBase) SetAllDayStartAt(t time.Time, params ...PropertyParameter) {
218 | cb.SetProperty(
219 | ComponentPropertyDtStart,
220 | t.Format(icalDateFormatLocal),
221 | append(params, WithValue(string(ValueDataTypeDate)))...,
222 | )
223 | }
224 |
225 | func (cb *ComponentBase) SetEndAt(t time.Time, params ...PropertyParameter) {
226 | cb.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), params...)
227 | }
228 |
229 | func (cb *ComponentBase) SetAllDayEndAt(t time.Time, params ...PropertyParameter) {
230 | cb.SetProperty(
231 | ComponentPropertyDtEnd,
232 | t.Format(icalDateFormatLocal),
233 | append(params, WithValue(string(ValueDataTypeDate)))...,
234 | )
235 | }
236 |
237 | // SetDuration updates the duration of an event.
238 | // This function will set either the end or start time of an event depending what is already given.
239 | // The duration defines the length of a event relative to start or end time.
240 | //
241 | // Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected.
242 | func (cb *ComponentBase) SetDuration(d time.Duration) error {
243 | startProp := cb.GetProperty(ComponentPropertyDtStart)
244 | if startProp != nil {
245 | t, err := cb.GetStartAt()
246 | if err == nil {
247 | v, _ := startProp.parameterValue(ParameterValue)
248 | if v == string(ValueDataTypeDate) {
249 | cb.SetAllDayEndAt(t.Add(d))
250 | } else {
251 | cb.SetEndAt(t.Add(d))
252 | }
253 | return nil
254 | }
255 | }
256 | endProp := cb.GetProperty(ComponentPropertyDtEnd)
257 | if endProp != nil {
258 | t, err := cb.GetEndAt()
259 | if err == nil {
260 | v, _ := endProp.parameterValue(ParameterValue)
261 | if v == string(ValueDataTypeDate) {
262 | cb.SetAllDayStartAt(t.Add(-d))
263 | } else {
264 | cb.SetStartAt(t.Add(-d))
265 | }
266 | return nil
267 | }
268 | }
269 | return errors.New("start or end not yet defined")
270 | }
271 |
272 | func (cb *ComponentBase) GetEndAt() (time.Time, error) {
273 | return cb.getTimeProp(ComponentPropertyDtEnd, false)
274 | }
275 |
276 | func (cb *ComponentBase) getTimeProp(componentProperty ComponentProperty, expectAllDay bool) (time.Time, error) {
277 | timeProp := cb.GetProperty(componentProperty)
278 | if timeProp == nil {
279 | return time.Time{}, fmt.Errorf("%w: %s", ErrorPropertyNotFound, componentProperty)
280 | }
281 |
282 | timeVal := timeProp.BaseProperty.Value
283 | matched := timeStampVariations.FindStringSubmatch(timeVal)
284 | if matched == nil {
285 | return time.Time{}, fmt.Errorf("time value not matched, got '%s'", timeVal)
286 | }
287 | tOrZGrp := matched[2]
288 | zGrp := matched[4]
289 | grp1len := len(matched[1])
290 | grp3len := len(matched[3])
291 |
292 | tzId, tzIdOk := timeProp.ICalParameters["TZID"]
293 | var propLoc *time.Location
294 | if tzIdOk {
295 | if len(tzId) != 1 {
296 | return time.Time{}, errors.New("expected only one TZID")
297 | }
298 | var tzErr error
299 | propLoc, tzErr = time.LoadLocation(tzId[0])
300 | if tzErr != nil {
301 | return time.Time{}, tzErr
302 | }
303 | }
304 | dateStr := matched[1]
305 |
306 | if expectAllDay {
307 | if grp1len > 0 {
308 | if tOrZGrp == "Z" || zGrp == "Z" {
309 | return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC)
310 | } else {
311 | if propLoc == nil {
312 | return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local)
313 | } else {
314 | return time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc)
315 | }
316 | }
317 | }
318 |
319 | return time.Time{}, fmt.Errorf("time value matched but unsupported all-day timestamp, got '%s'", timeVal)
320 | }
321 |
322 | switch {
323 | case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "Z":
324 | return time.ParseInLocation(icalTimestampFormatUtc, timeVal, time.UTC)
325 | case grp1len > 0 && grp3len > 0 && tOrZGrp == "T" && zGrp == "":
326 | if propLoc == nil {
327 | return time.ParseInLocation(icalTimestampFormatLocal, timeVal, time.Local)
328 | } else {
329 | return time.ParseInLocation(icalTimestampFormatLocal, timeVal, propLoc)
330 | }
331 | case grp1len > 0 && grp3len == 0 && tOrZGrp == "Z" && zGrp == "":
332 | return time.ParseInLocation(icalDateFormatUtc, dateStr+"Z", time.UTC)
333 | case grp1len > 0 && grp3len == 0 && tOrZGrp == "" && zGrp == "":
334 | if propLoc == nil {
335 | return time.ParseInLocation(icalDateFormatLocal, dateStr, time.Local)
336 | } else {
337 | return time.ParseInLocation(icalDateFormatLocal, dateStr, propLoc)
338 | }
339 | }
340 |
341 | return time.Time{}, fmt.Errorf("time value matched but not supported, got '%s'", timeVal)
342 | }
343 |
344 | func (cb *ComponentBase) GetStartAt() (time.Time, error) {
345 | return cb.getTimeProp(ComponentPropertyDtStart, false)
346 | }
347 |
348 | func (cb *ComponentBase) GetAllDayStartAt() (time.Time, error) {
349 | return cb.getTimeProp(ComponentPropertyDtStart, true)
350 | }
351 |
352 | func (cb *ComponentBase) GetLastModifiedAt() (time.Time, error) {
353 | return cb.getTimeProp(ComponentPropertyLastModified, false)
354 | }
355 |
356 | func (cb *ComponentBase) GetDtStampTime() (time.Time, error) {
357 | return cb.getTimeProp(ComponentPropertyDtstamp, false)
358 | }
359 |
360 | func (cb *ComponentBase) SetSummary(s string, params ...PropertyParameter) {
361 | cb.SetProperty(ComponentPropertySummary, s, params...)
362 | }
363 |
364 | func (cb *ComponentBase) SetStatus(s ObjectStatus, params ...PropertyParameter) {
365 | cb.SetProperty(ComponentPropertyStatus, string(s), params...)
366 | }
367 |
368 | func (cb *ComponentBase) SetDescription(s string, params ...PropertyParameter) {
369 | cb.SetProperty(ComponentPropertyDescription, s, params...)
370 | }
371 |
372 | func (cb *ComponentBase) SetLocation(s string, params ...PropertyParameter) {
373 | cb.SetProperty(ComponentPropertyLocation, s, params...)
374 | }
375 |
376 | func (cb *ComponentBase) setGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
377 | cb.SetProperty(ComponentPropertyGeo, fmt.Sprintf("%v;%v", lat, lng), params...)
378 | }
379 |
380 | func (cb *ComponentBase) SetURL(s string, params ...PropertyParameter) {
381 | cb.SetProperty(ComponentPropertyUrl, s, params...)
382 | }
383 |
384 | func (cb *ComponentBase) SetOrganizer(s string, params ...PropertyParameter) {
385 | if !strings.HasPrefix(s, "mailto:") {
386 | s = "mailto:" + s
387 | }
388 |
389 | cb.SetProperty(ComponentPropertyOrganizer, s, params...)
390 | }
391 |
392 | func (cb *ComponentBase) SetColor(s string, params ...PropertyParameter) {
393 | cb.SetProperty(ComponentPropertyColor, s, params...)
394 | }
395 |
396 | func (cb *ComponentBase) SetClass(c Classification, params ...PropertyParameter) {
397 | cb.SetProperty(ComponentPropertyClass, string(c), params...)
398 | }
399 |
400 | func (cb *ComponentBase) setPriority(p int, params ...PropertyParameter) {
401 | cb.SetProperty(ComponentPropertyPriority, strconv.Itoa(p), params...)
402 | }
403 |
404 | func (cb *ComponentBase) setResources(r string, params ...PropertyParameter) {
405 | cb.SetProperty(ComponentPropertyResources, r, params...)
406 | }
407 |
408 | func (cb *ComponentBase) AddAttendee(s string, params ...PropertyParameter) {
409 | if !strings.HasPrefix(s, "mailto:") {
410 | s = "mailto:" + s
411 | }
412 |
413 | cb.AddProperty(ComponentPropertyAttendee, s, params...)
414 | }
415 |
416 | func (cb *ComponentBase) AddExdate(s string, params ...PropertyParameter) {
417 | cb.AddProperty(ComponentPropertyExdate, s, params...)
418 | }
419 |
420 | func (cb *ComponentBase) AddExrule(s string, params ...PropertyParameter) {
421 | cb.AddProperty(ComponentPropertyExrule, s, params...)
422 | }
423 |
424 | func (cb *ComponentBase) AddRdate(s string, params ...PropertyParameter) {
425 | cb.AddProperty(ComponentPropertyRdate, s, params...)
426 | }
427 |
428 | func (cb *ComponentBase) AddRrule(s string, params ...PropertyParameter) {
429 | cb.AddProperty(ComponentPropertyRrule, s, params...)
430 | }
431 |
432 | func (cb *ComponentBase) AddAttachment(s string, params ...PropertyParameter) {
433 | cb.AddProperty(ComponentPropertyAttach, s, params...)
434 | }
435 |
436 | func (cb *ComponentBase) AddAttachmentURL(uri string, contentType string) {
437 | cb.AddAttachment(uri, WithFmtType(contentType))
438 | }
439 |
440 | func (cb *ComponentBase) AddAttachmentBinary(binary []byte, contentType string) {
441 | cb.AddAttachment(base64.StdEncoding.EncodeToString(binary),
442 | WithFmtType(contentType), WithEncoding("base64"), WithValue("binary"),
443 | )
444 | }
445 |
446 | func (cb *ComponentBase) AddComment(s string, params ...PropertyParameter) {
447 | cb.AddProperty(ComponentPropertyComment, s, params...)
448 | }
449 |
450 | func (cb *ComponentBase) AddCategory(s string, params ...PropertyParameter) {
451 | cb.AddProperty(ComponentPropertyCategories, s, params...)
452 | }
453 |
454 | type Attendee struct {
455 | IANAProperty
456 | }
457 |
458 | func (p *Attendee) Email() string {
459 | if strings.HasPrefix(p.Value, "mailto:") {
460 | return p.Value[len("mailto:"):]
461 | }
462 | return p.Value
463 | }
464 |
465 | func (p *Attendee) ParticipationStatus() ParticipationStatus {
466 | return ParticipationStatus(p.getPropertyFirst(ParameterParticipationStatus))
467 | }
468 |
469 | func (p *Attendee) getPropertyFirst(parameter Parameter) string {
470 | vs := p.getProperty(parameter)
471 | if len(vs) > 0 {
472 | return vs[0]
473 | }
474 | return ""
475 | }
476 |
477 | func (p *Attendee) getProperty(parameter Parameter) []string {
478 | if vs, ok := p.ICalParameters[string(parameter)]; ok {
479 | return vs
480 | }
481 | return nil
482 | }
483 |
484 | func (cb *ComponentBase) Attendees() []*Attendee {
485 | var r []*Attendee
486 | for i := range cb.Properties {
487 | switch cb.Properties[i].IANAToken {
488 | case string(ComponentPropertyAttendee):
489 | a := &Attendee{
490 | cb.Properties[i],
491 | }
492 | r = append(r, a)
493 | }
494 | }
495 | return r
496 | }
497 |
498 | func (cb *ComponentBase) Id() string {
499 | p := cb.GetProperty(ComponentPropertyUniqueId)
500 | if p != nil {
501 | return FromText(p.Value)
502 | }
503 | return ""
504 | }
505 |
506 | func (cb *ComponentBase) addAlarm() *VAlarm {
507 | a := &VAlarm{
508 | ComponentBase: ComponentBase{},
509 | }
510 | cb.Components = append(cb.Components, a)
511 | return a
512 | }
513 |
514 | func (cb *ComponentBase) addVAlarm(a *VAlarm) {
515 | cb.Components = append(cb.Components, a)
516 | }
517 |
518 | func (cb *ComponentBase) alarms() []*VAlarm {
519 | var r []*VAlarm
520 | for i := range cb.Components {
521 | switch alarm := cb.Components[i].(type) {
522 | case *VAlarm:
523 | r = append(r, alarm)
524 | }
525 | }
526 | return r
527 | }
528 |
529 | type VEvent struct {
530 | ComponentBase
531 | }
532 |
533 | func (event *VEvent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
534 | return event.ComponentBase.serializeThis(w, ComponentVEvent, serialConfig)
535 | }
536 |
537 | func (event *VEvent) Serialize(serialConfig *SerializationConfiguration) string {
538 | s, _ := event.serialize(serialConfig)
539 | return s
540 | }
541 |
542 | func (event *VEvent) serialize(serialConfig *SerializationConfiguration) (string, error) {
543 | b := &strings.Builder{}
544 | err := event.ComponentBase.serializeThis(b, ComponentVEvent, serialConfig)
545 | return b.String(), err
546 | }
547 |
548 | func NewEvent(uniqueId string) *VEvent {
549 | e := &VEvent{
550 | NewComponent(uniqueId),
551 | }
552 | return e
553 | }
554 |
555 | func (event *VEvent) SetEndAt(t time.Time, props ...PropertyParameter) {
556 | event.SetProperty(ComponentPropertyDtEnd, t.UTC().Format(icalTimestampFormatUtc), props...)
557 | }
558 |
559 | func (event *VEvent) SetLastModifiedAt(t time.Time, props ...PropertyParameter) {
560 | event.SetProperty(ComponentPropertyLastModified, t.UTC().Format(icalTimestampFormatUtc), props...)
561 | }
562 |
563 | // TODO use generics
564 | func (event *VEvent) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
565 | event.setGeo(lat, lng, params...)
566 | }
567 |
568 | func (event *VEvent) SetPriority(p int, params ...PropertyParameter) {
569 | event.setPriority(p, params...)
570 | }
571 |
572 | func (event *VEvent) SetResources(r string, params ...PropertyParameter) {
573 | event.setResources(r, params...)
574 | }
575 |
576 | func (event *VEvent) AddAlarm() *VAlarm {
577 | return event.addAlarm()
578 | }
579 |
580 | func (event *VEvent) AddVAlarm(a *VAlarm) {
581 | event.addVAlarm(a)
582 | }
583 |
584 | func (event *VEvent) Alarms() []*VAlarm {
585 | return event.alarms()
586 | }
587 |
588 | func (event *VEvent) GetAllDayEndAt() (time.Time, error) {
589 | return event.getTimeProp(ComponentPropertyDtEnd, true)
590 | }
591 |
592 | type TimeTransparency string
593 |
594 | const (
595 | TransparencyOpaque TimeTransparency = "OPAQUE" // default
596 | TransparencyTransparent TimeTransparency = "TRANSPARENT"
597 | )
598 |
599 | func (event *VEvent) SetTimeTransparency(v TimeTransparency, params ...PropertyParameter) {
600 | event.SetProperty(ComponentPropertyTransp, string(v), params...)
601 | }
602 |
603 | type VTodo struct {
604 | ComponentBase
605 | }
606 |
607 | func (todo *VTodo) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
608 | return todo.ComponentBase.serializeThis(w, ComponentVTodo, serialConfig)
609 | }
610 |
611 | func (todo *VTodo) Serialize(serialConfig *SerializationConfiguration) string {
612 | s, _ := todo.serialize(serialConfig)
613 | return s
614 | }
615 |
616 | func (todo *VTodo) serialize(serialConfig *SerializationConfiguration) (string, error) {
617 | b := &strings.Builder{}
618 | err := todo.ComponentBase.serializeThis(b, ComponentVTodo, serialConfig)
619 | if err != nil {
620 | return "", err
621 | }
622 | return b.String(), nil
623 | }
624 |
625 | func NewTodo(uniqueId string) *VTodo {
626 | e := &VTodo{
627 | NewComponent(uniqueId),
628 | }
629 | return e
630 | }
631 |
632 | func (cal *Calendar) AddTodo(id string) *VTodo {
633 | e := NewTodo(id)
634 | cal.Components = append(cal.Components, e)
635 | return e
636 | }
637 |
638 | func (cal *Calendar) AddVTodo(e *VTodo) {
639 | cal.Components = append(cal.Components, e)
640 | }
641 |
642 | func (cal *Calendar) Todos() []*VTodo {
643 | var r []*VTodo
644 | for i := range cal.Components {
645 | switch todo := cal.Components[i].(type) {
646 | case *VTodo:
647 | r = append(r, todo)
648 | }
649 | }
650 | return r
651 | }
652 |
653 | func (todo *VTodo) SetCompletedAt(t time.Time, params ...PropertyParameter) {
654 | todo.SetProperty(ComponentPropertyCompleted, t.UTC().Format(icalTimestampFormatUtc), params...)
655 | }
656 |
657 | func (todo *VTodo) SetAllDayCompletedAt(t time.Time, params ...PropertyParameter) {
658 | params = append(params, WithValue(string(ValueDataTypeDate)))
659 | todo.SetProperty(ComponentPropertyCompleted, t.Format(icalDateFormatLocal), params...)
660 | }
661 |
662 | func (todo *VTodo) SetDueAt(t time.Time, params ...PropertyParameter) {
663 | todo.SetProperty(ComponentPropertyDue, t.UTC().Format(icalTimestampFormatUtc), params...)
664 | }
665 |
666 | func (todo *VTodo) SetAllDayDueAt(t time.Time, params ...PropertyParameter) {
667 | params = append(params, WithValue(string(ValueDataTypeDate)))
668 | todo.SetProperty(ComponentPropertyDue, t.Format(icalDateFormatLocal), params...)
669 | }
670 |
671 | func (todo *VTodo) SetPercentComplete(p int, params ...PropertyParameter) {
672 | todo.SetProperty(ComponentPropertyPercentComplete, strconv.Itoa(p), params...)
673 | }
674 |
675 | func (todo *VTodo) SetGeo(lat interface{}, lng interface{}, params ...PropertyParameter) {
676 | todo.setGeo(lat, lng, params...)
677 | }
678 |
679 | func (todo *VTodo) SetPriority(p int, params ...PropertyParameter) {
680 | todo.setPriority(p, params...)
681 | }
682 |
683 | func (todo *VTodo) SetResources(r string, params ...PropertyParameter) {
684 | todo.setResources(r, params...)
685 | }
686 |
687 | // SetDuration updates the duration of an event.
688 | // This function will set either the end or start time of an event depending what is already given.
689 | // The duration defines the length of a event relative to start or end time.
690 | //
691 | // Notice: It will not set the DURATION key of the ics - only DTSTART and DTEND will be affected.
692 | func (todo *VTodo) SetDuration(d time.Duration) error {
693 | t, err := todo.GetStartAt()
694 | if err == nil {
695 | todo.SetDueAt(t.Add(d))
696 | return nil
697 | } else {
698 | t, err = todo.GetDueAt()
699 | if err == nil {
700 | todo.SetStartAt(t.Add(-d))
701 | return nil
702 | }
703 | }
704 | return errors.New("start or end not yet defined")
705 | }
706 |
707 | func (todo *VTodo) AddAlarm() *VAlarm {
708 | return todo.addAlarm()
709 | }
710 |
711 | func (todo *VTodo) AddVAlarm(a *VAlarm) {
712 | todo.addVAlarm(a)
713 | }
714 |
715 | func (todo *VTodo) Alarms() []*VAlarm {
716 | return todo.alarms()
717 | }
718 |
719 | func (todo *VTodo) GetDueAt() (time.Time, error) {
720 | return todo.getTimeProp(ComponentPropertyDue, false)
721 | }
722 |
723 | func (todo *VTodo) GetAllDayDueAt() (time.Time, error) {
724 | return todo.getTimeProp(ComponentPropertyDue, true)
725 | }
726 |
727 | type VJournal struct {
728 | ComponentBase
729 | }
730 |
731 | func (journal *VJournal) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
732 | return journal.ComponentBase.serializeThis(w, ComponentVJournal, serialConfig)
733 | }
734 |
735 | func (journal *VJournal) Serialize(serialConfig *SerializationConfiguration) string {
736 | s, _ := journal.serialize(serialConfig)
737 | return s
738 | }
739 |
740 | func (journal *VJournal) serialize(serialConfig *SerializationConfiguration) (string, error) {
741 | b := &strings.Builder{}
742 | err := journal.ComponentBase.serializeThis(b, ComponentVJournal, serialConfig)
743 | if err != nil {
744 | return "", err
745 | }
746 | return b.String(), nil
747 | }
748 |
749 | func NewJournal(uniqueId string) *VJournal {
750 | e := &VJournal{
751 | NewComponent(uniqueId),
752 | }
753 | return e
754 | }
755 |
756 | func (cal *Calendar) AddJournal(id string) *VJournal {
757 | e := NewJournal(id)
758 | cal.Components = append(cal.Components, e)
759 | return e
760 | }
761 |
762 | func (cal *Calendar) AddVJournal(e *VJournal) {
763 | cal.Components = append(cal.Components, e)
764 | }
765 |
766 | func (cal *Calendar) Journals() []*VJournal {
767 | var r []*VJournal
768 | for i := range cal.Components {
769 | switch journal := cal.Components[i].(type) {
770 | case *VJournal:
771 | r = append(r, journal)
772 | }
773 | }
774 | return r
775 | }
776 |
777 | type VBusy struct {
778 | ComponentBase
779 | }
780 |
781 | func (busy *VBusy) Serialize(serialConfig *SerializationConfiguration) string {
782 | s, _ := busy.serialize(serialConfig)
783 | return s
784 | }
785 |
786 | func (busy *VBusy) serialize(serialConfig *SerializationConfiguration) (string, error) {
787 | b := &strings.Builder{}
788 | err := busy.ComponentBase.serializeThis(b, ComponentVFreeBusy, serialConfig)
789 | if err != nil {
790 | return "", err
791 | }
792 | return b.String(), nil
793 | }
794 |
795 | func (busy *VBusy) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
796 | return busy.ComponentBase.serializeThis(w, ComponentVFreeBusy, serialConfig)
797 | }
798 |
799 | func NewBusy(uniqueId string) *VBusy {
800 | e := &VBusy{
801 | NewComponent(uniqueId),
802 | }
803 | return e
804 | }
805 |
806 | func (cal *Calendar) AddBusy(id string) *VBusy {
807 | e := NewBusy(id)
808 | cal.Components = append(cal.Components, e)
809 | return e
810 | }
811 |
812 | func (cal *Calendar) AddVBusy(e *VBusy) {
813 | cal.Components = append(cal.Components, e)
814 | }
815 |
816 | func (cal *Calendar) Busys() []*VBusy {
817 | var r []*VBusy
818 | for i := range cal.Components {
819 | switch busy := cal.Components[i].(type) {
820 | case *VBusy:
821 | r = append(r, busy)
822 | }
823 | }
824 | return r
825 | }
826 |
827 | type VTimezone struct {
828 | ComponentBase
829 | }
830 |
831 | func (timezone *VTimezone) Serialize(serialConfig *SerializationConfiguration) string {
832 | s, _ := timezone.serialize(serialConfig)
833 | return s
834 | }
835 |
836 | func (timezone *VTimezone) serialize(serialConfig *SerializationConfiguration) (string, error) {
837 | b := &strings.Builder{}
838 | err := timezone.ComponentBase.serializeThis(b, ComponentVTimezone, serialConfig)
839 | if err != nil {
840 | return "", err
841 | }
842 | return b.String(), nil
843 | }
844 |
845 | func (timezone *VTimezone) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
846 | return timezone.ComponentBase.serializeThis(w, ComponentVTimezone, serialConfig)
847 | }
848 |
849 | func (timezone *VTimezone) AddStandard() *Standard {
850 | e := NewStandard()
851 | timezone.Components = append(timezone.Components, e)
852 | return e
853 | }
854 |
855 | func NewTimezone(tzId string) *VTimezone {
856 | e := &VTimezone{
857 | ComponentBase{
858 | Properties: []IANAProperty{
859 | {BaseProperty{IANAToken: string(ComponentPropertyTzid), Value: tzId}},
860 | },
861 | },
862 | }
863 | return e
864 | }
865 |
866 | func (cal *Calendar) AddTimezone(id string) *VTimezone {
867 | e := NewTimezone(id)
868 | cal.Components = append(cal.Components, e)
869 | return e
870 | }
871 |
872 | func (cal *Calendar) AddVTimezone(e *VTimezone) {
873 | cal.Components = append(cal.Components, e)
874 | }
875 |
876 | func (cal *Calendar) Timezones() []*VTimezone {
877 | var r []*VTimezone
878 | for i := range cal.Components {
879 | switch timezone := cal.Components[i].(type) {
880 | case *VTimezone:
881 | r = append(r, timezone)
882 | }
883 | }
884 | return r
885 | }
886 |
887 | type VAlarm struct {
888 | ComponentBase
889 | }
890 |
891 | func (c *VAlarm) Serialize(serialConfig *SerializationConfiguration) string {
892 | s, _ := c.serialize(serialConfig)
893 | return s
894 | }
895 |
896 | func (c *VAlarm) serialize(serialConfig *SerializationConfiguration) (string, error) {
897 | b := &strings.Builder{}
898 | err := c.ComponentBase.serializeThis(b, ComponentVAlarm, serialConfig)
899 | if err != nil {
900 | return "", err
901 | }
902 | return b.String(), nil
903 | }
904 |
905 | func (c *VAlarm) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
906 | return c.ComponentBase.serializeThis(w, ComponentVAlarm, serialConfig)
907 | }
908 |
909 | func NewAlarm(tzId string) *VAlarm {
910 | // Todo How did this come about?
911 | e := &VAlarm{}
912 | return e
913 | }
914 |
915 | func (cal *Calendar) AddVAlarm(e *VAlarm) {
916 | cal.Components = append(cal.Components, e)
917 | }
918 |
919 | func (cal *Calendar) Alarms() []*VAlarm {
920 | var r []*VAlarm
921 | for i := range cal.Components {
922 | switch alarm := cal.Components[i].(type) {
923 | case *VAlarm:
924 | r = append(r, alarm)
925 | }
926 | }
927 | return r
928 | }
929 |
930 | func (c *VAlarm) SetAction(a Action, params ...PropertyParameter) {
931 | c.SetProperty(ComponentPropertyAction, string(a), params...)
932 | }
933 |
934 | func (c *VAlarm) SetTrigger(s string, params ...PropertyParameter) {
935 | c.SetProperty(ComponentPropertyTrigger, s, params...)
936 | }
937 |
938 | type Standard struct {
939 | ComponentBase
940 | }
941 |
942 | func NewStandard() *Standard {
943 | e := &Standard{
944 | ComponentBase{},
945 | }
946 | return e
947 | }
948 |
949 | func (standard *Standard) Serialize(serialConfig *SerializationConfiguration) string {
950 | s, _ := standard.serialize(serialConfig)
951 | return s
952 | }
953 |
954 | func (standard *Standard) serialize(serialConfig *SerializationConfiguration) (string, error) {
955 | b := &strings.Builder{}
956 | err := standard.ComponentBase.serializeThis(b, ComponentStandard, serialConfig)
957 | if err != nil {
958 | return "", err
959 | }
960 | return b.String(), nil
961 | }
962 |
963 | func (standard *Standard) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
964 | return standard.ComponentBase.serializeThis(w, ComponentStandard, serialConfig)
965 | }
966 |
967 | type Daylight struct {
968 | ComponentBase
969 | }
970 |
971 | func (daylight *Daylight) Serialize(serialConfig *SerializationConfiguration) string {
972 | s, _ := daylight.serialize(serialConfig)
973 | return s
974 | }
975 |
976 | func (daylight *Daylight) serialize(serialConfig *SerializationConfiguration) (string, error) {
977 | b := &strings.Builder{}
978 | err := daylight.ComponentBase.serializeThis(b, ComponentDaylight, serialConfig)
979 | if err != nil {
980 | return "", err
981 | }
982 | return b.String(), nil
983 | }
984 |
985 | func (daylight *Daylight) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
986 | return daylight.ComponentBase.serializeThis(w, ComponentDaylight, serialConfig)
987 | }
988 |
989 | type GeneralComponent struct {
990 | ComponentBase
991 | Token string
992 | }
993 |
994 | func (general *GeneralComponent) Serialize(serialConfig *SerializationConfiguration) string {
995 | s, _ := general.serialize(serialConfig)
996 | return s
997 | }
998 |
999 | func (general *GeneralComponent) serialize(serialConfig *SerializationConfiguration) (string, error) {
1000 | b := &strings.Builder{}
1001 | err := general.ComponentBase.serializeThis(b, ComponentType(general.Token), serialConfig)
1002 | if err != nil {
1003 | return "", err
1004 | }
1005 | return b.String(), nil
1006 | }
1007 |
1008 | func (general *GeneralComponent) SerializeTo(w io.Writer, serialConfig *SerializationConfiguration) error {
1009 | return general.ComponentBase.serializeThis(w, ComponentType(general.Token), serialConfig)
1010 | }
1011 |
1012 | func GeneralParseComponent(cs *CalendarStream, startLine *BaseProperty) (Component, error) {
1013 | var co Component
1014 | var err error
1015 | switch ComponentType(startLine.Value) {
1016 | case ComponentVCalendar:
1017 | return nil, errors.New("malformed calendar; vcalendar not where expected")
1018 | case ComponentVEvent:
1019 | co, err = ParseVEventWithError(cs, startLine)
1020 | case ComponentVTodo:
1021 | co, err = ParseVTodoWithError(cs, startLine)
1022 | case ComponentVJournal:
1023 | co, err = ParseVJournalWithError(cs, startLine)
1024 | case ComponentVFreeBusy:
1025 | co, err = ParseVBusyWithError(cs, startLine)
1026 | case ComponentVTimezone:
1027 | co, err = ParseVTimezoneWithError(cs, startLine)
1028 | case ComponentVAlarm:
1029 | co, err = ParseVAlarmWithError(cs, startLine)
1030 | case ComponentStandard:
1031 | co, err = ParseStandardWithError(cs, startLine)
1032 | case ComponentDaylight:
1033 | co, err = ParseDaylightWithError(cs, startLine)
1034 | default:
1035 | co, err = ParseGeneralComponentWithError(cs, startLine)
1036 | }
1037 | return co, err
1038 | }
1039 |
1040 | func ParseVEvent(cs *CalendarStream, startLine *BaseProperty) *VEvent {
1041 | ev, _ := ParseVEventWithError(cs, startLine)
1042 | return ev
1043 | }
1044 |
1045 | func ParseVEventWithError(cs *CalendarStream, startLine *BaseProperty) (*VEvent, error) {
1046 | r, err := ParseComponent(cs, startLine)
1047 | if err != nil {
1048 | return nil, fmt.Errorf("failed to parse event: %w", err)
1049 | }
1050 | rr := &VEvent{
1051 | ComponentBase: r,
1052 | }
1053 | return rr, nil
1054 | }
1055 |
1056 | func ParseVTodo(cs *CalendarStream, startLine *BaseProperty) *VTodo {
1057 | c, _ := ParseVTodoWithError(cs, startLine)
1058 | return c
1059 | }
1060 |
1061 | func ParseVTodoWithError(cs *CalendarStream, startLine *BaseProperty) (*VTodo, error) {
1062 | r, err := ParseComponent(cs, startLine)
1063 | if err != nil {
1064 | return nil, err
1065 | }
1066 | rr := &VTodo{
1067 | ComponentBase: r,
1068 | }
1069 | return rr, nil
1070 | }
1071 |
1072 | func ParseVJournal(cs *CalendarStream, startLine *BaseProperty) *VJournal {
1073 | c, _ := ParseVJournalWithError(cs, startLine)
1074 | return c
1075 | }
1076 |
1077 | func ParseVJournalWithError(cs *CalendarStream, startLine *BaseProperty) (*VJournal, error) {
1078 | r, err := ParseComponent(cs, startLine)
1079 | if err != nil {
1080 | return nil, err
1081 | }
1082 | rr := &VJournal{
1083 | ComponentBase: r,
1084 | }
1085 | return rr, nil
1086 | }
1087 |
1088 | func ParseVBusy(cs *CalendarStream, startLine *BaseProperty) *VBusy {
1089 | c, _ := ParseVBusyWithError(cs, startLine)
1090 | return c
1091 | }
1092 |
1093 | func ParseVBusyWithError(cs *CalendarStream, startLine *BaseProperty) (*VBusy, error) {
1094 | r, err := ParseComponent(cs, startLine)
1095 | if err != nil {
1096 | return nil, err
1097 | }
1098 | rr := &VBusy{
1099 | ComponentBase: r,
1100 | }
1101 | return rr, nil
1102 | }
1103 |
1104 | func ParseVTimezone(cs *CalendarStream, startLine *BaseProperty) *VTimezone {
1105 | c, _ := ParseVTimezoneWithError(cs, startLine)
1106 | return c
1107 | }
1108 |
1109 | func ParseVTimezoneWithError(cs *CalendarStream, startLine *BaseProperty) (*VTimezone, error) {
1110 | r, err := ParseComponent(cs, startLine)
1111 | if err != nil {
1112 | return nil, err
1113 | }
1114 | rr := &VTimezone{
1115 | ComponentBase: r,
1116 | }
1117 | return rr, nil
1118 | }
1119 |
1120 | func ParseVAlarm(cs *CalendarStream, startLine *BaseProperty) *VAlarm {
1121 | c, _ := ParseVAlarmWithError(cs, startLine)
1122 | return c
1123 | }
1124 |
1125 | func ParseVAlarmWithError(cs *CalendarStream, startLine *BaseProperty) (*VAlarm, error) {
1126 | r, err := ParseComponent(cs, startLine)
1127 | if err != nil {
1128 | return nil, err
1129 | }
1130 | rr := &VAlarm{
1131 | ComponentBase: r,
1132 | }
1133 | return rr, nil
1134 | }
1135 |
1136 | func ParseStandard(cs *CalendarStream, startLine *BaseProperty) *Standard {
1137 | c, _ := ParseStandardWithError(cs, startLine)
1138 | return c
1139 | }
1140 |
1141 | func ParseStandardWithError(cs *CalendarStream, startLine *BaseProperty) (*Standard, error) {
1142 | r, err := ParseComponent(cs, startLine)
1143 | if err != nil {
1144 | return nil, err
1145 | }
1146 | rr := &Standard{
1147 | ComponentBase: r,
1148 | }
1149 | return rr, nil
1150 | }
1151 |
1152 | func ParseDaylight(cs *CalendarStream, startLine *BaseProperty) *Daylight {
1153 | c, _ := ParseDaylightWithError(cs, startLine)
1154 | return c
1155 | }
1156 |
1157 | func ParseDaylightWithError(cs *CalendarStream, startLine *BaseProperty) (*Daylight, error) {
1158 | r, err := ParseComponent(cs, startLine)
1159 | if err != nil {
1160 | return nil, err
1161 | }
1162 | rr := &Daylight{
1163 | ComponentBase: r,
1164 | }
1165 | return rr, nil
1166 | }
1167 |
1168 | func ParseGeneralComponent(cs *CalendarStream, startLine *BaseProperty) *GeneralComponent {
1169 | c, _ := ParseGeneralComponentWithError(cs, startLine)
1170 | return c
1171 | }
1172 |
1173 | func ParseGeneralComponentWithError(cs *CalendarStream, startLine *BaseProperty) (*GeneralComponent, error) {
1174 | r, err := ParseComponent(cs, startLine)
1175 | if err != nil {
1176 | return nil, err
1177 | }
1178 | rr := &GeneralComponent{
1179 | ComponentBase: r,
1180 | Token: startLine.Value,
1181 | }
1182 | return rr, nil
1183 | }
1184 |
1185 | func ParseComponent(cs *CalendarStream, startLine *BaseProperty) (ComponentBase, error) {
1186 | cb := ComponentBase{}
1187 | cont := true
1188 | for ln := 0; cont; ln++ {
1189 | l, err := cs.ReadLine()
1190 | if err != nil {
1191 | switch err {
1192 | case io.EOF:
1193 | cont = false
1194 | default:
1195 | return cb, err
1196 | }
1197 | }
1198 | if l == nil || len(*l) == 0 {
1199 | continue
1200 | }
1201 | line, err := ParseProperty(*l)
1202 | if err != nil {
1203 | return cb, fmt.Errorf("parsing component property %d: %w", ln, err)
1204 | }
1205 | if line == nil {
1206 | return cb, errors.New("parsing component line")
1207 | }
1208 | switch line.IANAToken {
1209 | case "END":
1210 | switch line.Value {
1211 | case startLine.Value:
1212 | return cb, nil
1213 | default:
1214 | return cb, errors.New("unbalanced end")
1215 | }
1216 | case "BEGIN":
1217 | co, err := GeneralParseComponent(cs, line)
1218 | if err != nil {
1219 | return cb, err
1220 | }
1221 | if co != nil {
1222 | cb.Components = append(cb.Components, co)
1223 | }
1224 | default: // TODO put in all the supported types for type switching etc.
1225 | cb.Properties = append(cb.Properties, IANAProperty{*line})
1226 | }
1227 | }
1228 | return cb, errors.New("ran out of lines")
1229 | }
1230 |
--------------------------------------------------------------------------------
/components_test.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "strings"
5 | "testing"
6 | "time"
7 |
8 | "github.com/stretchr/testify/assert"
9 | )
10 |
11 | func TestSetDuration(t *testing.T) {
12 | date, _ := time.Parse(time.RFC822, time.RFC822)
13 | duration := time.Duration(float64(time.Hour) * 2)
14 |
15 | testCases := []struct {
16 | name string
17 | start time.Time
18 | end time.Time
19 | output string
20 | }{
21 | {
22 | name: "test set duration - start",
23 | start: date,
24 | output: `BEGIN:VEVENT
25 | UID:test-duration
26 | DTSTART:20060102T150400Z
27 | DTEND:20060102T170400Z
28 | END:VEVENT
29 | `,
30 | },
31 | {
32 | name: "test set duration - end",
33 | end: date,
34 | output: `BEGIN:VEVENT
35 | UID:test-duration
36 | DTEND:20060102T150400Z
37 | DTSTART:20060102T130400Z
38 | END:VEVENT
39 | `,
40 | },
41 | }
42 |
43 | for _, tc := range testCases {
44 | t.Run(tc.name, func(t *testing.T) {
45 | e := NewEvent("test-duration")
46 | if !tc.start.IsZero() {
47 | e.SetStartAt(tc.start)
48 | }
49 | if !tc.end.IsZero() {
50 | e.SetEndAt(tc.end)
51 | }
52 | err := e.SetDuration(duration)
53 |
54 | // we're not testing for encoding here so lets make the actual output line breaks == expected line breaks
55 | text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
56 |
57 | assert.Equal(t, tc.output, text)
58 | assert.Equal(t, nil, err)
59 | })
60 | }
61 | }
62 |
63 | func TestSetAllDay(t *testing.T) {
64 | date, _ := time.Parse(time.RFC822, time.RFC822)
65 |
66 | testCases := []struct {
67 | name string
68 | start time.Time
69 | end time.Time
70 | duration time.Duration
71 | output string
72 | }{
73 | {
74 | name: "test set all day - start",
75 | start: date,
76 | output: `BEGIN:VEVENT
77 | UID:test-allday
78 | DTSTART;VALUE=DATE:20060102
79 | END:VEVENT
80 | `,
81 | },
82 | {
83 | name: "test set all day - end",
84 | end: date,
85 | output: `BEGIN:VEVENT
86 | UID:test-allday
87 | DTEND;VALUE=DATE:20060102
88 | END:VEVENT
89 | `,
90 | },
91 | {
92 | name: "test set all day - duration",
93 | start: date,
94 | duration: time.Hour * 24,
95 | output: `BEGIN:VEVENT
96 | UID:test-allday
97 | DTSTART;VALUE=DATE:20060102
98 | DTEND;VALUE=DATE:20060103
99 | END:VEVENT
100 | `,
101 | },
102 | }
103 |
104 | for _, tc := range testCases {
105 | t.Run(tc.name, func(t *testing.T) {
106 | e := NewEvent("test-allday")
107 | if !tc.start.IsZero() {
108 | e.SetAllDayStartAt(tc.start)
109 | }
110 | if !tc.end.IsZero() {
111 | e.SetAllDayEndAt(tc.end)
112 | }
113 | if tc.duration != 0 {
114 | err := e.SetDuration(tc.duration)
115 | assert.NoError(t, err)
116 | }
117 |
118 | text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
119 |
120 | assert.Equal(t, tc.output, text)
121 | })
122 | }
123 | }
124 |
125 | func TestGetLastModifiedAt(t *testing.T) {
126 | e := NewEvent("test-last-modified")
127 | lastModified := time.Unix(123456789, 0)
128 | e.SetLastModifiedAt(lastModified)
129 | got, err := e.GetLastModifiedAt()
130 | if err != nil {
131 | t.Fatalf("e.GetLastModifiedAt: %v", err)
132 | }
133 |
134 | if !got.Equal(lastModified) {
135 | t.Errorf("got last modified = %q, want %q", got, lastModified)
136 | }
137 | }
138 |
139 | func TestSetMailtoPrefix(t *testing.T) {
140 | e := NewEvent("test-set-organizer")
141 |
142 | e.SetOrganizer("org1@provider.com")
143 | if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org1@provider.com") {
144 | t.Errorf("expected single mailto: prefix for email org1")
145 | }
146 |
147 | e.SetOrganizer("mailto:org2@provider.com")
148 | if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ORGANIZER:mailto:org2@provider.com") {
149 | t.Errorf("expected single mailto: prefix for email org2")
150 | }
151 |
152 | e.AddAttendee("att1@provider.com")
153 | if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att1@provider.com") {
154 | t.Errorf("expected single mailto: prefix for email att1")
155 | }
156 |
157 | e.AddAttendee("mailto:att2@provider.com")
158 | if !strings.Contains(e.Serialize(defaultSerializationOptions()), "ATTENDEE:mailto:att2@provider.com") {
159 | t.Errorf("expected single mailto: prefix for email att2")
160 | }
161 | }
162 |
163 | func TestRemoveProperty(t *testing.T) {
164 | testCases := []struct {
165 | name string
166 | output string
167 | }{
168 | {
169 | name: "test RemoveProperty - start",
170 | output: `BEGIN:VTODO
171 | UID:test-removeproperty
172 | X-TEST:42
173 | END:VTODO
174 | `,
175 | },
176 | }
177 |
178 | for _, tc := range testCases {
179 | t.Run(tc.name, func(t *testing.T) {
180 | e := NewTodo("test-removeproperty")
181 | e.AddProperty("X-TEST", "42")
182 | e.AddProperty("X-TESTREMOVE", "FOO")
183 | e.AddProperty("X-TESTREMOVE", "BAR")
184 | e.RemoveProperty("X-TESTREMOVE")
185 |
186 | // adjust to expected linebreaks, since we're not testing the encoding
187 | text := strings.ReplaceAll(e.Serialize(defaultSerializationOptions()), "\r\n", "\n")
188 |
189 | assert.Equal(t, tc.output, text)
190 | })
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/errors.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import "errors"
4 |
5 | var (
6 | // ErrorPropertyNotFound is the error returned if the requested valid
7 | // property is not set.
8 | ErrorPropertyNotFound = errors.New("property not found")
9 | )
10 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/arran4/golang-ical
2 |
3 | go 1.20
4 |
5 | require (
6 | github.com/google/go-cmp v0.6.0
7 | github.com/stretchr/testify v1.7.0
8 | )
9 |
10 | require (
11 | github.com/davecgh/go-spew v1.1.1 // indirect
12 | github.com/kr/text v0.2.0 // indirect
13 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
14 | github.com/pmezard/go-difflib v1.0.0 // indirect
15 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
16 | gopkg.in/yaml.v3 v3.0.0 // indirect
17 | )
18 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
6 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
8 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
9 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
10 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
11 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
12 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
15 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
16 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
17 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
19 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
20 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
21 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
22 | gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
23 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
24 |
--------------------------------------------------------------------------------
/os.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | const (
4 | WithNewLineUnix WithNewLine = "\n"
5 | WithNewLineWindows WithNewLine = "\r\n"
6 | )
7 |
--------------------------------------------------------------------------------
/os_unix.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | const (
4 | NewLine = WithNewLineUnix
5 | )
6 |
--------------------------------------------------------------------------------
/os_windows.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | const (
4 | NewLineString = WithNewLineWindows
5 | )
6 |
--------------------------------------------------------------------------------
/property.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | "log"
8 | "net/url"
9 | "regexp"
10 | "sort"
11 | "strconv"
12 | "strings"
13 | "unicode/utf8"
14 | )
15 |
16 | type BaseProperty struct {
17 | IANAToken string
18 | ICalParameters map[string][]string
19 | Value string
20 | }
21 |
22 | type PropertyParameter interface {
23 | KeyValue(s ...interface{}) (string, []string)
24 | }
25 |
26 | type KeyValues struct {
27 | Key string
28 | Value []string
29 | }
30 |
31 | func (kv *KeyValues) KeyValue(_ ...interface{}) (string, []string) {
32 | return kv.Key, kv.Value
33 | }
34 |
35 | func WithCN(cn string) PropertyParameter {
36 | return &KeyValues{
37 | Key: string(ParameterCn),
38 | Value: []string{cn},
39 | }
40 | }
41 |
42 | func WithTZID(tzid string) PropertyParameter {
43 | return &KeyValues{
44 | Key: string(ParameterTzid),
45 | Value: []string{tzid},
46 | }
47 | }
48 |
49 | // WithAlternativeRepresentation takes what must be a valid URI in quotation marks
50 | func WithAlternativeRepresentation(uri *url.URL) PropertyParameter {
51 | return &KeyValues{
52 | Key: string(ParameterAltrep),
53 | Value: []string{uri.String()},
54 | }
55 | }
56 |
57 | func WithEncoding(encType string) PropertyParameter {
58 | return &KeyValues{
59 | Key: string(ParameterEncoding),
60 | Value: []string{encType},
61 | }
62 | }
63 |
64 | func WithFmtType(contentType string) PropertyParameter {
65 | return &KeyValues{
66 | Key: string(ParameterFmttype),
67 | Value: []string{contentType},
68 | }
69 | }
70 |
71 | func WithValue(kind string) PropertyParameter {
72 | return &KeyValues{
73 | Key: string(ParameterValue),
74 | Value: []string{kind},
75 | }
76 | }
77 |
78 | func WithRSVP(b bool) PropertyParameter {
79 | return &KeyValues{
80 | Key: string(ParameterRsvp),
81 | Value: []string{strconv.FormatBool(b)},
82 | }
83 | }
84 |
85 | func trimUT8StringUpTo(maxLength int, s string) string {
86 | length := 0
87 | lastWordBoundary := -1
88 | var lastRune rune
89 | for i, r := range s {
90 | if r == ' ' || r == '<' {
91 | lastWordBoundary = i
92 | } else if lastRune == '>' {
93 | lastWordBoundary = i
94 | }
95 | lastRune = r
96 | newLength := length + utf8.RuneLen(r)
97 | if newLength > maxLength {
98 | break
99 | }
100 | length = newLength
101 | }
102 | if lastWordBoundary > 0 {
103 | return s[:lastWordBoundary]
104 | }
105 |
106 | return s[:length]
107 | }
108 |
109 | func (bp *BaseProperty) parameterValue(param Parameter) (string, error) {
110 | v, ok := bp.ICalParameters[string(param)]
111 | if !ok || len(v) == 0 {
112 | return "", fmt.Errorf("parameter %q not found in property", param)
113 | }
114 | if len(v) != 1 {
115 | return "", fmt.Errorf("expected only one value for parameter %q in property, found %d", param, len(v))
116 | }
117 | return v[0], nil
118 | }
119 |
120 | func (bp *BaseProperty) GetValueType() ValueDataType {
121 | for k, v := range bp.ICalParameters {
122 | if Parameter(k) == ParameterValue && len(v) == 1 {
123 | return ValueDataType(v[0])
124 | }
125 | }
126 |
127 | // defaults from spec if unspecified
128 | switch Property(bp.IANAToken) {
129 | default:
130 | fallthrough
131 | case PropertyCalscale, PropertyMethod, PropertyProductId, PropertyVersion, PropertyCategories, PropertyClass,
132 | PropertyComment, PropertyDescription, PropertyLocation, PropertyResources, PropertyStatus, PropertySummary,
133 | PropertyTransp, PropertyTzid, PropertyTzname, PropertyContact, PropertyRelatedTo, PropertyUid, PropertyAction,
134 | PropertyRequestStatus:
135 | return ValueDataTypeText
136 |
137 | case PropertyAttach, PropertyTzurl, PropertyUrl:
138 | return ValueDataTypeUri
139 |
140 | case PropertyGeo:
141 | return ValueDataTypeFloat
142 |
143 | case PropertyPercentComplete, PropertyPriority, PropertyRepeat, PropertySequence:
144 | return ValueDataTypeInteger
145 |
146 | case PropertyCompleted, PropertyDtend, PropertyDue, PropertyDtstart, PropertyRecurrenceId, PropertyExdate,
147 | PropertyRdate, PropertyCreated, PropertyDtstamp, PropertyLastModified:
148 | return ValueDataTypeDateTime
149 |
150 | case PropertyDuration, PropertyTrigger:
151 | return ValueDataTypeDuration
152 |
153 | case PropertyFreebusy:
154 | return ValueDataTypePeriod
155 |
156 | case PropertyTzoffsetfrom, PropertyTzoffsetto:
157 | return ValueDataTypeUtcOffset
158 |
159 | case PropertyAttendee, PropertyOrganizer:
160 | return ValueDataTypeCalAddress
161 |
162 | case PropertyRrule:
163 | return ValueDataTypeRecur
164 | }
165 | }
166 |
167 | func (bp *BaseProperty) serialize(w io.Writer, serialConfig *SerializationConfiguration) error {
168 | var b strings.Builder
169 | b.WriteString(bp.IANAToken)
170 |
171 | var keys []string
172 | for k := range bp.ICalParameters {
173 | keys = append(keys, k)
174 | }
175 | sort.Strings(keys)
176 | for _, k := range keys {
177 | vs := bp.ICalParameters[k]
178 | b.WriteByte(';')
179 | b.WriteString(k)
180 | b.WriteByte('=')
181 | for vi, v := range vs {
182 | if vi > 0 {
183 | b.WriteByte(',')
184 | }
185 | if Parameter(k).IsQuoted() {
186 | v = quotedValueString(v)
187 | b.WriteString(v)
188 | } else {
189 | v = escapeValueString(v)
190 | b.WriteString(v)
191 | }
192 | }
193 | }
194 | b.WriteByte(':')
195 | propertyValue := bp.Value
196 | if bp.GetValueType() == ValueDataTypeText {
197 | propertyValue = ToText(propertyValue)
198 | }
199 | b.WriteString(propertyValue)
200 | r := b.String()
201 | if len(r) > serialConfig.MaxLength {
202 | l := trimUT8StringUpTo(serialConfig.MaxLength, r)
203 | _, err := io.WriteString(w, l+serialConfig.NewLine)
204 | if err != nil {
205 | return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
206 | }
207 | r = r[len(l):]
208 |
209 | for len(r) > serialConfig.MaxLength-1 {
210 | l := trimUT8StringUpTo(serialConfig.MaxLength-1, r)
211 | _, err = io.WriteString(w, " "+l+serialConfig.NewLine)
212 | if err != nil {
213 | return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
214 | }
215 | r = r[len(l):]
216 | }
217 | _, err = io.WriteString(w, " ")
218 | if err != nil {
219 | return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
220 | }
221 | }
222 | _, err := io.WriteString(w, r+serialConfig.NewLine)
223 | if err != nil {
224 | return fmt.Errorf("property %s serialization: %w", bp.IANAToken, err)
225 | }
226 | return nil
227 | }
228 |
229 | func escapeValueString(v string) string {
230 | changed := 0
231 | result := ""
232 | for i, r := range v {
233 | switch r {
234 | case ',', '"', ';', ':', '\\', '\'':
235 | result = result + v[changed:i] + "\\" + string(r)
236 | changed = i + 1
237 | }
238 | }
239 | if changed == 0 {
240 | return v
241 | }
242 | return result + v[changed:]
243 | }
244 |
245 | func quotedValueString(v string) string {
246 | changed := 0
247 | result := ""
248 | for i, r := range v {
249 | switch r {
250 | case '"', '\\':
251 | result = result + v[changed:i] + "\\" + string(r)
252 | changed = i + 1
253 | }
254 | }
255 | if changed == 0 {
256 | return `"` + v + `"`
257 | }
258 | return `"` + result + v[changed:] + `"`
259 | }
260 |
261 | type IANAProperty struct {
262 | BaseProperty
263 | }
264 |
265 | var (
266 | propertyIanaTokenReg *regexp.Regexp
267 | propertyParamNameReg *regexp.Regexp
268 | propertyValueTextReg *regexp.Regexp
269 | )
270 |
271 | func init() {
272 | var err error
273 | propertyIanaTokenReg, err = regexp.Compile("[A-Za-z0-9-]{1,}")
274 | if err != nil {
275 | log.Panicf("Failed to build regex: %v", err)
276 | }
277 | propertyParamNameReg = propertyIanaTokenReg
278 | propertyValueTextReg, err = regexp.Compile("^.*")
279 | if err != nil {
280 | log.Panicf("Failed to build regex: %v", err)
281 | }
282 | }
283 |
284 | type ContentLine string
285 |
286 | func ParseProperty(contentLine ContentLine) (*BaseProperty, error) {
287 | r := &BaseProperty{
288 | ICalParameters: map[string][]string{},
289 | }
290 | tokenPos := propertyIanaTokenReg.FindIndex([]byte(contentLine))
291 | if tokenPos == nil {
292 | return nil, nil
293 | }
294 | p := 0
295 | r.IANAToken = string(contentLine[p+tokenPos[0] : p+tokenPos[1]])
296 | p += tokenPos[1]
297 | for {
298 | if p >= len(contentLine) {
299 | return nil, nil
300 | }
301 | switch rune(contentLine[p]) {
302 | case ':':
303 | return parsePropertyValue(r, string(contentLine), p+1), nil
304 | case ';':
305 | var np int
306 | var err error
307 | t := r.IANAToken
308 | r, np, err = parsePropertyParam(r, string(contentLine), p+1)
309 | if err != nil {
310 | return nil, fmt.Errorf("parsing property %s: %w", t, err)
311 | }
312 | if r == nil {
313 | return nil, nil
314 | }
315 | p = np
316 | default:
317 | return nil, nil
318 | }
319 | }
320 | }
321 |
322 | func parsePropertyParam(r *BaseProperty, contentLine string, p int) (*BaseProperty, int, error) {
323 | tokenPos := propertyParamNameReg.FindIndex([]byte(contentLine[p:]))
324 | if tokenPos == nil {
325 | return nil, p, nil
326 | }
327 | k, v := "", ""
328 | k = string(contentLine[p : p+tokenPos[1]])
329 | p += tokenPos[1]
330 | if p >= len(contentLine) {
331 | return nil, p, fmt.Errorf("missing property param operator for %s in %s", k, r.IANAToken)
332 | }
333 | switch rune(contentLine[p]) {
334 | case '=':
335 | p += 1
336 | default:
337 | return nil, p, fmt.Errorf("missing property value for %s in %s", k, r.IANAToken)
338 | }
339 | for {
340 | if p >= len(contentLine) {
341 | return nil, p, nil
342 | }
343 | var err error
344 | v, p, err = parsePropertyParamValue(contentLine, p)
345 | if err != nil {
346 | return nil, 0, fmt.Errorf("parse error: %w %s in %s", err, k, r.IANAToken)
347 | }
348 | r.ICalParameters[k] = append(r.ICalParameters[k], v)
349 | if p >= len(contentLine) {
350 | return nil, p, fmt.Errorf("unexpected end of property %s", r.IANAToken)
351 | }
352 | switch rune(contentLine[p]) {
353 | case ',':
354 | p += 1
355 | default:
356 | return r, p, nil
357 | }
358 | }
359 | }
360 |
361 | func parsePropertyParamValue(s string, p int) (string, int, error) {
362 | /*
363 | quoted-string = DQUOTE *QSAFE-CHAR DQUOTE
364 |
365 | QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII
366 | ; Any character except CONTROL and DQUOTE
367 |
368 | SAFE-CHAR = WSP / %x21 / %x23-2B / %x2D-39 / %x3C-7E
369 | / NON-US-ASCII
370 | ; Any character except CONTROL, DQUOTE, ";", ":", ","
371 |
372 | text = *(TSAFE-CHAR / ":" / DQUOTE / ESCAPED-CHAR)
373 | ; Folded according to description above
374 |
375 | ESCAPED-CHAR = "\\" / "\;" / "\," / "\N" / "\n")
376 | ; \\ encodes \, \N or \n encodes newline
377 | ; \; encodes ;, \, encodes ,
378 |
379 | TSAFE-CHAR = %x20-21 / %x23-2B / %x2D-39 / %x3C-5B
380 | %x5D-7E / NON-US-ASCII
381 | ; Any character except CTLs not needed by the current
382 | ; character set, DQUOTE, ";", ":", "\", ","
383 |
384 | CONTROL = %x00-08 / %x0A-1F / %x7F
385 | ; All the controls except HTAB
386 |
387 | */
388 | r := make([]byte, 0, len(s))
389 | quoted := false
390 | done := false
391 | ip := p
392 | for ; p < len(s) && !done; p++ {
393 | switch s[p] {
394 | case 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08:
395 | return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p])
396 | case 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B,
397 | 0x1C, 0x1D, 0x1E, 0x1F:
398 | return "", 0, fmt.Errorf("unexpected char ascii:%d in property param value", s[p])
399 | case '\\':
400 | if p+2 >= len(s) {
401 | return "", 0, errors.New("unexpected end of param value")
402 | }
403 | r = append(r, []byte(FromText(string(s[p+1:p+2])))...)
404 | p++
405 | continue
406 | case ';', ':', ',':
407 | if !quoted {
408 | done = true
409 | p--
410 | continue
411 | }
412 | case '"':
413 | if p == ip {
414 | quoted = true
415 | continue
416 | }
417 | if quoted {
418 | done = true
419 | continue
420 | }
421 | return "", 0, fmt.Errorf("unexpected double quote in property param value")
422 | }
423 | r = append(r, s[p])
424 | }
425 | return string(r), p, nil
426 | }
427 |
428 | func parsePropertyValue(r *BaseProperty, contentLine string, p int) *BaseProperty {
429 | tokenPos := propertyValueTextReg.FindIndex([]byte(contentLine[p:]))
430 | if tokenPos == nil {
431 | return nil
432 | }
433 | r.Value = contentLine[p : p+tokenPos[1]]
434 | if r.GetValueType() == ValueDataTypeText {
435 | r.Value = FromText(r.Value)
436 | }
437 | return r
438 | }
439 |
440 | var textEscaper = strings.NewReplacer(
441 | `\`, `\\`,
442 | "\n", `\n`,
443 | `;`, `\;`,
444 | `,`, `\,`,
445 | )
446 |
447 | func ToText(s string) string {
448 | // Some special characters for iCalendar format should be escaped while
449 | // setting a value of a property with a TEXT type.
450 | return textEscaper.Replace(s)
451 | }
452 |
453 | var textUnescaper = strings.NewReplacer(
454 | `\\`, `\`,
455 | `\n`, "\n",
456 | `\N`, "\n",
457 | `\;`, `;`,
458 | `\,`, `,`,
459 | )
460 |
461 | func FromText(s string) string {
462 | // Some special characters for iCalendar format should be escaped while
463 | // setting a value of a property with a TEXT type.
464 | return textUnescaper.Replace(s)
465 | }
466 |
--------------------------------------------------------------------------------
/property_test.go:
--------------------------------------------------------------------------------
1 | package ics
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | type PropertyValueCheck struct {
10 | Key string
11 | Values []string
12 | }
13 |
14 | func (c *PropertyValueCheck) Check(t *testing.T, output *BaseProperty) {
15 | v, ok := output.ICalParameters[c.Key]
16 | if !ok {
17 | t.Errorf("Key %s value is missing", c.Key)
18 | return
19 | }
20 | assert.Equal(t, c.Values, v)
21 | }
22 |
23 | func NewPropertyValueCheck(key string, properties ...string) *PropertyValueCheck {
24 | return &PropertyValueCheck{
25 | Key: key,
26 | Values: properties,
27 | }
28 | }
29 |
30 | func TestPropertyParse(t *testing.T) {
31 | tests := []struct {
32 | Name string
33 | Input string
34 | Expected func(t *testing.T, output *BaseProperty, err error)
35 | }{
36 | {Name: "Normal attendee parse", Input: "ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@example.com", Expected: func(t *testing.T, output *BaseProperty, err error) {
37 | assert.NoError(t, err)
38 | assert.NotNil(t, output)
39 | assert.Equal(t, "ATTENDEE", output.IANAToken)
40 | assert.Equal(t, "mailto:employee-A@example.com", output.Value)
41 | for _, expected := range []*PropertyValueCheck{
42 | NewPropertyValueCheck("RSVP", "TRUE"),
43 | } {
44 | expected.Check(t, output)
45 | }
46 | }},
47 | {Name: "Attendee parse with quotes", Input: "ATTENDEE;RSVP=\"TRUE\";ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@example.com", Expected: func(t *testing.T, output *BaseProperty, err error) {
48 | assert.NoError(t, err)
49 | assert.NotNil(t, output)
50 | assert.Equal(t, "ATTENDEE", output.IANAToken)
51 | assert.Equal(t, "mailto:employee-A@example.com", output.Value)
52 | for _, expected := range []*PropertyValueCheck{
53 | NewPropertyValueCheck("RSVP", "TRUE"),
54 | } {
55 | expected.Check(t, output)
56 | }
57 | }},
58 | {Name: "Attendee parse with bad quotes", Input: "ATTENDEE;RSVP=T\"RUE\";ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:mailto:employee-A@example.com", Expected: func(t *testing.T, output *BaseProperty, err error) {
59 | assert.Nil(t, output)
60 | assert.Error(t, err)
61 | }},
62 | {Name: "Attendee parse with weird escapes in quotes", Input: "ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=DECLINED;CN=xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com;X-NUM-GUESTS=0;X-RESPONSE-COMMENT=\"Abgelehnt\\, weil ich auß\\;er Haus bin\":mailto:xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com", Expected: func(t *testing.T, output *BaseProperty, err error) {
63 | assert.NotNil(t, output)
64 | assert.NoError(t, err)
65 | assert.Equal(t, "ATTENDEE", output.IANAToken)
66 | assert.Equal(t, "mailto:xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com", output.Value)
67 | for _, expected := range []*PropertyValueCheck{
68 | NewPropertyValueCheck("CUTYPE", "INDIVIDUAL"),
69 | NewPropertyValueCheck("ROLE", "REQ-PARTICIPANT"),
70 | NewPropertyValueCheck("PARTSTAT", "DECLINED"),
71 | NewPropertyValueCheck("CN", "xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com"),
72 | NewPropertyValueCheck("X-NUM-GUESTS", "0"),
73 | NewPropertyValueCheck("X-RESPONSE-COMMENT", "Abgelehnt, weil ich außer Haus bin"),
74 | } {
75 | expected.Check(t, output)
76 | }
77 | }},
78 | {Name: "Attendee parse with weird escapes in quotes short", Input: "ATTENDEE;X-RESPONSE-COMMENT=\"Abgelehnt\\, weil ich auß\\;er Haus bin\":mailto:xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com\n", Expected: func(t *testing.T, output *BaseProperty, err error) {
79 | assert.NotNil(t, output)
80 | assert.NoError(t, err)
81 | assert.Equal(t, "ATTENDEE", output.IANAToken)
82 | assert.Equal(t, "mailto:xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com", output.Value)
83 | for _, expected := range []*PropertyValueCheck{
84 | NewPropertyValueCheck("X-RESPONSE-COMMENT", "Abgelehnt, weil ich außer Haus bin"),
85 | } {
86 | expected.Check(t, output)
87 | }
88 | }},
89 | }
90 | for _, test := range tests {
91 | t.Run(test.Name, func(t *testing.T) {
92 | v, err := ParseProperty(ContentLine(test.Input))
93 | test.Expected(t, v, err)
94 | })
95 | }
96 | }
97 |
98 | func Test_parsePropertyParamValue(t *testing.T) {
99 | tests := []struct {
100 | name string
101 | input string
102 | position int
103 | match string
104 | newposition int
105 | wantErr bool
106 | }{
107 | {
108 | name: "Basic sentence",
109 | input: "basic sentence",
110 | position: 0,
111 | match: "basic sentence",
112 | newposition: len("basic sentence"),
113 | wantErr: false,
114 | },
115 | {
116 | name: "Basic quoted sentence",
117 | input: "\"basic sentence\"",
118 | position: 0,
119 | match: "basic sentence",
120 | newposition: len("basic sentence\"\""),
121 | wantErr: false,
122 | },
123 | {
124 | name: "Basic sentence with terminal ,",
125 | input: "basic sentence,",
126 | position: 0,
127 | match: "basic sentence",
128 | newposition: len("basic sentence"),
129 | wantErr: false,
130 | },
131 | {
132 | name: "Basic sentence with terminal ;",
133 | input: "basic sentence;",
134 | position: 0,
135 | match: "basic sentence",
136 | newposition: len("basic sentence"),
137 | wantErr: false,
138 | },
139 | {
140 | name: "Basic sentence with terminal :",
141 | input: "basic sentence:",
142 | position: 0,
143 | match: "basic sentence",
144 | newposition: len("basic sentence"),
145 | wantErr: false,
146 | },
147 | {
148 | name: "Basic quoted sentence with terminals internal ;:,",
149 | input: "\"basic sentence;:,\"",
150 | position: 0,
151 | match: "basic sentence;:,",
152 | newposition: len("basic sentence;:,\"\""),
153 | wantErr: false,
154 | },
155 | {
156 | name: "Basic quoted sentence with escaped terminals internal ;:,",
157 | input: "\"basic sentence\\;\\:\\,\"",
158 | position: 0,
159 | match: "basic sentence;:,",
160 | newposition: len("basic sentence\\;\\:\\,\"\""),
161 | wantErr: false,
162 | },
163 | {
164 | name: "Basic quoted sentence with escaped quote",
165 | input: "\"basic \\\"sentence\"",
166 | position: 0,
167 | match: "basic \"sentence",
168 | newposition: len("basic sentence\\\"\"\""),
169 | wantErr: false,
170 | },
171 | }
172 | for _, tt := range tests {
173 | t.Run(tt.name, func(t *testing.T) {
174 | got, got1, err := parsePropertyParamValue(tt.input, tt.position)
175 | if (err != nil) != tt.wantErr {
176 | t.Errorf("parsePropertyParamValue() error = %v, wantErr %v", err, tt.wantErr)
177 | return
178 | }
179 | if got != tt.match {
180 | t.Errorf("parsePropertyParamValue() got = %v, want %v", got, tt.match)
181 | }
182 | if got1 != tt.newposition {
183 | t.Errorf("parsePropertyParamValue() got1 = %v, want %v", got1, tt.newposition)
184 | }
185 | })
186 | }
187 | }
188 |
189 | func Test_trimUT8StringUpTo(t *testing.T) {
190 | tests := []struct {
191 | name string
192 | maxLength int
193 | s string
194 | want string
195 | }{
196 | {
197 | name: "simply break at spaces",
198 | s: "simply break at spaces",
199 | maxLength: 14,
200 | want: "simply break",
201 | },
202 | {
203 | name: "(Don't) Break after punctuation 1", // See if we can change this.
204 | s: "hi.are.",
205 | maxLength: len("hi.are"),
206 | want: "hi.are",
207 | },
208 | {
209 | name: "Break after punctuation 2",
210 | s: "Hi how are you?",
211 | maxLength: len("Hi how are you"),
212 | want: "Hi how are",
213 | },
214 | {
215 | name: "HTML opening tag breaking",
216 | s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.",
217 | maxLength: len("I want a custom linkout for Thunderbird.
This is the Github<"),
218 | want: "I want a custom linkout for Thunderbird.
This is the Github",
219 | },
220 | {
221 | name: "HTML closing tag breaking",
222 | s: "I want a custom linkout for Thunderbird.
This is the GithubIssue.",
223 | maxLength: len("I want a custom linkout for Thunderbird.
") + 1,
224 | want: "I want a custom linkout for Thunderbird.
",
225 | },
226 | }
227 | for _, tt := range tests {
228 | t.Run(tt.name, func(t *testing.T) {
229 | assert.Equalf(t, tt.want, trimUT8StringUpTo(tt.maxLength, tt.s), "trimUT8StringUpTo(%v, %v)", tt.maxLength, tt.s)
230 | })
231 | }
232 | }
233 |
234 | func TestFixValueStrings(t *testing.T) {
235 | tests := []struct {
236 | input string
237 | expected string
238 | }{
239 | {"hello", "hello"},
240 | {"hello;world", "hello\\;world"},
241 | {"path\\to:file", "path\\\\to\\:file"},
242 | {"name:\"value\"", "name\\:\\\"value\\\""},
243 | {"key,value", "key\\,value"},
244 | {";:\\\",", "\\;\\:\\\\\\\"\\,"},
245 | }
246 |
247 | for _, tt := range tests {
248 | t.Run(tt.input, func(t *testing.T) {
249 | result := escapeValueString(tt.input)
250 | if result != tt.expected {
251 | t.Errorf("got %q, want %q", result, tt.expected)
252 | }
253 | })
254 | }
255 | }
256 |
--------------------------------------------------------------------------------
/testdata/fuzz/FuzzParseCalendar/5940bf4f62ecac30:
--------------------------------------------------------------------------------
1 | go test fuzz v1
2 | []byte("0;0=\\")
3 |
--------------------------------------------------------------------------------
/testdata/fuzz/FuzzParseCalendar/5f69bd55acfce1af:
--------------------------------------------------------------------------------
1 | go test fuzz v1
2 | []byte("0;0")
3 |
--------------------------------------------------------------------------------
/testdata/fuzz/FuzzParseCalendar/8856e23652c60ed6:
--------------------------------------------------------------------------------
1 | go test fuzz v1
2 | []byte("0;0=0")
3 |
--------------------------------------------------------------------------------
/testdata/issue52/issue52_notworking.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN
3 | VERSION:2.0
4 | CALSCALE:GREGORIAN
5 | METHOD:PUBLISH
6 | X-WR-CALNAME:xxxxx.xxxxxxxxx@xxxxxxxxxxxx.de
7 | X-WR-TIMEZONE:Europe/Berlin
8 | BEGIN:VTIMEZONE
9 | TZID:Europe/Berlin
10 | X-LIC-LOCATION:Europe/Berlin
11 | BEGIN:DAYLIGHT
12 | TZOFFSETFROM:+0100
13 | TZOFFSETTO:+0200
14 | TZNAME:CEST
15 | DTSTART:19700329T020000
16 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
17 | END:DAYLIGHT
18 | BEGIN:STANDARD
19 | TZOFFSETFROM:+0200
20 | TZOFFSETTO:+0100
21 | TZNAME:CET
22 | DTSTART:19701025T030000
23 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
24 | END:STANDARD
25 | END:VTIMEZONE
26 | BEGIN:VEVENT
27 | DTSTART:20220301T170000Z
28 | DTEND:20220301T183000Z
29 | DTSTAMP:20220207T210221Z
30 | ORGANIZER;CN=xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de:mailto:xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de
31 | UID:37dfo8hp5hdsxxxxxxxxxxxxxx@google.com
32 | ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de;X-NUM-GUESTS=0:mailto:xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de
33 | ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN=xxxxxxxxxx xxxx xxxx;X-NUM-GUESTS=0:mailto:xxxxxxxx@xxxxxxxxxxxx.de
34 | ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=DECLINED;CN=xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com;X-NUM-GUESTS=0;X-RESPONSE-COMMENT="Abgelehnt\, weil ich auß\;er Haus bin":mailto:xxxxxx.xxxxxxxxxx@xxxxxxxxxxx.com
35 | X-GOOGLE-CONFERENCE:https://meet.google.com/xxx-xxxx-xxx
36 | CREATED:20220207T190137Z
37 | DESCRIPTION:Dieser Termin enthält einen Videoanruf.\nTeilnehmen: https://meet.google.com/xxx-xxxx-xxx\n(DE) +49 40 xxxxxxxxxx PIN: xxxxxxxxx#\nWeitere Telefonnummern anzeigen: https://tel.meet/xxx-xxxx-xxx?pin=xxxxxxxxxxxxx&hs=x
38 | LAST-MODIFIED:20220207T203520Z
39 | LOCATION:
40 | SEQUENCE:0
41 | STATUS:CONFIRMED
42 | SUMMARY:xxxxxxxxxxxxxxxx xxxxxxxx
43 | TRANSP:OPAQUE
44 | END:VEVENT
45 | END:VCALENDAR
46 |
--------------------------------------------------------------------------------
/testdata/issue52/issue52_working.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN
3 | VERSION:2.0
4 | CALSCALE:GREGORIAN
5 | METHOD:PUBLISH
6 | X-WR-CALNAME:xxxxx.xxxxxxxxx@xxxxxxxxxxxx.de
7 | X-WR-TIMEZONE:Europe/Berlin
8 | BEGIN:VTIMEZONE
9 | TZID:Europe/Berlin
10 | X-LIC-LOCATION:Europe/Berlin
11 | BEGIN:DAYLIGHT
12 | TZOFFSETFROM:+0100
13 | TZOFFSETTO:+0200
14 | TZNAME:CEST
15 | DTSTART:19700329T020000
16 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
17 | END:DAYLIGHT
18 | BEGIN:STANDARD
19 | TZOFFSETFROM:+0200
20 | TZOFFSETTO:+0100
21 | TZNAME:CET
22 | DTSTART:19701025T030000
23 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
24 | END:STANDARD
25 | END:VTIMEZONE
26 | BEGIN:VEVENT
27 | DTSTART:20220301T170000Z
28 | DTEND:20220301T183000Z
29 | DTSTAMP:20220207T210221Z
30 | ORGANIZER;CN=xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de:mailto:xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de
31 | UID:37dfo8hp5hdsxxxxxxxxxxxxxx@google.com
32 | ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de;X-NUM-GUESTS=0:mailto:xxxx.xxxxxxxxxxxxxxx@xxxxxxxxxxxx.de
33 | X-GOOGLE-CONFERENCE:https://meet.google.com/xxx-xxxx-xxx
34 | CREATED:20220207T190137Z
35 | DESCRIPTION:Dieser Termin enthält einen Videoanruf.\nTeilnehmen: https://meet.google.com/xxx-xxxx-xxx\n(DE) +49 40 xxxxxxxxxx PIN: xxxxxxxxx#\nWeitere Telefonnummern anzeigen: https://tel.meet/xxx-xxxx-xxx?pin=xxxxxxxxxxxxx&hs=x
36 | LAST-MODIFIED:20220207T203520Z
37 | LOCATION:
38 | SEQUENCE:0
39 | STATUS:CONFIRMED
40 | SUMMARY:xxxxxxxxxxxxxxxx xxxxxxxx
41 | TRANSP:OPAQUE
42 | END:VEVENT
43 | END:VCALENDAR
44 |
--------------------------------------------------------------------------------
/testdata/issue97/google.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN
3 | VERSION:2.0
4 | CALSCALE:GREGORIAN
5 | METHOD:PUBLISH
6 | X-WR-CALNAME:Test
7 | X-WR-TIMEZONE:Europe/Berlin
8 | BEGIN:VEVENT
9 | DTSTART:20240929T124500Z
10 | DTEND:20240929T134500Z
11 | DTSTAMP:20240929T121653Z
12 | UID:al23c5kr943d42u3bqoqrkf455@google.com
13 | CREATED:20240929T121642Z
14 | DESCRIPTION:I want a custom linkout for Thunderbird.
This is the Github
15 | Issue.
16 | LAST-MODIFIED:20240929T121642Z
17 | LOCATION:GitHub
18 | SEQUENCE:0
19 | STATUS:CONFIRMED
20 | SUMMARY:Test Event
21 | TRANSP:OPAQUE
22 | END:VEVENT
23 | END:VCALENDAR
24 |
--------------------------------------------------------------------------------
/testdata/issue97/thunderbird.ics_disabled:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
3 | VERSION:2.0
4 | BEGIN:VTIMEZONE
5 | TZID:Europe/Berlin
6 | X-TZINFO:Europe/Berlin[2024a]
7 | BEGIN:STANDARD
8 | TZOFFSETTO:+010000
9 | TZOFFSETFROM:+005328
10 | TZNAME:Europe/Berlin(STD)
11 | DTSTART:18930401T000000
12 | RDATE:18930401T000000
13 | END:STANDARD
14 | END:VTIMEZONE
15 | BEGIN:VEVENT
16 | CREATED:20240929T120640Z
17 | LAST-MODIFIED:20240929T120731Z
18 | DTSTAMP:20240929T120731Z
19 | UID:d23cef0d-9e58-43c4-9391-5ad8483ca346
20 | SUMMARY:Test Event
21 | DTSTART;TZID=Europe/Berlin:20240929T144500
22 | DTEND;TZID=Europe/Berlin:20240929T154500
23 | TRANSP:OPAQUE
24 | LOCATION:Github
25 | DESCRIPTION;ALTREP="data:text/html,I%20want%20
26 | a%20custom%20linkout%20for%20
27 | Thunderbird.%3Cbr%3EThis%20is%20the%20Github%20
28 | %3Ca%20href%3D%22https%3A%2F
29 | %2Fgithub.com%2Farran4%2Fgolang-ical%2Fissues%2F97%22
30 | %3EIssue%3C%2Fa%3E.":I
31 | want a custom linkout for Thunderbird.\nThis is the Github Issue.
32 | END:VEVENT
33 | END:VCALENDAR
34 |
35 |
36 |
37 | Disabled due to wordwrapping differences
38 |
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input1.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
3 | VERSION:2.0
4 | BEGIN:VEVENT
5 | DTSTAMP:19960704T120000Z
6 | UID:uid1@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | DTSTART:19960918T143000Z
9 | DTEND:19960920T220000Z
10 | STATUS:CONFIRMED
11 | CATEGORIES:CONFERENCE
12 | SUMMARY:Networld+Interop Conference
13 | DESCRIPTION:Networld+Interop Conference
14 | and Exhibit\nAtlanta World Congress Center\n
15 | Atlanta\, Georgia
16 | END:VEVENT
17 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input2.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//RDU Software//NONSGML HandCal//EN
3 | VERSION:2.0
4 | BEGIN:VTIMEZONE
5 | TZID:America/New_York
6 | BEGIN:STANDARD
7 | DTSTART:19981025T020000
8 | TZOFFSETFROM:-0400
9 | TZOFFSETTO:-0500
10 | TZNAME:EST
11 | END:STANDARD
12 | BEGIN:DAYLIGHT
13 | DTSTART:19990404T020000
14 | TZOFFSETFROM:-0500
15 | TZOFFSETTO:-0400
16 | TZNAME:EDT
17 | END:DAYLIGHT
18 | END:VTIMEZONE
19 | BEGIN:VEVENT
20 | DTSTAMP:19980309T231000Z
21 | UID:guid-1.example.com
22 | ORGANIZER:mailto:mrbig@example.com
23 | ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:
24 | mailto:employee-A@example.com
25 | DESCRIPTION:Project XYZ Review Meeting
26 | CATEGORIES:MEETING
27 | CLASS:PUBLIC
28 | CREATED:19980309T130000Z
29 | SUMMARY:XYZ Project Review
30 | DTSTART;TZID=America/New_York:19980312T083000
31 | DTEND;TZID=America/New_York:19980312T093000
32 | LOCATION:1CP Conference Room 4350
33 | END:VEVENT
34 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input3.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VTODO
5 | DTSTAMP:19980130T134500Z
6 | SEQUENCE:2
7 | UID:uid4@example.com
8 | ORGANIZER:mailto:unclesam@example.com
9 | ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com
10 | DUE:19980415T000000
11 | STATUS:NEEDS-ACTION
12 | SUMMARY:Submit Income Taxes
13 | BEGIN:VALARM
14 | ACTION:AUDIO
15 | TRIGGER:19980403T120000Z
16 | ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-
17 | files/ssbanner.aud
18 | REPEAT:4
19 | DURATION:PT1H
20 | END:VALARM
21 | END:VTODO
22 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input4.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report,XYZ,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\n
12 | Agenda\n1. Review of project version 1.0 requirements.\n2.
13 | Definition
14 | of project processes.\n3. Review of project schedule.\n
15 | Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was
16 | decided that the requirements need to be signed off by
17 | product marketing.\n-Project processes were accepted.\n
18 | -Project schedule needs to account for scheduled holidays
19 | and employee vacation time. Check with HR for specific
20 | dates.\n-New schedule will be distributed by Friday.\n-
21 | Next weeks meeting is cancelled. No meeting until 3/23.
22 | END:VJOURNAL
23 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input5.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report,XYZ,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\n
12 | Agenda\n1. Review of project version 1.0 requirements.\n2.
13 | Definition
14 | of project processes.\n3. Review of project schedule.\n
15 | Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was
16 | decided that the requirements need to be signed off by
17 | product marketing.\n-Project processes were accepted.\n
18 | -Project schedule needs to account for scheduled holidays
19 | and employee vacation time. Check with HR for specific
20 | dates.\n-New schedule will be distributed by Friday.\n-
21 | Next weeks meeting is cancelled. No meeting until 3/23.
22 | END:VJOURNAL
23 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/rfc5545sec4/input6.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//RDU Software//NONSGML HandCal//EN
4 | BEGIN:VFREEBUSY
5 | ORGANIZER:mailto:jsmith@example.com
6 | DTSTART:19980313T141711Z
7 | DTEND:19980410T141711Z
8 | FREEBUSY:19980314T233000Z/19980315T003000Z
9 | FREEBUSY:19980316T153000Z/19980316T163000Z
10 | FREEBUSY:19980318T030000Z/19980318T040000Z
11 | URL:http://www.example.com/calendar/busytime/jsmith.ifb
12 | END:VFREEBUSY
13 | END:VCALENDAR
--------------------------------------------------------------------------------
/testdata/serialization/expected/input1.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
3 | VERSION:2.0
4 | BEGIN:VEVENT
5 | DTSTAMP:19960704T120000Z
6 | UID:uid1@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | DTSTART:19960918T143000Z
9 | DTEND:19960920T220000Z
10 | STATUS:CONFIRMED
11 | CATEGORIES:CONFERENCE
12 | SUMMARY:Networld+Interop Conference
13 | DESCRIPTION:Networld+Interop Conference and Exhibit\nAtlanta World Congress
14 | Center\nAtlanta\, Georgia
15 | END:VEVENT
16 | END:VCALENDAR
17 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input2.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//RDU Software//NONSGML HandCal//EN
3 | VERSION:2.0
4 | BEGIN:VTIMEZONE
5 | TZID:America/New_York
6 | BEGIN:STANDARD
7 | DTSTART:19981025T020000
8 | TZOFFSETFROM:-0400
9 | TZOFFSETTO:-0500
10 | TZNAME:EST
11 | END:STANDARD
12 | BEGIN:DAYLIGHT
13 | DTSTART:19990404T020000
14 | TZOFFSETFROM:-0500
15 | TZOFFSETTO:-0400
16 | TZNAME:EDT
17 | END:DAYLIGHT
18 | END:VTIMEZONE
19 | BEGIN:VEVENT
20 | DTSTAMP:19980309T231000Z
21 | UID:guid-1.example.com
22 | ORGANIZER:mailto:mrbig@example.com
23 | ATTENDEE;CUTYPE=GROUP;ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:employee-A@exam
24 | ple.com
25 | DESCRIPTION:Project XYZ Review Meeting
26 | CATEGORIES:MEETING
27 | CLASS:PUBLIC
28 | CREATED:19980309T130000Z
29 | SUMMARY:XYZ Project Review
30 | DTSTART;TZID=America/New_York:19980312T083000
31 | DTEND;TZID=America/New_York:19980312T093000
32 | LOCATION:1CP Conference Room 4350
33 | END:VEVENT
34 | END:VCALENDAR
35 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input3.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VTODO
5 | DTSTAMP:19980130T134500Z
6 | SEQUENCE:2
7 | UID:uid4@example.com
8 | ORGANIZER:mailto:unclesam@example.com
9 | ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com
10 | DUE:19980415T000000
11 | STATUS:NEEDS-ACTION
12 | SUMMARY:Submit Income Taxes
13 | BEGIN:VALARM
14 | ACTION:AUDIO
15 | TRIGGER:19980403T120000Z
16 | ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-files/ssbanner.aud
17 | REPEAT:4
18 | DURATION:PT1H
19 | END:VALARM
20 | END:VTODO
21 | END:VCALENDAR
22 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input4.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report\,XYZ\,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of
12 | project version 1.0 requirements.\n2. Definitionof project processes.\n3.
13 | Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim
14 | Dandy\n-It was decided that the requirements need to be signed off by
15 | product marketing.\n-Project processes were accepted.\n-Project schedule
16 | needs to account for scheduled holidays and employee vacation time. Check
17 | with HR for specific dates.\n-New schedule will be distributed by
18 | Friday.\n-Next weeks meeting is cancelled. No meeting until 3/23.
19 | END:VJOURNAL
20 | END:VCALENDAR
21 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input5.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report\,XYZ\,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\nAgenda\n1. Review of
12 | project version 1.0 requirements.\n2. Definitionof project processes.\n3.
13 | Review of project schedule.\nParticipants: John Smith\, Jane Doe\, Jim
14 | Dandy\n-It was decided that the requirements need to be signed off by
15 | product marketing.\n-Project processes were accepted.\n-Project schedule
16 | needs to account for scheduled holidays and employee vacation time. Check
17 | with HR for specific dates.\n-New schedule will be distributed by
18 | Friday.\n-Next weeks meeting is cancelled. No meeting until 3/23.
19 | END:VJOURNAL
20 | END:VCALENDAR
21 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input6.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//RDU Software//NONSGML HandCal//EN
4 | BEGIN:VFREEBUSY
5 | ORGANIZER:mailto:jsmith@example.com
6 | DTSTART:19980313T141711Z
7 | DTEND:19980410T141711Z
8 | FREEBUSY:19980314T233000Z/19980315T003000Z
9 | FREEBUSY:19980316T153000Z/19980316T163000Z
10 | FREEBUSY:19980318T030000Z/19980318T040000Z
11 | URL:http://www.example.com/calendar/busytime/jsmith.ifb
12 | END:VFREEBUSY
13 | END:VCALENDAR
14 |
--------------------------------------------------------------------------------
/testdata/serialization/expected/input7.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
3 | VERSION:2.0
4 | BEGIN:VEVENT
5 | DTSTAMP:19960704T120000Z
6 | UID:uid1@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | DTSTART:19960918T143000Z
9 | DTEND:19960920T220000Z
10 | STATUS:CONFIRMED
11 | CATEGORIES:CONFERENCE
12 | SUMMARY:Networld+Interop Conference
13 | DESCRIPTION:[{"Name":"Some
14 | Test"\,"Data":""}\,{"Name":"Meeting"\,"Foo":"Bar"}]
15 | END:VEVENT
16 | END:VCALENDAR
17 |
--------------------------------------------------------------------------------
/testdata/serialization/input1.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
3 | VERSION:2.0
4 | BEGIN:VEVENT
5 | DTSTAMP:19960704T120000Z
6 | UID:uid1@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | DTSTART:19960918T143000Z
9 | DTEND:19960920T220000Z
10 | STATUS:CONFIRMED
11 | CATEGORIES:CONFERENCE
12 | SUMMARY:Networld+Interop Conference
13 | DESCRIPTION:Networld+Interop Conference
14 | and Exhibit\nAtlanta World Congress Center\n
15 | Atlanta\, Georgia
16 | END:VEVENT
17 | END:VCALENDAR
18 |
--------------------------------------------------------------------------------
/testdata/serialization/input2.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//RDU Software//NONSGML HandCal//EN
3 | VERSION:2.0
4 | BEGIN:VTIMEZONE
5 | TZID:America/New_York
6 | BEGIN:STANDARD
7 | DTSTART:19981025T020000
8 | TZOFFSETFROM:-0400
9 | TZOFFSETTO:-0500
10 | TZNAME:EST
11 | END:STANDARD
12 | BEGIN:DAYLIGHT
13 | DTSTART:19990404T020000
14 | TZOFFSETFROM:-0500
15 | TZOFFSETTO:-0400
16 | TZNAME:EDT
17 | END:DAYLIGHT
18 | END:VTIMEZONE
19 | BEGIN:VEVENT
20 | DTSTAMP:19980309T231000Z
21 | UID:guid-1.example.com
22 | ORGANIZER:mailto:mrbig@example.com
23 | ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:
24 | mailto:employee-A@example.com
25 | DESCRIPTION:Project XYZ Review Meeting
26 | CATEGORIES:MEETING
27 | CLASS:PUBLIC
28 | CREATED:19980309T130000Z
29 | SUMMARY:XYZ Project Review
30 | DTSTART;TZID=America/New_York:19980312T083000
31 | DTEND;TZID=America/New_York:19980312T093000
32 | LOCATION:1CP Conference Room 4350
33 | END:VEVENT
34 | END:VCALENDAR
35 |
--------------------------------------------------------------------------------
/testdata/serialization/input3.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VTODO
5 | DTSTAMP:19980130T134500Z
6 | SEQUENCE:2
7 | UID:uid4@example.com
8 | ORGANIZER:mailto:unclesam@example.com
9 | ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com
10 | DUE:19980415T000000
11 | STATUS:NEEDS-ACTION
12 | SUMMARY:Submit Income Taxes
13 | BEGIN:VALARM
14 | ACTION:AUDIO
15 | TRIGGER:19980403T120000Z
16 | ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-
17 | files/ssbanner.aud
18 | REPEAT:4
19 | DURATION:PT1H
20 | END:VALARM
21 | END:VTODO
22 | END:VCALENDAR
23 |
--------------------------------------------------------------------------------
/testdata/serialization/input4.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report,XYZ,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\n
12 | Agenda\n1. Review of project version 1.0 requirements.\n2.
13 | Definition
14 | of project processes.\n3. Review of project schedule.\n
15 | Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was
16 | decided that the requirements need to be signed off by
17 | product marketing.\n-Project processes were accepted.\n
18 | -Project schedule needs to account for scheduled holidays
19 | and employee vacation time. Check with HR for specific
20 | dates.\n-New schedule will be distributed by Friday.\n-
21 | Next weeks meeting is cancelled. No meeting until 3/23.
22 | END:VJOURNAL
23 | END:VCALENDAR
24 |
--------------------------------------------------------------------------------
/testdata/serialization/input5.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//ABC Corporation//NONSGML My Product//EN
4 | BEGIN:VJOURNAL
5 | DTSTAMP:19970324T120000Z
6 | UID:uid5@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | STATUS:DRAFT
9 | CLASS:PUBLIC
10 | CATEGORIES:Project Report,XYZ,Weekly Meeting
11 | DESCRIPTION:Project xyz Review Meeting Minutes\n
12 | Agenda\n1. Review of project version 1.0 requirements.\n2.
13 | Definition
14 | of project processes.\n3. Review of project schedule.\n
15 | Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was
16 | decided that the requirements need to be signed off by
17 | product marketing.\n-Project processes were accepted.\n
18 | -Project schedule needs to account for scheduled holidays
19 | and employee vacation time. Check with HR for specific
20 | dates.\n-New schedule will be distributed by Friday.\n-
21 | Next weeks meeting is cancelled. No meeting until 3/23.
22 | END:VJOURNAL
23 | END:VCALENDAR
24 |
--------------------------------------------------------------------------------
/testdata/serialization/input6.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:-//RDU Software//NONSGML HandCal//EN
4 | BEGIN:VFREEBUSY
5 | ORGANIZER:mailto:jsmith@example.com
6 | DTSTART:19980313T141711Z
7 | DTEND:19980410T141711Z
8 | FREEBUSY:19980314T233000Z/19980315T003000Z
9 | FREEBUSY:19980316T153000Z/19980316T163000Z
10 | FREEBUSY:19980318T030000Z/19980318T040000Z
11 | URL:http://www.example.com/calendar/busytime/jsmith.ifb
12 | END:VFREEBUSY
13 | END:VCALENDAR
14 |
--------------------------------------------------------------------------------
/testdata/serialization/input7.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
3 | VERSION:2.0
4 | BEGIN:VEVENT
5 | DTSTAMP:19960704T120000Z
6 | UID:uid1@example.com
7 | ORGANIZER:mailto:jsmith@example.com
8 | DTSTART:19960918T143000Z
9 | DTEND:19960920T220000Z
10 | STATUS:CONFIRMED
11 | CATEGORIES:CONFERENCE
12 | SUMMARY:Networld+Interop Conference
13 | DESCRIPTION:[{"Name":"Some
14 | Test"\,"Data":""}\,{"Name":"Meeting"\,"Foo":"Bar"}]
15 | END:VEVENT
16 | END:VCALENDAR
17 |
18 |
--------------------------------------------------------------------------------
/testdata/timeparsing.ics:
--------------------------------------------------------------------------------
1 | BEGIN:VCALENDAR
2 | VERSION:2.0
3 | PRODID:DAVx5/4.0-gplay ical4j/3.1.0
4 | BEGIN:VTIMEZONE
5 | TZID:Europe/Copenhagen
6 | LAST-MODIFIED:20201010T011803Z
7 | BEGIN:STANDARD
8 | DTSTART:19961027T030000
9 | RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
10 | TZNAME:CET
11 | TZOFFSETFROM:+0200
12 | TZOFFSETTO:+0100
13 | END:STANDARD
14 | BEGIN:DAYLIGHT
15 | DTSTART:19810329T020000
16 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
17 | TZNAME:CEST
18 | TZOFFSETFROM:+0100
19 | TZOFFSETTO:+0200
20 | END:DAYLIGHT
21 | END:VTIMEZONE
22 | BEGIN:VEVENT
23 | UID:269cf715-4e14-4a10-8753-f2feeb9d060e
24 | DTSTART;TZID=Europe/Copenhagen:20211207T140000
25 | DTEND;TZID=Europe/Copenhagen:20211207T150000
26 | SUMMARY:Form 3
27 | END:VEVENT
28 | BEGIN:VEVENT
29 | UID:53634aed-1b7d-4d85-aa38-ede76a2e4fe3
30 | DTSTART:20220122T170000Z
31 | DTEND:20220122T200000Z
32 | SUMMARY:Form #2
33 | END:VEVENT
34 | BEGIN:VEVENT
35 | UID:be7c9690-d42a-40ef-b82f-1634dc5033b4
36 | DTSTART:19980118T230000
37 | DTEND:19980119T230000
38 | DTSTAMP:20210624T080748Z
39 | SUMMARY:Form #1
40 | END:VEVENT
41 | BEGIN:VEVENT
42 | UID:fb54680e-7f69-46d3-9632-00aed2469f7b
43 | DTSTART;VALUE=DATE:20210627
44 | DTEND;VALUE=DATE:20210628
45 | SUMMARY:Unknown local date, with 'VALUE'
46 | END:VEVENT
47 | BEGIN:VEVENT
48 | UID:62475ad0-a76c-4fab-8e68-f99209afcca6
49 | DTSTART:20210527Z
50 | DTEND:20210528Z
51 | SUMMARY:Unknown UTC date
52 | END:VEVENT
53 | END:VCALENDAR
54 |
--------------------------------------------------------------------------------