├── .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 | <!-- 11 | Note: Please search to see if an issue already exists for the bug you encountered. 12 | --> 13 | 14 | ### Current Behavior: 15 | <!-- A concise description of what you're experiencing. --> 16 | 17 | ### Expected Behavior: 18 | <!-- A concise description of what you expected to happen. --> 19 | 20 | ### Steps To Reproduce: 21 | <!-- 22 | Example: steps to reproduce the behavior: 23 | 1. In this environment... 24 | 1. With this config... 25 | 1. Run '...' 26 | 1. See error... 27 | --> 28 | 29 | ### Minimal Example ical extract: 30 | 31 | ```ical 32 | BEGIN:VCALENDAR 33 | .... 34 | ``` 35 | 36 | ### Anything else: 37 | <!-- 38 | Links? References? Anything that will give us more context about the issue that you are encountering! 39 | --> 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Something else 3 | about: Any other issue 4 | title: '<title>' 5 | labels: Needs Triage 6 | assignees: '' 7 | 8 | --- 9 | 10 | <!-- 11 | Note: Please search to see if an issue already exists for the bug you encountered. 12 | 13 | Please include smallest possible sized examples. 14 | --> 15 | 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | <!-- 2 | 3 | Thanks for contributing! 4 | 5 | --> 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 | <!-- The following line will automatically close the issues if done correctly --> 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 | [![GoDoc](https://godoc.org/github.com/arran4/golang-ical?status.svg)](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.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", 217 | maxLength: len("I want a custom linkout for Thunderbird.<br>This is the Github<"), 218 | want: "I want a custom linkout for Thunderbird.<br>This is the Github", 219 | }, 220 | { 221 | name: "HTML closing tag breaking", 222 | s: "I want a custom linkout for Thunderbird.<br>This is the Github<a href=\"https://github.com/arran4/golang-ical/issues/97\">Issue</a>.", 223 | maxLength: len("I want a custom linkout for Thunderbird.<br>") + 1, 224 | want: "I want a custom linkout for Thunderbird.<br>", 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.<br>This is the Github 15 | <a href="https://github.com/arran4/golang-ical/issues/97">Issue</a>. 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 | --------------------------------------------------------------------------------