├── go.sum ├── Makefile ├── go.mod ├── .github ├── ISSUE_TEMPLATE │ ├── 4_security_issue_disclosure.md │ ├── 3_docs_wiki_or_website_issue.md │ ├── 1_bug_report.md │ └── 2_feature_request.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ └── ci-cover.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── util.go ├── README.md ├── example_test.go ├── rruleset.go ├── rruleset_test.go ├── str_test.go ├── str.go └── rrule.go /go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test --race 3 | 4 | .PHONY: test 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/teambition/rrule-go 2 | 3 | go 1.16 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4_security_issue_disclosure.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F513 Security issue disclosure" 3 | about: Report a security issue in fxamacker/cbor 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3_docs_wiki_or_website_issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4DA Docs, wiki, or website issue" 3 | about: Report an issue regarding documentation, wiki, or website 4 | title: 'docs: ' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the URL of the content?** 11 | 12 | 13 | **Please describe the problem.** 14 | 15 | 16 | **Screenshot (if applicable).** 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | debug 26 | .idea 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: go 3 | matrix: 4 | include: 5 | - go: "1.12.x" 6 | - go: "1.13.x" 7 | - go: "1.14.x" 8 | - go: "1.15.x" 9 | env: 10 | - GO111MODULE=on 11 | before_install: 12 | - go get -t -v ./... 13 | - go get github.com/mattn/goveralls 14 | script: 15 | - go test -coverprofile=rrule.coverprofile 16 | - goveralls -coverprofile=rrule.coverprofile -service=travis-ci 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "gomod" 6 | directory: "/" # Location of package manifests 7 | schedule: 8 | interval: "weekly" 9 | 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: "monthly" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41E Bug report" 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | teambition/rrule-go version: v1.x.x 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | Code to reproduce the behavior: 16 | ```go 17 | // your code. 18 | ``` 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4A1 Feature request" 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - 'master' 6 | jobs: 7 | # Test on various OS with default Go version. 8 | tests: 9 | name: Test on ${{matrix.os}} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | go-version: ['1.18.x', '1.19.x'] 15 | 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 1 26 | 27 | - name: Print Go version 28 | run: go version 29 | 30 | - name: Get dependencies 31 | run: go get -v -t -d ./... 32 | 33 | - name: Run tests 34 | run: go test -v -failfast -tags=test -timeout="3m" -race ./... 35 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: '26 4 * * 3' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'go' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | 34 | - name: Autobuild 35 | uses: github/codeql-action/autobuild@v2 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v2 39 | with: 40 | category: "/language:${{matrix.language}}" 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2023 Teambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci-cover.yml: -------------------------------------------------------------------------------- 1 | name: CI Cover 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | # Test on various OS with default Go version. 8 | tests: 9 | name: Test on ${{matrix.os}} 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | go-version: ['1.19.x'] 15 | 16 | steps: 17 | - name: Install Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | 22 | - name: Checkout code 23 | uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 1 26 | 27 | - name: Print Go version 28 | run: go version 29 | 30 | - name: Get dependencies 31 | run: go get -v -t -d ./... 32 | 33 | - name: Run tests 34 | run: go test -v -failfast -tags=test -timeout="3m" -coverprofile="./coverage.out" -covermode="atomic" ./... 35 | 36 | - name: Upload coverage to Codecov 37 | uses: codecov/codecov-action@v3 38 | with: 39 | token: ${{ secrets.CODECOV_TOKEN }} 40 | files: ./coverage.out 41 | flags: unittests 42 | name: codecov-umbrella 43 | verbose: true 44 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "errors" 7 | "math" 8 | "time" 9 | ) 10 | 11 | // MAXYEAR 12 | const ( 13 | MAXYEAR = 9999 14 | ) 15 | 16 | // Next is a generator of time.Time. 17 | // It returns false of Ok if there is no value to generate. 18 | type Next func() (value time.Time, ok bool) 19 | 20 | type timeSlice []time.Time 21 | 22 | func (s timeSlice) Len() int { return len(s) } 23 | func (s timeSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 24 | func (s timeSlice) Less(i, j int) bool { return s[i].Before(s[j]) } 25 | 26 | // Python: MO-SU: 0 - 6 27 | // Golang: SU-SAT 0 - 6 28 | func toPyWeekday(from time.Weekday) int { 29 | return []int{6, 0, 1, 2, 3, 4, 5}[from] 30 | } 31 | 32 | // year -> 1 if leap year, else 0." 33 | func isLeap(year int) int { 34 | if year%4 == 0 && (year%100 != 0 || year%400 == 0) { 35 | return 1 36 | } 37 | return 0 38 | } 39 | 40 | // daysIn returns the number of days in a month for a given year. 41 | func daysIn(m time.Month, year int) int { 42 | return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() 43 | } 44 | 45 | // mod in Python 46 | func pymod(a, b int) int { 47 | r := a % b 48 | // If r and b differ in sign, add b to wrap the result to the correct sign. 49 | if r*b < 0 { 50 | r += b 51 | } 52 | return r 53 | } 54 | 55 | // divmod in Python 56 | func divmod(a, b int) (div, mod int) { 57 | return int(math.Floor(float64(a) / float64(b))), pymod(a, b) 58 | } 59 | 60 | func contains(list []int, elem int) bool { 61 | for _, t := range list { 62 | if t == elem { 63 | return true 64 | } 65 | } 66 | return false 67 | } 68 | 69 | func timeContains(list []time.Time, elem time.Time) bool { 70 | for _, t := range list { 71 | if t.Equal(elem) { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | 78 | func repeat(value, count int) []int { 79 | result := []int{} 80 | for i := 0; i < count; i++ { 81 | result = append(result, value) 82 | } 83 | return result 84 | } 85 | 86 | func concat(slices ...[]int) []int { 87 | result := []int{} 88 | for _, item := range slices { 89 | result = append(result, item...) 90 | } 91 | return result 92 | } 93 | 94 | func rang(start, end int) []int { 95 | result := []int{} 96 | for i := start; i < end; i++ { 97 | result = append(result, i) 98 | } 99 | return result 100 | } 101 | 102 | func pySubscript(slice []int, index int) (int, error) { 103 | if index < 0 { 104 | index += len(slice) 105 | } 106 | if index < 0 || index >= len(slice) { 107 | return 0, errors.New("index error") 108 | } 109 | return slice[index], nil 110 | } 111 | 112 | func timeSliceIterator(s []time.Time) func() (time.Time, bool) { 113 | index := 0 114 | return func() (time.Time, bool) { 115 | if index >= len(s) { 116 | return time.Time{}, false 117 | } 118 | result := s[index] 119 | index++ 120 | return result, true 121 | } 122 | } 123 | 124 | func easter(year int) time.Time { 125 | g := year % 19 126 | c := year / 100 127 | h := (c - c/4 - (8*c+13)/25 + 19*g + 15) % 30 128 | i := h - (h/28)*(1-(h/28)*(29/(h+1))*((21-g)/11)) 129 | j := (year + year/4 + i + 2 - c + c/4) % 7 130 | p := i - j 131 | d := 1 + (p+27+(p+6)/40)%31 132 | m := 3 + (p+26)/30 133 | return time.Date(year, time.Month(m), d, 0, 0, 0, 0, time.UTC) 134 | } 135 | 136 | func all(next Next) []time.Time { 137 | result := []time.Time{} 138 | for { 139 | v, ok := next() 140 | if !ok { 141 | return result 142 | } 143 | result = append(result, v) 144 | } 145 | } 146 | 147 | func between(next Next, after, before time.Time, inc bool) []time.Time { 148 | result := []time.Time{} 149 | for { 150 | v, ok := next() 151 | if !ok || inc && v.After(before) || !inc && !v.Before(before) { 152 | return result 153 | } 154 | if inc && !v.Before(after) || !inc && v.After(after) { 155 | result = append(result, v) 156 | } 157 | } 158 | } 159 | 160 | func before(next Next, dt time.Time, inc bool) time.Time { 161 | result := time.Time{} 162 | for { 163 | v, ok := next() 164 | if !ok || inc && v.After(dt) || !inc && !v.Before(dt) { 165 | return result 166 | } 167 | result = v 168 | } 169 | } 170 | 171 | func after(next Next, dt time.Time, inc bool) time.Time { 172 | for { 173 | v, ok := next() 174 | if !ok { 175 | return time.Time{} 176 | } 177 | if inc && !v.Before(dt) || !inc && v.After(dt) { 178 | return v 179 | } 180 | } 181 | } 182 | 183 | type optInt struct { 184 | Int int 185 | Defined bool 186 | } 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rrule-go 2 | 3 | Go library for working with recurrence rules for calendar dates. 4 | 5 | [![CI](https://github.com/teambition/rrule-go/actions/workflows/ci.yml/badge.svg)](https://github.com/teambition/rrule-go/actions/workflows/ci.yml) 6 | [![Codecov](https://codecov.io/gh/teambition/rrule-go/master/main/graph/badge.svg)](https://codecov.io/gh/teambition/rrule-go) 7 | [![CodeQL](https://github.com/teambition/rrule-go/actions/workflows/codeql.yml/badge.svg)](https://github.com/teambition/rrule-go/actions/workflows/codeql.yml) 8 | [![License](http://img.shields.io/badge/license-mit-blue.svg?style=flat-square)](https://raw.githubusercontent.com/teambition/rrule-go/master/LICENSE) 9 | [![Go Reference](https://pkg.go.dev/badge/github.com/teambition/rrule-go.svg)](https://pkg.go.dev/github.com/teambition/rrule-go) 10 | 11 | The rrule module offers a complete implementation of the recurrence rules documented in the [iCalendar 12 | RFC](http://www.ietf.org/rfc/rfc2445.txt). It is a partial port of the rrule module from the excellent [python-dateutil](http://labix.org/python-dateutil/) library. 13 | 14 | ## Demo 15 | 16 | ### rrule.RRule 17 | 18 | ```go 19 | package main 20 | 21 | import ( 22 | "fmt" 23 | "time" 24 | 25 | "github.com/teambition/rrule-go" 26 | ) 27 | 28 | func printTimeSlice(ts []time.Time) { 29 | for _, t := range ts { 30 | fmt.Println(t) 31 | } 32 | } 33 | 34 | func main() { 35 | // Daily, for 10 occurrences. 36 | r, _ := rrule.NewRRule(rrule.ROption{ 37 | Freq: rrule.DAILY, 38 | Count: 10, 39 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 40 | }) 41 | 42 | fmt.Println(r.String()) 43 | // DTSTART:19970902T090000Z 44 | // RRULE:FREQ=DAILY;COUNT=10 45 | 46 | printTimeSlice(r.All()) 47 | // 1997-09-02 09:00:00 +0000 UTC 48 | // 1997-09-03 09:00:00 +0000 UTC 49 | // ... 50 | // 1997-09-07 09:00:00 +0000 UTC 51 | 52 | printTimeSlice(r.Between( 53 | time.Date(1997, 9, 6, 0, 0, 0, 0, time.UTC), 54 | time.Date(1997, 9, 8, 0, 0, 0, 0, time.UTC), true)) 55 | // [1997-09-06 09:00:00 +0000 UTC 56 | // 1997-09-07 09:00:00 +0000 UTC] 57 | 58 | // Every four years, the first Tuesday after a Monday in November, 3 occurrences (U.S. Presidential Election day). 59 | r, _ = rrule.NewRRule(rrule.ROption{ 60 | Freq: rrule.YEARLY, 61 | Interval: 4, 62 | Count: 3, 63 | Bymonth: []int{11}, 64 | Byweekday: []rrule.Weekday{rrule.TU}, 65 | Bymonthday: []int{2, 3, 4, 5, 6, 7, 8}, 66 | Dtstart: time.Date(1996, 11, 5, 9, 0, 0, 0, time.UTC), 67 | }) 68 | 69 | fmt.Println(r.String()) 70 | // DTSTART:19961105T090000Z 71 | // RRULE:FREQ=YEARLY;INTERVAL=4;COUNT=3;BYMONTH=11;BYMONTHDAY=2,3,4,5,6,7,8;BYDAY=TU 72 | 73 | printTimeSlice(r.All()) 74 | // 1996-11-05 09:00:00 +0000 UTC 75 | // 2000-11-07 09:00:00 +0000 UTC 76 | // 2004-11-02 09:00:00 +0000 UTC 77 | } 78 | 79 | ``` 80 | 81 | ### rrule.Set 82 | 83 | ```go 84 | func ExampleSet() { 85 | // Daily, for 7 days, jumping Saturday and Sunday occurrences. 86 | set := rrule.Set{} 87 | r, _ := rrule.NewRRule(rrule.ROption{ 88 | Freq: rrule.DAILY, 89 | Count: 7, 90 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 91 | set.RRule(r) 92 | 93 | fmt.Println(set.String()) 94 | // DTSTART:19970902T090000Z 95 | // RRULE:FREQ=DAILY;COUNT=7 96 | 97 | printTimeSlice(set.All()) 98 | // 1997-09-02 09:00:00 +0000 UTC 99 | // 1997-09-03 09:00:00 +0000 UTC 100 | // 1997-09-04 09:00:00 +0000 UTC 101 | // 1997-09-05 09:00:00 +0000 UTC 102 | // 1997-09-06 09:00:00 +0000 UTC 103 | // 1997-09-07 09:00:00 +0000 UTC 104 | // 1997-09-08 09:00:00 +0000 UTC 105 | 106 | // Weekly, for 4 weeks, plus one time on day 7, and not on day 16. 107 | set = rrule.Set{} 108 | r, _ = rrule.NewRRule(rrule.ROption{ 109 | Freq: rrule.WEEKLY, 110 | Count: 4, 111 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 112 | set.RRule(r) 113 | set.RDate(time.Date(1997, 9, 7, 9, 0, 0, 0, time.UTC)) 114 | set.ExDate(time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)) 115 | 116 | fmt.Println(set.String()) 117 | // DTSTART:19970902T090000Z 118 | // RRULE:FREQ=WEEKLY;COUNT=4 119 | // RDATE:19970907T090000Z 120 | // EXDATE:19970916T090000Z 121 | 122 | printTimeSlice(set.All()) 123 | // 1997-09-02 09:00:00 +0000 UTC 124 | // 1997-09-07 09:00:00 +0000 UTC 125 | // 1997-09-09 09:00:00 +0000 UTC 126 | // 1997-09-23 09:00:00 +0000 UTC 127 | } 128 | ``` 129 | 130 | ### rrule.StrToRRule 131 | 132 | ```go 133 | func ExampleStrToRRule() { 134 | // Compatible with old DTSTART 135 | r, _ := rrule.StrToRRule("FREQ=DAILY;DTSTART=20060101T150405Z;COUNT=5") 136 | fmt.Println(r.OrigOptions.RRuleString()) 137 | // FREQ=DAILY;COUNT=5 138 | 139 | fmt.Println(r.OrigOptions.String()) 140 | // DTSTART:20060101T150405Z 141 | // RRULE:FREQ=DAILY;COUNT=5 142 | 143 | fmt.Println(r.String()) 144 | // DTSTART:20060101T150405Z 145 | // RRULE:FREQ=DAILY;COUNT=5 146 | 147 | printTimeSlice(r.All()) 148 | // 2006-01-01 15:04:05 +0000 UTC 149 | // 2006-01-02 15:04:05 +0000 UTC 150 | // 2006-01-03 15:04:05 +0000 UTC 151 | // 2006-01-04 15:04:05 +0000 UTC 152 | // 2006-01-05 15:04:05 +0000 UTC 153 | } 154 | ``` 155 | 156 | ### rrule.StrToRRuleSet 157 | 158 | ```go 159 | func ExampleStrToRRuleSet() { 160 | s, _ := rrule.StrToRRuleSet("DTSTART:20060101T150405Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE:20060102T150405Z") 161 | fmt.Println(s.String()) 162 | // DTSTART:20060101T150405Z 163 | // RRULE:FREQ=DAILY;COUNT=5 164 | // EXDATE:20060102T150405Z 165 | 166 | printTimeSlice(s.All()) 167 | // 2006-01-01 15:04:05 +0000 UTC 168 | // 2006-01-03 15:04:05 +0000 UTC 169 | // 2006-01-04 15:04:05 +0000 UTC 170 | // 2006-01-05 15:04:05 +0000 UTC 171 | } 172 | ``` 173 | 174 | For more examples see [python-dateutil](http://labix.org/python-dateutil/) documentation. 175 | 176 | ## License 177 | 178 | Gear is licensed under the [MIT](https://github.com/teambition/gear/blob/master/LICENSE) license. 179 | Copyright © 2017-2023 [Teambition](https://www.teambition.com). 180 | -------------------------------------------------------------------------------- /example_test.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule_test 4 | 5 | import ( 6 | "fmt" 7 | "time" 8 | 9 | "github.com/teambition/rrule-go" 10 | ) 11 | 12 | func printTimeSlice(ts []time.Time) { 13 | for _, t := range ts { 14 | fmt.Println(t) 15 | } 16 | } 17 | 18 | func ExampleRRule() { 19 | // Daily, for 10 occurrences. 20 | r, _ := rrule.NewRRule(rrule.ROption{ 21 | Freq: rrule.DAILY, 22 | Count: 10, 23 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 24 | }) 25 | 26 | fmt.Println(r.String()) 27 | // DTSTART:19970902T090000Z 28 | // RRULE:FREQ=DAILY;COUNT=10 29 | 30 | printTimeSlice(r.All()) 31 | // 1997-09-02 09:00:00 +0000 UTC 32 | // 1997-09-03 09:00:00 +0000 UTC 33 | // ... 34 | // 1997-09-07 09:00:00 +0000 UTC 35 | 36 | printTimeSlice(r.Between( 37 | time.Date(1997, 9, 6, 0, 0, 0, 0, time.UTC), 38 | time.Date(1997, 9, 8, 0, 0, 0, 0, time.UTC), true)) 39 | // [1997-09-06 09:00:00 +0000 UTC 40 | // 1997-09-07 09:00:00 +0000 UTC] 41 | 42 | // Every four years, the first Tuesday after a Monday in November, 3 occurrences (U.S. Presidential Election day). 43 | r, _ = rrule.NewRRule(rrule.ROption{ 44 | Freq: rrule.YEARLY, 45 | Interval: 4, 46 | Count: 3, 47 | Bymonth: []int{11}, 48 | Byweekday: []rrule.Weekday{rrule.TU}, 49 | Bymonthday: []int{2, 3, 4, 5, 6, 7, 8}, 50 | Dtstart: time.Date(1996, 11, 5, 9, 0, 0, 0, time.UTC), 51 | }) 52 | 53 | fmt.Println(r.String()) 54 | // DTSTART:19961105T090000Z 55 | // RRULE:FREQ=YEARLY;INTERVAL=4;COUNT=3;BYMONTH=11;BYMONTHDAY=2,3,4,5,6,7,8;BYDAY=TU 56 | 57 | printTimeSlice(r.All()) 58 | // 1996-11-05 09:00:00 +0000 UTC 59 | // 2000-11-07 09:00:00 +0000 UTC 60 | // 2004-11-02 09:00:00 +0000 UTC 61 | 62 | // Output: 63 | // DTSTART:19970902T090000Z 64 | // RRULE:FREQ=DAILY;COUNT=10 65 | // 1997-09-02 09:00:00 +0000 UTC 66 | // 1997-09-03 09:00:00 +0000 UTC 67 | // 1997-09-04 09:00:00 +0000 UTC 68 | // 1997-09-05 09:00:00 +0000 UTC 69 | // 1997-09-06 09:00:00 +0000 UTC 70 | // 1997-09-07 09:00:00 +0000 UTC 71 | // 1997-09-08 09:00:00 +0000 UTC 72 | // 1997-09-09 09:00:00 +0000 UTC 73 | // 1997-09-10 09:00:00 +0000 UTC 74 | // 1997-09-11 09:00:00 +0000 UTC 75 | // 1997-09-06 09:00:00 +0000 UTC 76 | // 1997-09-07 09:00:00 +0000 UTC 77 | // DTSTART:19961105T090000Z 78 | // RRULE:FREQ=YEARLY;INTERVAL=4;COUNT=3;BYMONTH=11;BYMONTHDAY=2,3,4,5,6,7,8;BYDAY=TU 79 | // 1996-11-05 09:00:00 +0000 UTC 80 | // 2000-11-07 09:00:00 +0000 UTC 81 | // 2004-11-02 09:00:00 +0000 UTC 82 | } 83 | 84 | func ExampleSet() { 85 | // Daily, for 7 days, jumping Saturday and Sunday occurrences. 86 | set := rrule.Set{} 87 | r, _ := rrule.NewRRule(rrule.ROption{ 88 | Freq: rrule.DAILY, 89 | Count: 7, 90 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 91 | set.RRule(r) 92 | 93 | fmt.Println(set.String()) 94 | // DTSTART:19970902T090000Z 95 | // RRULE:FREQ=DAILY;COUNT=7 96 | 97 | printTimeSlice(set.All()) 98 | // 1997-09-02 09:00:00 +0000 UTC 99 | // 1997-09-03 09:00:00 +0000 UTC 100 | // 1997-09-04 09:00:00 +0000 UTC 101 | // 1997-09-05 09:00:00 +0000 UTC 102 | // 1997-09-06 09:00:00 +0000 UTC 103 | // 1997-09-07 09:00:00 +0000 UTC 104 | // 1997-09-08 09:00:00 +0000 UTC 105 | 106 | // Weekly, for 4 weeks, plus one time on day 7, and not on day 16. 107 | set = rrule.Set{} 108 | r, _ = rrule.NewRRule(rrule.ROption{ 109 | Freq: rrule.WEEKLY, 110 | Count: 4, 111 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 112 | set.RRule(r) 113 | set.RDate(time.Date(1997, 9, 7, 9, 0, 0, 0, time.UTC)) 114 | set.ExDate(time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)) 115 | 116 | fmt.Println(set.String()) 117 | // DTSTART:19970902T090000Z 118 | // RRULE:FREQ=WEEKLY;COUNT=4 119 | // RDATE:19970907T090000Z 120 | // EXDATE:19970916T090000Z 121 | 122 | printTimeSlice(set.All()) 123 | // 1997-09-02 09:00:00 +0000 UTC 124 | // 1997-09-07 09:00:00 +0000 UTC 125 | // 1997-09-09 09:00:00 +0000 UTC 126 | // 1997-09-23 09:00:00 +0000 UTC 127 | 128 | // Output: 129 | // DTSTART:19970902T090000Z 130 | // RRULE:FREQ=DAILY;COUNT=7 131 | // 1997-09-02 09:00:00 +0000 UTC 132 | // 1997-09-03 09:00:00 +0000 UTC 133 | // 1997-09-04 09:00:00 +0000 UTC 134 | // 1997-09-05 09:00:00 +0000 UTC 135 | // 1997-09-06 09:00:00 +0000 UTC 136 | // 1997-09-07 09:00:00 +0000 UTC 137 | // 1997-09-08 09:00:00 +0000 UTC 138 | // DTSTART:19970902T090000Z 139 | // RRULE:FREQ=WEEKLY;COUNT=4 140 | // RDATE:19970907T090000Z 141 | // EXDATE:19970916T090000Z 142 | // 1997-09-02 09:00:00 +0000 UTC 143 | // 1997-09-07 09:00:00 +0000 UTC 144 | // 1997-09-09 09:00:00 +0000 UTC 145 | // 1997-09-23 09:00:00 +0000 UTC 146 | } 147 | 148 | func ExampleStrToRRule() { 149 | // Compatible with old DTSTART 150 | r, _ := rrule.StrToRRule("FREQ=DAILY;DTSTART=20060101T150405Z;COUNT=5") 151 | fmt.Println(r.OrigOptions.RRuleString()) 152 | // FREQ=DAILY;COUNT=5 153 | 154 | fmt.Println(r.OrigOptions.String()) 155 | // DTSTART:20060101T150405Z 156 | // RRULE:FREQ=DAILY;COUNT=5 157 | 158 | fmt.Println(r.String()) 159 | // DTSTART:20060101T150405Z 160 | // RRULE:FREQ=DAILY;COUNT=5 161 | 162 | printTimeSlice(r.All()) 163 | // 2006-01-01 15:04:05 +0000 UTC 164 | // 2006-01-02 15:04:05 +0000 UTC 165 | // 2006-01-03 15:04:05 +0000 UTC 166 | // 2006-01-04 15:04:05 +0000 UTC 167 | // 2006-01-05 15:04:05 +0000 UTC 168 | 169 | // Output: 170 | // FREQ=DAILY;COUNT=5 171 | // DTSTART:20060101T150405Z 172 | // RRULE:FREQ=DAILY;COUNT=5 173 | // DTSTART:20060101T150405Z 174 | // RRULE:FREQ=DAILY;COUNT=5 175 | // 2006-01-01 15:04:05 +0000 UTC 176 | // 2006-01-02 15:04:05 +0000 UTC 177 | // 2006-01-03 15:04:05 +0000 UTC 178 | // 2006-01-04 15:04:05 +0000 UTC 179 | // 2006-01-05 15:04:05 +0000 UTC 180 | } 181 | 182 | func ExampleStrToRRuleSet() { 183 | s, _ := rrule.StrToRRuleSet("DTSTART:20060101T150405Z\nRRULE:FREQ=DAILY;COUNT=5\nEXDATE:20060102T150405Z") 184 | fmt.Println(s.String()) 185 | // DTSTART:20060101T150405Z 186 | // RRULE:FREQ=DAILY;COUNT=5 187 | // EXDATE:20060102T150405Z 188 | 189 | printTimeSlice(s.All()) 190 | // 2006-01-01 15:04:05 +0000 UTC 191 | // 2006-01-03 15:04:05 +0000 UTC 192 | // 2006-01-04 15:04:05 +0000 UTC 193 | // 2006-01-05 15:04:05 +0000 UTC 194 | 195 | // Output: 196 | // DTSTART:20060101T150405Z 197 | // RRULE:FREQ=DAILY;COUNT=5 198 | // EXDATE:20060102T150405Z 199 | // 2006-01-01 15:04:05 +0000 UTC 200 | // 2006-01-03 15:04:05 +0000 UTC 201 | // 2006-01-04 15:04:05 +0000 UTC 202 | // 2006-01-05 15:04:05 +0000 UTC 203 | } 204 | -------------------------------------------------------------------------------- /rruleset.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "fmt" 7 | "sort" 8 | "time" 9 | ) 10 | 11 | // Set allows more complex recurrence setups, mixing multiple rules, dates, exclusion rules, and exclusion dates 12 | type Set struct { 13 | dtstart time.Time 14 | rrule *RRule 15 | rdate []time.Time 16 | exdate []time.Time 17 | } 18 | 19 | // Recurrence returns a slice of all the recurrence rules for a set 20 | func (set *Set) Recurrence() []string { 21 | var res []string 22 | 23 | if !set.dtstart.IsZero() { 24 | // No colon, DTSTART may have TZID, which would require a semicolon after DTSTART 25 | res = append(res, fmt.Sprintf("DTSTART%s", timeToRFCDatetimeStr(set.dtstart))) 26 | } 27 | 28 | if set.rrule != nil { 29 | res = append(res, fmt.Sprintf("RRULE:%s", set.rrule.OrigOptions.RRuleString())) 30 | } 31 | 32 | for _, item := range set.rdate { 33 | res = append(res, fmt.Sprintf("RDATE%s", timeToRFCDatetimeStr(item))) 34 | } 35 | 36 | for _, item := range set.exdate { 37 | res = append(res, fmt.Sprintf("EXDATE%s", timeToRFCDatetimeStr(item))) 38 | } 39 | return res 40 | } 41 | 42 | // DTStart sets dtstart property for set. 43 | // It will be truncated to second precision. 44 | func (set *Set) DTStart(dtstart time.Time) { 45 | set.dtstart = dtstart.Truncate(time.Second) 46 | 47 | if set.rrule != nil { 48 | set.rrule.DTStart(set.dtstart) 49 | } 50 | } 51 | 52 | // GetDTStart gets DTSTART for set 53 | func (set *Set) GetDTStart() time.Time { 54 | return set.dtstart 55 | } 56 | 57 | // RRule set the RRULE for set. 58 | // There is the only one RRULE in the set as https://tools.ietf.org/html/rfc5545#appendix-A.1 59 | func (set *Set) RRule(rrule *RRule) { 60 | if !rrule.OrigOptions.Dtstart.IsZero() { 61 | set.dtstart = rrule.dtstart 62 | } else if !set.dtstart.IsZero() { 63 | rrule.DTStart(set.dtstart) 64 | } 65 | set.rrule = rrule 66 | } 67 | 68 | // GetRRule returns the rrules in the set 69 | func (set *Set) GetRRule() *RRule { 70 | return set.rrule 71 | } 72 | 73 | // RDate include the given datetime instance in the recurrence set generation. 74 | // It will be truncated to second precision. 75 | func (set *Set) RDate(rdate time.Time) { 76 | set.rdate = append(set.rdate, rdate.Truncate(time.Second)) 77 | } 78 | 79 | // SetRDates sets explicitly added dates (rdates) in the set. 80 | // It will be truncated to second precision. 81 | func (set *Set) SetRDates(rdates []time.Time) { 82 | set.rdate = make([]time.Time, 0, len(rdates)) 83 | for _, rdate := range rdates { 84 | set.rdate = append(set.rdate, rdate.Truncate(time.Second)) 85 | } 86 | } 87 | 88 | // GetRDate returns explicitly added dates (rdates) in the set 89 | func (set *Set) GetRDate() []time.Time { 90 | return set.rdate 91 | } 92 | 93 | // ExDate include the given datetime instance in the recurrence set exclusion list. 94 | // Dates included that way will not be generated, 95 | // even if some inclusive rrule or rdate matches them. 96 | // It will be truncated to second precision. 97 | func (set *Set) ExDate(exdate time.Time) { 98 | set.exdate = append(set.exdate, exdate.Truncate(time.Second)) 99 | } 100 | 101 | // SetExDates sets explicitly excluded dates (exdates) in the set. 102 | // It will be truncated to second precision. 103 | func (set *Set) SetExDates(exdates []time.Time) { 104 | set.exdate = make([]time.Time, 0, len(exdates)) 105 | for _, exdate := range exdates { 106 | set.exdate = append(set.exdate, exdate.Truncate(time.Second)) 107 | } 108 | } 109 | 110 | // GetExDate returns explicitly excluded dates (exdates) in the set 111 | func (set *Set) GetExDate() []time.Time { 112 | return set.exdate 113 | } 114 | 115 | type genItem struct { 116 | dt time.Time 117 | gen Next 118 | } 119 | 120 | type genItemSlice []genItem 121 | 122 | func (s genItemSlice) Len() int { return len(s) } 123 | func (s genItemSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 124 | func (s genItemSlice) Less(i, j int) bool { return s[i].dt.Before(s[j].dt) } 125 | 126 | func addGenList(genList *[]genItem, next Next) { 127 | dt, ok := next() 128 | if ok { 129 | *genList = append(*genList, genItem{dt, next}) 130 | } 131 | } 132 | 133 | // Iterator returns an iterator for rrule.Set 134 | func (set *Set) Iterator() (next func() (time.Time, bool)) { 135 | rlist := []genItem{} 136 | exlist := []genItem{} 137 | 138 | sort.Sort(timeSlice(set.rdate)) 139 | addGenList(&rlist, timeSliceIterator(set.rdate)) 140 | if set.rrule != nil { 141 | addGenList(&rlist, set.rrule.Iterator()) 142 | } 143 | sort.Sort(genItemSlice(rlist)) 144 | 145 | sort.Sort(timeSlice(set.exdate)) 146 | addGenList(&exlist, timeSliceIterator(set.exdate)) 147 | sort.Sort(genItemSlice(exlist)) 148 | 149 | lastdt := time.Time{} 150 | return func() (time.Time, bool) { 151 | for len(rlist) != 0 { 152 | dt := rlist[0].dt 153 | var ok bool 154 | rlist[0].dt, ok = rlist[0].gen() 155 | if !ok { 156 | rlist = rlist[1:] 157 | } 158 | sort.Sort(genItemSlice(rlist)) 159 | if lastdt.IsZero() || !lastdt.Equal(dt) { 160 | for len(exlist) != 0 && exlist[0].dt.Before(dt) { 161 | exlist[0].dt, ok = exlist[0].gen() 162 | if !ok { 163 | exlist = exlist[1:] 164 | } 165 | sort.Sort(genItemSlice(exlist)) 166 | } 167 | lastdt = dt 168 | if len(exlist) == 0 || !dt.Equal(exlist[0].dt) { 169 | return dt, true 170 | } 171 | } 172 | } 173 | return time.Time{}, false 174 | } 175 | } 176 | 177 | // All returns all occurrences of the rrule.Set. 178 | // It is only supported second precision. 179 | func (set *Set) All() []time.Time { 180 | return all(set.Iterator()) 181 | } 182 | 183 | // Between returns all the occurrences of the rrule between after and before. 184 | // The inc keyword defines what happens if after and/or before are themselves occurrences. 185 | // With inc == True, they will be included in the list, if they are found in the recurrence set. 186 | // It is only supported second precision. 187 | func (set *Set) Between(after, before time.Time, inc bool) []time.Time { 188 | return between(set.Iterator(), after, before, inc) 189 | } 190 | 191 | // Before Returns the last recurrence before the given datetime instance, 192 | // or time.Time's zero value if no recurrence match. 193 | // The inc keyword defines what happens if dt is an occurrence. 194 | // With inc == True, if dt itself is an occurrence, it will be returned. 195 | // It is only supported second precision. 196 | func (set *Set) Before(dt time.Time, inc bool) time.Time { 197 | return before(set.Iterator(), dt, inc) 198 | } 199 | 200 | // After returns the first recurrence after the given datetime instance, 201 | // or time.Time's zero value if no recurrence match. 202 | // The inc keyword defines what happens if dt is an occurrence. 203 | // With inc == True, if dt itself is an occurrence, it will be returned. 204 | // It is only supported second precision. 205 | func (set *Set) After(dt time.Time, inc bool) time.Time { 206 | return after(set.Iterator(), dt, inc) 207 | } 208 | -------------------------------------------------------------------------------- /rruleset_test.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestSet(t *testing.T) { 11 | set := Set{} 12 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 2, Byweekday: []Weekday{TU}, 13 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 14 | set.RRule(r) 15 | value := set.All() 16 | want := []time.Time{time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 17 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)} 18 | if !timesEqual(value, want) { 19 | t.Errorf("get %v, want %v", value, want) 20 | } 21 | } 22 | 23 | func TestSetOverlapping(t *testing.T) { 24 | set := Set{} 25 | r, _ := NewRRule(ROption{Freq: YEARLY, 26 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 27 | set.RRule(r) 28 | v1 := set.All() 29 | if len(v1) > 300 || len(v1) < 200 { 30 | t.Errorf("No default Util time") 31 | } 32 | } 33 | 34 | func TestSetString(t *testing.T) { 35 | moscow, _ := time.LoadLocation("Europe/Moscow") 36 | newYork, _ := time.LoadLocation("America/New_York") 37 | tehran, _ := time.LoadLocation("Asia/Tehran") 38 | 39 | set := Set{} 40 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 1, Byweekday: []Weekday{TU}, 41 | Dtstart: time.Date(1997, 9, 2, 8, 0, 0, 0, time.UTC)}) 42 | set.RRule(r) 43 | set.ExDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 44 | set.ExDate(time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC).In(moscow)) 45 | set.ExDate(time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC).In(newYork)) 46 | set.RDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC).In(tehran)) 47 | set.RDate(time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)) 48 | 49 | want := `DTSTART:19970902T080000Z 50 | RRULE:FREQ=YEARLY;COUNT=1;BYDAY=TU 51 | RDATE;TZID=Asia/Tehran:19970904T133000 52 | RDATE:19970909T090000Z 53 | EXDATE:19970904T090000Z 54 | EXDATE;TZID=Europe/Moscow:19970911T130000 55 | EXDATE;TZID=America/New_York:19970918T050000` 56 | value := set.String() 57 | if want != value { 58 | t.Errorf("get %v, want %v", value, want) 59 | } 60 | } 61 | 62 | func TestSetDTStart(t *testing.T) { 63 | set := Set{} 64 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 1, Byweekday: []Weekday{TU}, 65 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 66 | set.RRule(r) 67 | set.ExDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 68 | set.ExDate(time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC)) 69 | set.ExDate(time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC)) 70 | set.RDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 71 | set.RDate(time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)) 72 | 73 | nyLoc, _ := time.LoadLocation("America/New_York") 74 | set.DTStart(time.Date(1997, 9, 3, 9, 0, 0, 0, nyLoc)) 75 | 76 | want := `DTSTART;TZID=America/New_York:19970903T090000 77 | RRULE:FREQ=YEARLY;COUNT=1;BYDAY=TU 78 | RDATE:19970904T090000Z 79 | RDATE:19970909T090000Z 80 | EXDATE:19970904T090000Z 81 | EXDATE:19970911T090000Z 82 | EXDATE:19970918T090000Z` 83 | value := set.String() 84 | if want != value { 85 | t.Errorf("get \n%v\n want \n%v\n", value, want) 86 | } 87 | 88 | sset, err := StrToRRuleSet(set.String()) 89 | if err != nil { 90 | t.Errorf("Could not create RSET from set output") 91 | } 92 | if sset.String() != set.String() { 93 | t.Errorf("RSET created from set output different than original set, %s", sset.String()) 94 | } 95 | } 96 | 97 | func TestSetRecurrence(t *testing.T) { 98 | set := Set{} 99 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 1, Byweekday: []Weekday{TU}, 100 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 101 | set.RRule(r) 102 | value := set.Recurrence() 103 | if len(value) != 2 { 104 | t.Errorf("Wrong length for recurrence got=%v want=%v", len(value), 2) 105 | } 106 | want := "DTSTART:19970902T090000Z\nRRULE:FREQ=YEARLY;COUNT=1;BYDAY=TU" 107 | if set.String() != want { 108 | t.Errorf("get %s, want %v", set.String(), want) 109 | } 110 | } 111 | 112 | func TestSetDate(t *testing.T) { 113 | set := Set{} 114 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 1, Byweekday: []Weekday{TU}, 115 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 116 | set.RRule(r) 117 | set.RDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 118 | set.RDate(time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)) 119 | value := set.All() 120 | want := []time.Time{time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 121 | time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 122 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)} 123 | if !timesEqual(value, want) { 124 | t.Errorf("get %v, want %v", value, want) 125 | } 126 | } 127 | 128 | func TestSetRDates(t *testing.T) { 129 | set := Set{} 130 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 1, Byweekday: []Weekday{TU}, 131 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 132 | set.RRule(r) 133 | set.SetRDates([]time.Time{ 134 | time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 135 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC), 136 | }) 137 | value := set.All() 138 | want := []time.Time{ 139 | time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 140 | time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 141 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC), 142 | } 143 | if !timesEqual(value, want) { 144 | t.Errorf("get %v, want %v", value, want) 145 | } 146 | } 147 | 148 | func TestSetExDate(t *testing.T) { 149 | set := Set{} 150 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 6, Byweekday: []Weekday{TU, TH}, 151 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 152 | set.RRule(r) 153 | set.ExDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 154 | set.ExDate(time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC)) 155 | set.ExDate(time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC)) 156 | value := set.All() 157 | want := []time.Time{time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 158 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC), 159 | time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)} 160 | if !timesEqual(value, want) { 161 | t.Errorf("get %v, want %v", value, want) 162 | } 163 | } 164 | 165 | func TestSetExDates(t *testing.T) { 166 | set := Set{} 167 | r, _ := NewRRule(ROption{Freq: YEARLY, Count: 6, Byweekday: []Weekday{TU, TH}, 168 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 169 | set.RRule(r) 170 | set.SetExDates([]time.Time{ 171 | time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 172 | time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC), 173 | time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC), 174 | }) 175 | value := set.All() 176 | want := []time.Time{time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 177 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC), 178 | time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)} 179 | if !timesEqual(value, want) { 180 | t.Errorf("get %v, want %v", value, want) 181 | } 182 | } 183 | 184 | func TestSetExDateRevOrder(t *testing.T) { 185 | set := Set{} 186 | r, _ := NewRRule(ROption{Freq: MONTHLY, Count: 5, Bymonthday: []int{10}, 187 | Dtstart: time.Date(2004, 1, 1, 9, 0, 0, 0, time.UTC)}) 188 | set.RRule(r) 189 | set.ExDate(time.Date(2004, 4, 10, 9, 0, 0, 0, time.UTC)) 190 | set.ExDate(time.Date(2004, 2, 10, 9, 0, 0, 0, time.UTC)) 191 | value := set.All() 192 | want := []time.Time{time.Date(2004, 1, 10, 9, 0, 0, 0, time.UTC), 193 | time.Date(2004, 3, 10, 9, 0, 0, 0, time.UTC), 194 | time.Date(2004, 5, 10, 9, 0, 0, 0, time.UTC)} 195 | if !timesEqual(value, want) { 196 | t.Errorf("get %v, want %v", value, want) 197 | } 198 | } 199 | 200 | func TestSetDateAndExDate(t *testing.T) { 201 | set := Set{} 202 | set.RDate(time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)) 203 | set.RDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 204 | set.RDate(time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC)) 205 | set.RDate(time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC)) 206 | set.RDate(time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)) 207 | set.RDate(time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC)) 208 | set.ExDate(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC)) 209 | set.ExDate(time.Date(1997, 9, 11, 9, 0, 0, 0, time.UTC)) 210 | set.ExDate(time.Date(1997, 9, 18, 9, 0, 0, 0, time.UTC)) 211 | value := set.All() 212 | want := []time.Time{time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC), 213 | time.Date(1997, 9, 9, 9, 0, 0, 0, time.UTC), 214 | time.Date(1997, 9, 16, 9, 0, 0, 0, time.UTC)} 215 | if !timesEqual(value, want) { 216 | t.Errorf("get %v, want %v", value, want) 217 | } 218 | } 219 | 220 | func TestSetBefore(t *testing.T) { 221 | set := Set{} 222 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 223 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 224 | set.RRule(r) 225 | want := time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC) 226 | value := set.Before(time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC), false) 227 | if value != want { 228 | t.Errorf("get %v, want %v", value, want) 229 | } 230 | } 231 | 232 | func TestSetBeforeInc(t *testing.T) { 233 | set := Set{} 234 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 235 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 236 | set.RRule(r) 237 | want := time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC) 238 | value := set.Before(time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC), true) 239 | if value != want { 240 | t.Errorf("get %v, want %v", value, want) 241 | } 242 | } 243 | 244 | func TestSetAfter(t *testing.T) { 245 | set := Set{} 246 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 247 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 248 | set.RRule(r) 249 | want := time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC) 250 | value := set.After(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), false) 251 | if value != want { 252 | t.Errorf("get %v, want %v", value, want) 253 | } 254 | } 255 | 256 | func TestSetAfterInc(t *testing.T) { 257 | set := Set{} 258 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 259 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 260 | set.RRule(r) 261 | want := time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC) 262 | value := set.After(time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), true) 263 | if value != want { 264 | t.Errorf("get %v, want %v", value, want) 265 | } 266 | } 267 | 268 | func TestSetBetween(t *testing.T) { 269 | set := Set{} 270 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 271 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 272 | set.RRule(r) 273 | value := set.Between(time.Date(1997, 9, 3, 9, 0, 0, 0, time.UTC), time.Date(1997, 9, 6, 9, 0, 0, 0, time.UTC), false) 274 | want := []time.Time{time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 275 | time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC)} 276 | if !timesEqual(value, want) { 277 | t.Errorf("get %v, want %v", value, want) 278 | } 279 | } 280 | 281 | func TestSetBetweenInc(t *testing.T) { 282 | set := Set{} 283 | r, _ := NewRRule(ROption{Freq: DAILY, Count: 7, 284 | Dtstart: time.Date(1997, 9, 2, 9, 0, 0, 0, time.UTC)}) 285 | set.RRule(r) 286 | value := set.Between(time.Date(1997, 9, 3, 9, 0, 0, 0, time.UTC), time.Date(1997, 9, 6, 9, 0, 0, 0, time.UTC), true) 287 | want := []time.Time{time.Date(1997, 9, 3, 9, 0, 0, 0, time.UTC), 288 | time.Date(1997, 9, 4, 9, 0, 0, 0, time.UTC), 289 | time.Date(1997, 9, 5, 9, 0, 0, 0, time.UTC), 290 | time.Date(1997, 9, 6, 9, 0, 0, 0, time.UTC)} 291 | if !timesEqual(value, want) { 292 | t.Errorf("get %v, want %v", value, want) 293 | } 294 | } 295 | 296 | func TestSetTrickyTimeZones(t *testing.T) { 297 | set := Set{} 298 | 299 | moscow, _ := time.LoadLocation("Europe/Moscow") 300 | newYork, _ := time.LoadLocation("America/New_York") 301 | tehran, _ := time.LoadLocation("Asia/Tehran") 302 | 303 | r, _ := NewRRule(ROption{ 304 | Freq: DAILY, 305 | Count: 4, 306 | Dtstart: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).In(moscow), 307 | }) 308 | set.RRule(r) 309 | 310 | set.ExDate(time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC).In(newYork)) 311 | set.ExDate(time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC).In(tehran)) 312 | set.ExDate(time.Date(2000, 1, 3, 0, 0, 0, 0, time.UTC).In(moscow)) 313 | set.ExDate(time.Date(2000, 1, 4, 0, 0, 0, 0, time.UTC)) 314 | 315 | occurrences := set.All() 316 | 317 | if len(occurrences) > 0 { 318 | t.Errorf("No all occurrences excluded by ExDate: [%+v]", occurrences) 319 | } 320 | } 321 | 322 | func TestSetDtStart(t *testing.T) { 323 | ogr := []string{"DTSTART;TZID=America/Los_Angeles:20181115T000000", "RRULE:FREQ=DAILY;INTERVAL=1;WKST=SU;UNTIL=20181117T235959"} 324 | set, _ := StrSliceToRRuleSet(ogr) 325 | 326 | ogoc := set.All() 327 | set.DTStart(set.GetDTStart().AddDate(0, 0, 1)) 328 | 329 | noc := set.All() 330 | if len(noc) != len(ogoc)-1 { 331 | t.Fatalf("As per the new DTStart the new occurences should exactly be one less that the original, new :%d original: %d", len(noc), len(ogoc)) 332 | } 333 | 334 | for i := range noc { 335 | if noc[i] != ogoc[i+1] { 336 | t.Errorf("New occurences should just offset by one, mismatch at %d, expected: %+v, actual: %+v", i, ogoc[i+1], noc[i]) 337 | } 338 | } 339 | } 340 | 341 | func TestRuleSetChangeDTStartTimezoneRespected(t *testing.T) { 342 | /* 343 | https://golang.org/pkg/time/#LoadLocation 344 | 345 | "The time zone database needed by LoadLocation may not be present on all systems, especially non-Unix systems. 346 | LoadLocation looks in the directory or uncompressed zip file named by the ZONEINFO environment variable, 347 | if any, then looks in known installation locations on Unix systems, and finally looks in 348 | $GOROOT/lib/time/zoneinfo.zip." 349 | */ 350 | loc, err := time.LoadLocation("CET") 351 | if err != nil { 352 | t.Fatal("expected", nil, "got", err) 353 | } 354 | 355 | ruleSet := &Set{} 356 | rule, err := NewRRule( 357 | ROption{ 358 | Freq: DAILY, 359 | Count: 10, 360 | Wkst: MO, 361 | Byhour: []int{10}, 362 | Byminute: []int{0}, 363 | Bysecond: []int{0}, 364 | Dtstart: time.Date(2019, 3, 6, 0, 0, 0, 0, loc), 365 | }, 366 | ) 367 | if err != nil { 368 | t.Fatal("expected", nil, "got", err) 369 | } 370 | ruleSet.RRule(rule) 371 | ruleSet.DTStart(time.Date(2019, 3, 6, 0, 0, 0, 0, time.UTC)) 372 | 373 | events := ruleSet.All() 374 | if len(events) != 10 { 375 | t.Fatal("expected", 10, "got", len(events)) 376 | } 377 | 378 | for _, e := range events { 379 | if e.Location().String() != "UTC" { 380 | t.Fatal("expected", "UTC", "got", e.Location().String()) 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /str_test.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestRFCRuleToStr(t *testing.T) { 11 | nyLoc, _ := time.LoadLocation("America/New_York") 12 | dtStart := time.Date(2018, 1, 1, 9, 0, 0, 0, nyLoc) 13 | 14 | r, _ := NewRRule(ROption{Freq: MONTHLY, Dtstart: dtStart}) 15 | want := "DTSTART;TZID=America/New_York:20180101T090000\nRRULE:FREQ=MONTHLY" 16 | if r.String() != want { 17 | t.Errorf("Expected RFC string %s, got %v", want, r.String()) 18 | } 19 | } 20 | 21 | func TestRFCSetToString(t *testing.T) { 22 | nyLoc, _ := time.LoadLocation("America/New_York") 23 | dtStart := time.Date(2018, 1, 1, 9, 0, 0, 0, nyLoc) 24 | 25 | r, _ := NewRRule(ROption{Freq: MONTHLY, Dtstart: dtStart}) 26 | want := "DTSTART;TZID=America/New_York:20180101T090000\nRRULE:FREQ=MONTHLY" 27 | if r.String() != want { 28 | t.Errorf("Expected RFC string %s, got %v", want, r.String()) 29 | } 30 | 31 | expectedSetStr := "DTSTART;TZID=America/New_York:20180101T090000\nRRULE:FREQ=MONTHLY" 32 | 33 | set := Set{} 34 | set.RRule(r) 35 | set.DTStart(dtStart) 36 | if set.String() != expectedSetStr { 37 | t.Errorf("Expected RFC Set string %s, got %s", expectedSetStr, set.String()) 38 | } 39 | } 40 | 41 | func TestCompatibility(t *testing.T) { 42 | str := "FREQ=WEEKLY;DTSTART=20120201T093000Z;INTERVAL=5;WKST=TU;COUNT=2;UNTIL=20130130T230000Z;BYSETPOS=2;BYMONTH=3;BYYEARDAY=95;BYWEEKNO=1;BYDAY=MO,+2FR;BYHOUR=9;BYMINUTE=30;BYSECOND=0;BYEASTER=-1" 43 | r, _ := StrToRRule(str) 44 | want := "DTSTART:20120201T093000Z\nRRULE:FREQ=WEEKLY;INTERVAL=5;WKST=TU;COUNT=2;UNTIL=20130130T230000Z;BYSETPOS=2;BYMONTH=3;BYYEARDAY=95;BYWEEKNO=1;BYDAY=MO,+2FR;BYHOUR=9;BYMINUTE=30;BYSECOND=0;BYEASTER=-1" 45 | if s := r.String(); s != want { 46 | t.Errorf("StrToRRule(%q).String() = %q, want %q", str, s, want) 47 | } 48 | r, _ = StrToRRule(want) 49 | if s := r.String(); s != want { 50 | t.Errorf("StrToRRule(%q).String() = %q, want %q", want, want, want) 51 | } 52 | } 53 | 54 | func TestInvalidString(t *testing.T) { 55 | cases := []string{ 56 | "", 57 | " ", 58 | "FREQ", 59 | "FREQ=HELLO", 60 | "BYMONTH=", 61 | "FREQ=WEEKLY;HELLO=WORLD", 62 | "FREQ=WEEKLY;BYMONTHDAY=I", 63 | "FREQ=WEEKLY;BYDAY=M", 64 | "FREQ=WEEKLY;BYDAY=MQ", 65 | "FREQ=WEEKLY;BYDAY=+MO", 66 | "BYDAY=MO", 67 | } 68 | for _, item := range cases { 69 | if _, e := StrToRRule(item); e == nil { 70 | t.Errorf("StrToRRule(%q) = nil, want error", item) 71 | } 72 | } 73 | } 74 | 75 | func TestSetStr(t *testing.T) { 76 | setStr := "RRULE:FREQ=DAILY;UNTIL=20180517T235959Z\n" + 77 | "EXDATE;VALUE=DATE-TIME:20180525T070000Z,20180530T130000Z\n" + 78 | "RDATE;VALUE=DATE-TIME:20180801T131313Z,20180902T141414Z\n" 79 | 80 | set, err := StrToRRuleSet(setStr) 81 | if err != nil { 82 | t.Fatalf("StrToRRuleSet(%s) returned error: %v", setStr, err) 83 | } 84 | 85 | rule := set.GetRRule() 86 | if rule == nil { 87 | t.Errorf("Unexpected rrule parsed") 88 | } 89 | if rule.String() != "FREQ=DAILY;UNTIL=20180517T235959Z" { 90 | t.Errorf("Unexpected rrule: %s", rule.String()) 91 | } 92 | 93 | // matching parsed EXDates 94 | exDates := set.GetExDate() 95 | if len(exDates) != 2 { 96 | t.Errorf("Unexpected number of exDates: %v != 2, %v", len(exDates), exDates) 97 | } 98 | if [2]string{timeToStr(exDates[0]), timeToStr(exDates[1])} != [2]string{"20180525T070000Z", "20180530T130000Z"} { 99 | t.Errorf("Unexpected exDates: %v", exDates) 100 | } 101 | 102 | // matching parsed RDates 103 | rDates := set.GetRDate() 104 | if len(rDates) != 2 { 105 | t.Errorf("Unexpected number of rDates: %v != 2, %v", len(rDates), rDates) 106 | } 107 | if [2]string{timeToStr(rDates[0]), timeToStr(rDates[1])} != [2]string{"20180801T131313Z", "20180902T141414Z"} { 108 | t.Errorf("Unexpected exDates: %v", exDates) 109 | } 110 | } 111 | 112 | func TestStrToDtStart(t *testing.T) { 113 | validCases := []string{ 114 | "19970714T133000", 115 | "19970714T173000Z", 116 | "TZID=America/New_York:19970714T133000", 117 | } 118 | 119 | invalidCases := []string{ 120 | "DTSTART;TZID=America/New_York:19970714T133000", 121 | "19970714T1330000", 122 | "DTSTART;TZID=:20180101T090000", 123 | "TZID=:20180101T090000", 124 | "TZID=notatimezone:20180101T090000", 125 | "DTSTART:19970714T133000", 126 | "DTSTART:19970714T133000Z", 127 | "DTSTART;:19970714T133000Z", 128 | "DTSTART;:1997:07:14T13:30:00Z", 129 | ";:19970714T133000Z", 130 | " ", 131 | "", 132 | } 133 | 134 | for _, item := range validCases { 135 | if _, e := StrToDtStart(item, time.UTC); e != nil { 136 | t.Errorf("StrToDtStart(%q) error = %s, want nil", item, e.Error()) 137 | } 138 | } 139 | 140 | for _, item := range invalidCases { 141 | if _, e := StrToDtStart(item, time.UTC); e == nil { 142 | t.Errorf("StrToDtStart(%q) err = nil, want not nil", item) 143 | } 144 | } 145 | } 146 | 147 | func TestStrToDates(t *testing.T) { 148 | validCases := []string{ 149 | "19970714T133000", 150 | "19970714T173000Z", 151 | "VALUE=DATE-TIME:19970714T133000,19980714T133000,19980714T133000", 152 | "VALUE=DATE-TIME;TZID=America/New_York:19970714T133000,19980714T133000,19980714T133000", 153 | "VALUE=DATE:19970714T133000,19980714T133000,19980714T133000", 154 | } 155 | 156 | invalidCases := []string{ 157 | "VALUE:DATE:TIME:19970714T133000,19980714T133000,19980714T133000", 158 | ";:19970714T133000Z", 159 | " ", 160 | "", 161 | "VALUE=DATE-TIME;TZID=:19970714T133000", 162 | "VALUE=PERIOD:19970714T133000Z/19980714T133000Z", 163 | } 164 | 165 | for _, item := range validCases { 166 | if _, e := StrToDates(item); e != nil { 167 | t.Errorf("StrToDates(%q) error = %s, want nil", item, e.Error()) 168 | } 169 | if _, e := StrToDatesInLoc(item, time.Local); e != nil { 170 | t.Errorf("StrToDates(%q) error = %s, want nil", item, e.Error()) 171 | } 172 | } 173 | 174 | for _, item := range invalidCases { 175 | if _, e := StrToDates(item); e == nil { 176 | t.Errorf("StrToDates(%q) err = nil, want not nil", item) 177 | } 178 | if _, e := StrToDatesInLoc(item, time.Local); e == nil { 179 | t.Errorf("StrToDates(%q) err = nil, want not nil", item) 180 | } 181 | } 182 | } 183 | 184 | func TestStrToDatesTimeIsCorrect(t *testing.T) { 185 | nyLoc, _ := time.LoadLocation("America/New_York") 186 | inputs := []string{ 187 | "VALUE=DATE-TIME:19970714T133000", 188 | "VALUE=DATE-TIME;TZID=America/New_York:19970714T133000", 189 | } 190 | exp := []time.Time{ 191 | time.Date(1997, 7, 14, 13, 30, 0, 0, time.UTC), 192 | time.Date(1997, 7, 14, 13, 30, 0, 0, nyLoc), 193 | } 194 | 195 | for i, s := range inputs { 196 | ts, err := StrToDates(s) 197 | if err != nil { 198 | t.Fatalf("StrToDates(%s): error = %s", s, err.Error()) 199 | } 200 | if len(ts) != 1 { 201 | t.Fatalf("StrToDates(%s): bad answer: %v", s, ts) 202 | } 203 | if !ts[0].Equal(exp[i]) { 204 | t.Fatalf("StrToDates(%s): bad answer: %v, expected: %v", s, ts[0], exp[i]) 205 | } 206 | } 207 | } 208 | 209 | func TestProcessRRuleName(t *testing.T) { 210 | validCases := []string{ 211 | "DTSTART;TZID=America/New_York:19970714T133000", 212 | "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU", 213 | "EXDATE;VALUE=DATE-TIME:20180525T070000Z,20180530T130000Z", 214 | "RDATE;TZID=America/New_York;VALUE=DATE-TIME:20180801T131313Z,20180902T141414Z", 215 | } 216 | 217 | invalidCases := []string{ 218 | "TZID=America/New_York:19970714T133000", 219 | "19970714T1330000", 220 | ";:19970714T133000Z", 221 | "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU", 222 | " ", 223 | } 224 | 225 | for _, item := range validCases { 226 | if _, e := processRRuleName(item); e != nil { 227 | t.Errorf("processRRuleName(%q) error = %s, want nil", item, e.Error()) 228 | } 229 | } 230 | 231 | for _, item := range invalidCases { 232 | if _, e := processRRuleName(item); e == nil { 233 | t.Errorf("processRRuleName(%q) err = nil, want not nil", item) 234 | } 235 | } 236 | } 237 | 238 | func TestSetStrCompatibility(t *testing.T) { 239 | badInputStrs := []string{ 240 | "", 241 | "FREQ=DAILY;UNTIL=20180517T235959Z", 242 | "DTSTART:;", 243 | "RRULE:;", 244 | } 245 | 246 | for _, badInputStr := range badInputStrs { 247 | _, err := StrToRRuleSet(badInputStr) 248 | if err == nil { 249 | t.Fatalf("StrToRRuleSet(%s) didn't return error", badInputStr) 250 | } 251 | } 252 | 253 | inputStr := "DTSTART;TZID=America/New_York:20180101T090000\n" + 254 | "RRULE:FREQ=DAILY;UNTIL=20180517T235959Z\n" + 255 | "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU\n" + 256 | "EXRULE:FREQ=MONTHLY;UNTIL=20180520;BYMONTHDAY=1,2,3\n" + 257 | "EXDATE;VALUE=DATE-TIME:20180525T070000Z,20180530T130000Z\n" + 258 | "RDATE;VALUE=DATE-TIME:20180801T131313Z,20180902T141414Z\n" 259 | 260 | set, err := StrToRRuleSet(inputStr) 261 | if err != nil { 262 | t.Fatalf("StrToRRuleSet(%s) returned error: %v", inputStr, err) 263 | } 264 | 265 | nyLoc, _ := time.LoadLocation("America/New_York") 266 | dtWantTime := time.Date(2018, 1, 1, 9, 0, 0, 0, nyLoc) 267 | 268 | rrule := set.GetRRule() 269 | if rrule.String() != "DTSTART;TZID=America/New_York:20180101T090000\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TU" { 270 | t.Errorf("Unexpected rrule: %s", rrule.String()) 271 | } 272 | if !dtWantTime.Equal(rrule.dtstart) { 273 | t.Fatalf("Expected RRule dtstart to be %v got %v", dtWantTime, rrule.dtstart) 274 | } 275 | if !dtWantTime.Equal(set.GetDTStart()) { 276 | t.Fatalf("Expected Set dtstart to be %v got %v", dtWantTime, set.GetDTStart()) 277 | } 278 | 279 | // matching parsed EXDates 280 | exDates := set.GetExDate() 281 | if len(exDates) != 2 { 282 | t.Errorf("Unexpected number of exDates: %v != 2, %v", len(exDates), exDates) 283 | } 284 | if [2]string{timeToStr(exDates[0]), timeToStr(exDates[1])} != [2]string{"20180525T070000Z", "20180530T130000Z"} { 285 | t.Errorf("Unexpected exDates: %v", exDates) 286 | } 287 | 288 | // matching parsed RDates 289 | rDates := set.GetRDate() 290 | if len(rDates) != 2 { 291 | t.Errorf("Unexpected number of rDates: %v != 2, %v", len(rDates), rDates) 292 | } 293 | if [2]string{timeToStr(rDates[0]), timeToStr(rDates[1])} != [2]string{"20180801T131313Z", "20180902T141414Z"} { 294 | t.Errorf("Unexpected exDates: %v", exDates) 295 | } 296 | 297 | dtWantAfter := time.Date(2018, 1, 2, 9, 0, 0, 0, nyLoc) 298 | dtAfter := set.After(dtWantTime, false) 299 | if !dtWantAfter.Equal(dtAfter) { 300 | t.Errorf("Next time wrong should be %s but is %s", dtWantAfter, dtAfter) 301 | } 302 | 303 | // String to set to string comparison 304 | setStr := set.String() 305 | setFromSetStr, _ := StrToRRuleSet(setStr) 306 | 307 | if setStr != setFromSetStr.String() { 308 | t.Errorf("Expected string output\n %s \nbut got\n %s\n", setStr, setFromSetStr.String()) 309 | } 310 | } 311 | 312 | func TestSetParseLocalTimes(t *testing.T) { 313 | moscow, _ := time.LoadLocation("Europe/Moscow") 314 | 315 | t.Run("DtstartTimeZoneIsUsed", func(t *testing.T) { 316 | input := []string{ 317 | "DTSTART;TZID=Europe/Moscow:20180220T090000", 318 | "RDATE;VALUE=DATE-TIME:20180223T100000", 319 | } 320 | s, err := StrSliceToRRuleSet(input) 321 | if err != nil { 322 | t.Error(err) 323 | } 324 | d := s.GetRDate()[0] 325 | if !d.Equal(time.Date(2018, 02, 23, 10, 0, 0, 0, moscow)) { 326 | t.Error("Bad time parsed: ", d) 327 | } 328 | }) 329 | 330 | t.Run("DtstartTimeZoneValidOutput", func(t *testing.T) { 331 | input := []string{ 332 | "DTSTART;TZID=Europe/Moscow:20180220T090000", 333 | "RDATE;VALUE=DATE-TIME:20180223T100000", 334 | } 335 | expected := "DTSTART;TZID=Europe/Moscow:20180220T090000\nRDATE;TZID=Europe/Moscow:20180223T100000" 336 | s, err := StrSliceToRRuleSet(input) 337 | if err != nil { 338 | t.Error(err) 339 | } 340 | 341 | sRRule := s.String() 342 | 343 | if sRRule != expected { 344 | t.Errorf("DTSTART output not valid. Expected: \n%s \n Got: \n%s", expected, sRRule) 345 | } 346 | }) 347 | 348 | t.Run("DtstartUTCValidOutput", func(t *testing.T) { 349 | input := []string{ 350 | "DTSTART:20180220T090000Z", 351 | "RDATE;VALUE=DATE-TIME:20180223T100000", 352 | } 353 | expected := "DTSTART:20180220T090000Z\nRDATE:20180223T100000Z" 354 | s, err := StrSliceToRRuleSet(input) 355 | if err != nil { 356 | t.Error(err) 357 | } 358 | 359 | sRRule := s.String() 360 | 361 | if sRRule != expected { 362 | t.Errorf("DTSTART output not valid. Expected: \n%s \n Got: \n%s", expected, sRRule) 363 | } 364 | }) 365 | 366 | t.Run("SpecifiedDefaultZoneIsUsed", func(t *testing.T) { 367 | input := []string{ 368 | "RDATE;VALUE=DATE-TIME:20180223T100000", 369 | } 370 | s, err := StrSliceToRRuleSetInLoc(input, moscow) 371 | if err != nil { 372 | t.Error(err) 373 | } 374 | d := s.GetRDate()[0] 375 | if !d.Equal(time.Date(2018, 02, 23, 10, 0, 0, 0, moscow)) { 376 | t.Error("Bad time parsed: ", d) 377 | } 378 | }) 379 | } 380 | 381 | func TestRDateValueDateStr(t *testing.T) { 382 | t.Run("DefaultToUTC", func(t *testing.T) { 383 | input := []string{ 384 | "RDATE;VALUE=DATE:20180223", 385 | } 386 | s, err := StrSliceToRRuleSet(input) 387 | if err != nil { 388 | t.Error(err) 389 | } 390 | d := s.GetRDate()[0] 391 | if !d.Equal(time.Date(2018, 02, 23, 0, 0, 0, 0, time.UTC)) { 392 | t.Error("Bad time parsed: ", d) 393 | } 394 | }) 395 | 396 | t.Run("PreserveExplicitTimezone", func(t *testing.T) { 397 | denver, _ := time.LoadLocation("America/Denver") 398 | input := []string{ 399 | "RDATE;VALUE=DATE;TZID=America/Denver:20180223", 400 | } 401 | s, err := StrSliceToRRuleSet(input) 402 | if err != nil { 403 | t.Error(err) 404 | } 405 | d := s.GetRDate()[0] 406 | if !d.Equal(time.Date(2018, 02, 23, 0, 0, 0, 0, denver)) { 407 | t.Error("Bad time parsed: ", d) 408 | } 409 | }) 410 | } 411 | 412 | func TestStrSetEmptySliceParse(t *testing.T) { 413 | s, err := StrSliceToRRuleSet([]string{}) 414 | if err != nil { 415 | t.Error(err) 416 | } 417 | if s == nil { 418 | t.Error("Empty set should not be nil") 419 | } 420 | } 421 | 422 | func TestStrSetParseErrors(t *testing.T) { 423 | inputs := [][]string{ 424 | {"RRULE:XXX"}, 425 | {"RDATE;TZD=X:1"}, 426 | } 427 | 428 | for _, ss := range inputs { 429 | if _, err := StrSliceToRRuleSet(ss); err == nil { 430 | t.Error("Expected parse error for rules: ", ss) 431 | } 432 | } 433 | } 434 | -------------------------------------------------------------------------------- /str.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | // DateTimeFormat is date-time format used in iCalendar (RFC 5545) 15 | DateTimeFormat = "20060102T150405Z" 16 | // LocalDateTimeFormat is a date-time format without Z prefix 17 | LocalDateTimeFormat = "20060102T150405" 18 | // DateFormat is date format used in iCalendar (RFC 5545) 19 | DateFormat = "20060102" 20 | ) 21 | 22 | func timeToStr(time time.Time) string { 23 | return time.UTC().Format(DateTimeFormat) 24 | } 25 | 26 | func strToTimeInLoc(str string, loc *time.Location) (time.Time, error) { 27 | if len(str) == len(DateFormat) { 28 | return time.ParseInLocation(DateFormat, str, loc) 29 | } 30 | if len(str) == len(LocalDateTimeFormat) { 31 | return time.ParseInLocation(LocalDateTimeFormat, str, loc) 32 | } 33 | // date-time format carries zone info 34 | return time.Parse(DateTimeFormat, str) 35 | } 36 | 37 | func (f Frequency) String() string { 38 | return [...]string{ 39 | "YEARLY", "MONTHLY", "WEEKLY", "DAILY", 40 | "HOURLY", "MINUTELY", "SECONDLY"}[f] 41 | } 42 | 43 | func StrToFreq(str string) (Frequency, error) { 44 | freqMap := map[string]Frequency{ 45 | "YEARLY": YEARLY, "MONTHLY": MONTHLY, "WEEKLY": WEEKLY, "DAILY": DAILY, 46 | "HOURLY": HOURLY, "MINUTELY": MINUTELY, "SECONDLY": SECONDLY, 47 | } 48 | result, ok := freqMap[str] 49 | if !ok { 50 | return 0, errors.New("undefined frequency: " + str) 51 | } 52 | return result, nil 53 | } 54 | 55 | func (wday Weekday) String() string { 56 | s := [...]string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}[wday.weekday] 57 | if wday.n == 0 { 58 | return s 59 | } 60 | return fmt.Sprintf("%+d%s", wday.n, s) 61 | } 62 | 63 | func strToWeekday(str string) (Weekday, error) { 64 | if len(str) < 2 { 65 | return Weekday{}, errors.New("undefined weekday: " + str) 66 | } 67 | weekMap := map[string]Weekday{ 68 | "MO": MO, "TU": TU, "WE": WE, "TH": TH, 69 | "FR": FR, "SA": SA, "SU": SU} 70 | result, ok := weekMap[str[len(str)-2:]] 71 | if !ok { 72 | return Weekday{}, errors.New("undefined weekday: " + str) 73 | } 74 | if len(str) > 2 { 75 | n, e := strconv.Atoi(str[:len(str)-2]) 76 | if e != nil { 77 | return Weekday{}, e 78 | } 79 | result.n = n 80 | } 81 | return result, nil 82 | } 83 | 84 | func strToWeekdays(value string) ([]Weekday, error) { 85 | contents := strings.Split(value, ",") 86 | result := make([]Weekday, len(contents)) 87 | var e error 88 | for i, s := range contents { 89 | result[i], e = strToWeekday(s) 90 | if e != nil { 91 | return nil, e 92 | } 93 | } 94 | return result, nil 95 | } 96 | 97 | func appendIntsOption(options []string, key string, value []int) []string { 98 | if len(value) == 0 { 99 | return options 100 | } 101 | valueStr := make([]string, len(value)) 102 | for i, v := range value { 103 | valueStr[i] = strconv.Itoa(v) 104 | } 105 | return append(options, fmt.Sprintf("%s=%s", key, strings.Join(valueStr, ","))) 106 | } 107 | 108 | func strToInts(value string) ([]int, error) { 109 | contents := strings.Split(value, ",") 110 | result := make([]int, len(contents)) 111 | var e error 112 | for i, s := range contents { 113 | result[i], e = strconv.Atoi(s) 114 | if e != nil { 115 | return nil, e 116 | } 117 | } 118 | return result, nil 119 | } 120 | 121 | // String returns RRULE string with DTSTART if exists. e.g. 122 | // 123 | // DTSTART;TZID=America/New_York:19970105T083000 124 | // RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30 125 | func (option *ROption) String() string { 126 | str := option.RRuleString() 127 | if option.Dtstart.IsZero() { 128 | return str 129 | } 130 | 131 | return fmt.Sprintf("DTSTART%s\nRRULE:%s", timeToRFCDatetimeStr(option.Dtstart), str) 132 | } 133 | 134 | // RRuleString returns RRULE string exclude DTSTART 135 | func (option *ROption) RRuleString() string { 136 | result := []string{fmt.Sprintf("FREQ=%v", option.Freq)} 137 | if option.Interval != 0 { 138 | result = append(result, fmt.Sprintf("INTERVAL=%v", option.Interval)) 139 | } 140 | if option.Wkst != MO { 141 | result = append(result, fmt.Sprintf("WKST=%v", option.Wkst)) 142 | } 143 | if option.Count != 0 { 144 | result = append(result, fmt.Sprintf("COUNT=%v", option.Count)) 145 | } 146 | if !option.Until.IsZero() { 147 | result = append(result, fmt.Sprintf("UNTIL=%v", timeToStr(option.Until))) 148 | } 149 | result = appendIntsOption(result, "BYSETPOS", option.Bysetpos) 150 | result = appendIntsOption(result, "BYMONTH", option.Bymonth) 151 | result = appendIntsOption(result, "BYMONTHDAY", option.Bymonthday) 152 | result = appendIntsOption(result, "BYYEARDAY", option.Byyearday) 153 | result = appendIntsOption(result, "BYWEEKNO", option.Byweekno) 154 | if len(option.Byweekday) != 0 { 155 | valueStr := make([]string, len(option.Byweekday)) 156 | for i, wday := range option.Byweekday { 157 | valueStr[i] = wday.String() 158 | } 159 | result = append(result, fmt.Sprintf("BYDAY=%s", strings.Join(valueStr, ","))) 160 | } 161 | result = appendIntsOption(result, "BYHOUR", option.Byhour) 162 | result = appendIntsOption(result, "BYMINUTE", option.Byminute) 163 | result = appendIntsOption(result, "BYSECOND", option.Bysecond) 164 | result = appendIntsOption(result, "BYEASTER", option.Byeaster) 165 | return strings.Join(result, ";") 166 | } 167 | 168 | // StrToROption converts string to ROption. 169 | func StrToROption(rfcString string) (*ROption, error) { 170 | return StrToROptionInLocation(rfcString, time.UTC) 171 | } 172 | 173 | // StrToROptionInLocation is same as StrToROption but in case local 174 | // time is supplied as date-time/date field (ex. UNTIL), it is parsed 175 | // as a time in a given location (time zone) 176 | func StrToROptionInLocation(rfcString string, loc *time.Location) (*ROption, error) { 177 | rfcString = strings.TrimSpace(rfcString) 178 | strs := strings.Split(rfcString, "\n") 179 | var rruleStr, dtstartStr string 180 | switch len(strs) { 181 | case 1: 182 | rruleStr = strs[0] 183 | case 2: 184 | dtstartStr = strs[0] 185 | rruleStr = strs[1] 186 | default: 187 | return nil, errors.New("invalid RRULE string") 188 | } 189 | 190 | result := ROption{} 191 | freqSet := false 192 | 193 | if dtstartStr != "" { 194 | firstName, err := processRRuleName(dtstartStr) 195 | if err != nil { 196 | return nil, fmt.Errorf("expect DTSTART but: %s", err) 197 | } 198 | if firstName != "DTSTART" { 199 | return nil, fmt.Errorf("expect DTSTART but: %s", firstName) 200 | } 201 | 202 | result.Dtstart, err = StrToDtStart(dtstartStr[len(firstName)+1:], loc) 203 | if err != nil { 204 | return nil, fmt.Errorf("StrToDtStart failed: %s", err) 205 | } 206 | } 207 | 208 | rruleStr = strings.TrimPrefix(rruleStr, "RRULE:") 209 | for _, attr := range strings.Split(rruleStr, ";") { 210 | keyValue := strings.Split(attr, "=") 211 | if len(keyValue) != 2 { 212 | return nil, errors.New("wrong format") 213 | } 214 | key, value := keyValue[0], keyValue[1] 215 | if len(value) == 0 { 216 | return nil, errors.New(key + " option has no value") 217 | } 218 | var e error 219 | switch key { 220 | case "FREQ": 221 | result.Freq, e = StrToFreq(value) 222 | freqSet = true 223 | case "DTSTART": 224 | result.Dtstart, e = strToTimeInLoc(value, loc) 225 | case "INTERVAL": 226 | result.Interval, e = strconv.Atoi(value) 227 | case "WKST": 228 | result.Wkst, e = strToWeekday(value) 229 | case "COUNT": 230 | result.Count, e = strconv.Atoi(value) 231 | case "UNTIL": 232 | result.Until, e = strToTimeInLoc(value, loc) 233 | case "BYSETPOS": 234 | result.Bysetpos, e = strToInts(value) 235 | case "BYMONTH": 236 | result.Bymonth, e = strToInts(value) 237 | case "BYMONTHDAY": 238 | result.Bymonthday, e = strToInts(value) 239 | case "BYYEARDAY": 240 | result.Byyearday, e = strToInts(value) 241 | case "BYWEEKNO": 242 | result.Byweekno, e = strToInts(value) 243 | case "BYDAY": 244 | result.Byweekday, e = strToWeekdays(value) 245 | case "BYHOUR": 246 | result.Byhour, e = strToInts(value) 247 | case "BYMINUTE": 248 | result.Byminute, e = strToInts(value) 249 | case "BYSECOND": 250 | result.Bysecond, e = strToInts(value) 251 | case "BYEASTER": 252 | result.Byeaster, e = strToInts(value) 253 | default: 254 | return nil, errors.New("unknown RRULE property: " + key) 255 | } 256 | if e != nil { 257 | return nil, e 258 | } 259 | } 260 | if !freqSet { 261 | // Per RFC 5545, FREQ is mandatory and supposed to be the first 262 | // parameter. We'll just confirm it exists because we do not 263 | // have a meaningful default nor a way to confirm if we parsed 264 | // a value from the options this returns. 265 | return nil, errors.New("RRULE property FREQ is required") 266 | } 267 | return &result, nil 268 | } 269 | 270 | func (r *RRule) String() string { 271 | return r.OrigOptions.String() 272 | } 273 | 274 | func (set *Set) String() string { 275 | res := set.Recurrence() 276 | return strings.Join(res, "\n") 277 | } 278 | 279 | // StrToRRule converts string to RRule 280 | func StrToRRule(rfcString string) (*RRule, error) { 281 | option, e := StrToROption(rfcString) 282 | if e != nil { 283 | return nil, e 284 | } 285 | return NewRRule(*option) 286 | } 287 | 288 | // StrToRRuleSet converts string to RRuleSet 289 | func StrToRRuleSet(s string) (*Set, error) { 290 | s = strings.TrimSpace(s) 291 | if s == "" { 292 | return nil, errors.New("empty string") 293 | } 294 | ss := strings.Split(s, "\n") 295 | return StrSliceToRRuleSet(ss) 296 | } 297 | 298 | // StrSliceToRRuleSet converts given str slice to RRuleSet 299 | // In case there is a time met in any rule without specified time zone, when 300 | // it is parsed in UTC (see StrSliceToRRuleSetInLoc) 301 | func StrSliceToRRuleSet(ss []string) (*Set, error) { 302 | return StrSliceToRRuleSetInLoc(ss, time.UTC) 303 | } 304 | 305 | // StrSliceToRRuleSetInLoc is same as StrSliceToRRuleSet, but by default parses local times 306 | // in specified default location 307 | func StrSliceToRRuleSetInLoc(ss []string, defaultLoc *time.Location) (*Set, error) { 308 | if len(ss) == 0 { 309 | return &Set{}, nil 310 | } 311 | 312 | set := Set{} 313 | 314 | // According to RFC DTSTART is always the first line. 315 | firstName, err := processRRuleName(ss[0]) 316 | if err != nil { 317 | return nil, err 318 | } 319 | if firstName == "DTSTART" { 320 | dt, err := StrToDtStart(ss[0][len(firstName)+1:], defaultLoc) 321 | if err != nil { 322 | return nil, fmt.Errorf("StrToDtStart failed: %v", err) 323 | } 324 | // default location should be taken from DTSTART property to correctly 325 | // parse local times met in RDATE,EXDATE and other rules 326 | defaultLoc = dt.Location() 327 | set.DTStart(dt) 328 | // We've processed the first one 329 | ss = ss[1:] 330 | } 331 | 332 | for _, line := range ss { 333 | name, err := processRRuleName(line) 334 | if err != nil { 335 | return nil, err 336 | } 337 | rule := line[len(name)+1:] 338 | 339 | switch name { 340 | case "RRULE": 341 | rOpt, err := StrToROptionInLocation(rule, defaultLoc) 342 | if err != nil { 343 | return nil, fmt.Errorf("StrToROption failed: %v", err) 344 | } 345 | r, err := NewRRule(*rOpt) 346 | if err != nil { 347 | return nil, fmt.Errorf("NewRRule failed: %v", r) 348 | } 349 | 350 | set.RRule(r) 351 | case "RDATE", "EXDATE": 352 | ts, err := StrToDatesInLoc(rule, defaultLoc) 353 | if err != nil { 354 | return nil, fmt.Errorf("strToDates failed: %v", err) 355 | } 356 | for _, t := range ts { 357 | if name == "RDATE" { 358 | set.RDate(t) 359 | } else { 360 | set.ExDate(t) 361 | } 362 | } 363 | } 364 | } 365 | 366 | return &set, nil 367 | } 368 | 369 | // https://tools.ietf.org/html/rfc5545#section-3.3.5 370 | // DTSTART:19970714T133000 ; Local time 371 | // DTSTART:19970714T173000Z ; UTC time 372 | // DTSTART;TZID=America/New_York:19970714T133000 ; Local time and time zone reference 373 | func timeToRFCDatetimeStr(time time.Time) string { 374 | if time.Location().String() != "UTC" { 375 | return fmt.Sprintf(";TZID=%s:%s", time.Location().String(), time.Format(LocalDateTimeFormat)) 376 | } 377 | return fmt.Sprintf(":%s", time.Format(DateTimeFormat)) 378 | } 379 | 380 | // StrToDates is intended to parse RDATE and EXDATE properties supporting only 381 | // VALUE=DATE-TIME (DATE and PERIOD are not supported). 382 | // Accepts string with format: "VALUE=DATE-TIME;[TZID=...]:{time},{time},...,{time}" 383 | // or simply "{time},{time},...{time}" and parses it to array of dates 384 | // In case no time zone specified in str, when all dates are parsed in UTC 385 | func StrToDates(str string) (ts []time.Time, err error) { 386 | return StrToDatesInLoc(str, time.UTC) 387 | } 388 | 389 | // StrToDatesInLoc same as StrToDates but it consideres default location to parse dates in 390 | // in case no location specified with TZID parameter 391 | func StrToDatesInLoc(str string, defaultLoc *time.Location) (ts []time.Time, err error) { 392 | tmp := strings.Split(str, ":") 393 | if len(tmp) > 2 { 394 | return nil, fmt.Errorf("bad format") 395 | } 396 | loc := defaultLoc 397 | if len(tmp) == 2 { 398 | params := strings.Split(tmp[0], ";") 399 | for _, param := range params { 400 | if strings.HasPrefix(param, "TZID=") { 401 | loc, err = parseTZID(param) 402 | } else if param != "VALUE=DATE-TIME" && param != "VALUE=DATE" { 403 | err = fmt.Errorf("unsupported: %v", param) 404 | } 405 | if err != nil { 406 | return nil, fmt.Errorf("bad dates param: %s", err.Error()) 407 | } 408 | } 409 | tmp = tmp[1:] 410 | } 411 | for _, datestr := range strings.Split(tmp[0], ",") { 412 | t, err := strToTimeInLoc(datestr, loc) 413 | if err != nil { 414 | return nil, fmt.Errorf("strToTime failed: %v", err) 415 | } 416 | ts = append(ts, t) 417 | } 418 | return 419 | } 420 | 421 | // processRRuleName processes the name of an RRule off a multi-line RRule set 422 | func processRRuleName(line string) (string, error) { 423 | line = strings.ToUpper(strings.TrimSpace(line)) 424 | if line == "" { 425 | return "", fmt.Errorf("bad format %v", line) 426 | } 427 | 428 | nameLen := strings.IndexAny(line, ";:") 429 | if nameLen <= 0 { 430 | return "", fmt.Errorf("bad format %v", line) 431 | } 432 | 433 | name := line[:nameLen] 434 | if strings.IndexAny(name, "=") > 0 { 435 | return "", fmt.Errorf("bad format %v", line) 436 | } 437 | 438 | return name, nil 439 | } 440 | 441 | // StrToDtStart accepts string with format: "(TZID={timezone}:)?{time}" and parses it to a date 442 | // may be used to parse DTSTART rules, without the DTSTART; part. 443 | func StrToDtStart(str string, defaultLoc *time.Location) (time.Time, error) { 444 | tmp := strings.Split(str, ":") 445 | if len(tmp) > 2 || len(tmp) == 0 { 446 | return time.Time{}, fmt.Errorf("bad format") 447 | } 448 | 449 | if len(tmp) == 2 { 450 | // tzid 451 | loc, err := parseTZID(tmp[0]) 452 | if err != nil { 453 | return time.Time{}, err 454 | } 455 | return strToTimeInLoc(tmp[1], loc) 456 | } 457 | // no tzid, len == 1 458 | return strToTimeInLoc(tmp[0], defaultLoc) 459 | } 460 | 461 | func parseTZID(s string) (*time.Location, error) { 462 | if !strings.HasPrefix(s, "TZID=") || len(s) == len("TZID=") { 463 | return nil, fmt.Errorf("bad TZID parameter format") 464 | } 465 | return time.LoadLocation(s[len("TZID="):]) 466 | } 467 | -------------------------------------------------------------------------------- /rrule.go: -------------------------------------------------------------------------------- 1 | // 2017-2022, Teambition. All rights reserved. 2 | 3 | package rrule 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "sort" 9 | "time" 10 | ) 11 | 12 | // Every mask is 7 days longer to handle cross-year weekly periods. 13 | var ( 14 | M366MASK []int 15 | M365MASK []int 16 | MDAY366MASK []int 17 | MDAY365MASK []int 18 | NMDAY366MASK []int 19 | NMDAY365MASK []int 20 | WDAYMASK []int 21 | M366RANGE = []int{0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366} 22 | M365RANGE = []int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365} 23 | ) 24 | 25 | func init() { 26 | M366MASK = concat(repeat(1, 31), repeat(2, 29), repeat(3, 31), 27 | repeat(4, 30), repeat(5, 31), repeat(6, 30), repeat(7, 31), 28 | repeat(8, 31), repeat(9, 30), repeat(10, 31), repeat(11, 30), 29 | repeat(12, 31), repeat(1, 7)) 30 | M365MASK = concat(M366MASK[:59], M366MASK[60:]) 31 | M29, M30, M31 := rang(1, 30), rang(1, 31), rang(1, 32) 32 | MDAY366MASK = concat(M31, M29, M31, M30, M31, M30, M31, M31, M30, M31, M30, M31, M31[:7]) 33 | MDAY365MASK = concat(MDAY366MASK[:59], MDAY366MASK[60:]) 34 | M29, M30, M31 = rang(-29, 0), rang(-30, 0), rang(-31, 0) 35 | NMDAY366MASK = concat(M31, M29, M31, M30, M31, M30, M31, M31, M30, M31, M30, M31, M31[:7]) 36 | NMDAY365MASK = concat(NMDAY366MASK[:31], NMDAY366MASK[32:]) 37 | for i := 0; i < 55; i++ { 38 | WDAYMASK = append(WDAYMASK, []int{0, 1, 2, 3, 4, 5, 6}...) 39 | } 40 | } 41 | 42 | // Frequency denotes the period on which the rule is evaluated. 43 | type Frequency int 44 | 45 | // Constants 46 | const ( 47 | YEARLY Frequency = iota 48 | MONTHLY 49 | WEEKLY 50 | DAILY 51 | HOURLY 52 | MINUTELY 53 | SECONDLY 54 | ) 55 | 56 | // Weekday specifying the nth weekday. 57 | // Field N could be positive or negative (like MO(+2) or MO(-3). 58 | // Not specifying N (0) is the same as specifying +1. 59 | type Weekday struct { 60 | weekday int 61 | n int 62 | } 63 | 64 | // Nth return the nth weekday 65 | // __call__ - Cannot call the object directly, 66 | // do it through e.g. TH.nth(-1) instead, 67 | func (wday *Weekday) Nth(n int) Weekday { 68 | return Weekday{wday.weekday, n} 69 | } 70 | 71 | // N returns index of the week, e.g. for 3MO, N() will return 3 72 | func (wday *Weekday) N() int { 73 | return wday.n 74 | } 75 | 76 | // Day returns index of the day in a week (0 for MO, 6 for SU) 77 | func (wday *Weekday) Day() int { 78 | return wday.weekday 79 | } 80 | 81 | // Weekdays 82 | var ( 83 | MO = Weekday{weekday: 0} 84 | TU = Weekday{weekday: 1} 85 | WE = Weekday{weekday: 2} 86 | TH = Weekday{weekday: 3} 87 | FR = Weekday{weekday: 4} 88 | SA = Weekday{weekday: 5} 89 | SU = Weekday{weekday: 6} 90 | ) 91 | 92 | // ROption offers options to construct a RRule instance. 93 | // For performance, it is strongly recommended providing explicit ROption.Dtstart, which defaults to `time.Now().UTC().Truncate(time.Second)`. 94 | type ROption struct { 95 | Freq Frequency 96 | Dtstart time.Time 97 | Interval int 98 | Wkst Weekday 99 | Count int 100 | Until time.Time 101 | Bysetpos []int 102 | Bymonth []int 103 | Bymonthday []int 104 | Byyearday []int 105 | Byweekno []int 106 | Byweekday []Weekday 107 | Byhour []int 108 | Byminute []int 109 | Bysecond []int 110 | Byeaster []int 111 | } 112 | 113 | // RRule offers a small, complete, and very fast, implementation of the recurrence rules 114 | // documented in the iCalendar RFC, including support for caching of results. 115 | type RRule struct { 116 | OrigOptions ROption 117 | Options ROption 118 | freq Frequency 119 | dtstart time.Time 120 | interval int 121 | wkst int 122 | count int 123 | until time.Time 124 | bysetpos []int 125 | bymonth []int 126 | bymonthday, bynmonthday []int 127 | byyearday []int 128 | byweekno []int 129 | byweekday []int 130 | bynweekday []Weekday 131 | byhour []int 132 | byminute []int 133 | bysecond []int 134 | byeaster []int 135 | timeset []time.Time 136 | len int 137 | } 138 | 139 | // NewRRule construct a new RRule instance 140 | func NewRRule(arg ROption) (*RRule, error) { 141 | if err := validateBounds(arg); err != nil { 142 | return nil, err 143 | } 144 | r := buildRRule(arg) 145 | return &r, nil 146 | } 147 | 148 | func buildRRule(arg ROption) RRule { 149 | r := RRule{} 150 | r.OrigOptions = arg 151 | // FREQ default to YEARLY 152 | r.freq = arg.Freq 153 | 154 | // INTERVAL default to 1 155 | if arg.Interval < 1 { 156 | arg.Interval = 1 157 | } 158 | r.interval = arg.Interval 159 | 160 | if arg.Count < 0 { 161 | arg.Count = 0 162 | } 163 | r.count = arg.Count 164 | 165 | // DTSTART default to now 166 | if arg.Dtstart.IsZero() { 167 | arg.Dtstart = time.Now().UTC() 168 | } 169 | arg.Dtstart = arg.Dtstart.Truncate(time.Second) 170 | r.dtstart = arg.Dtstart 171 | 172 | // UNTIL 173 | if arg.Until.IsZero() { 174 | // add largest representable duration (approximately 290 years). 175 | r.until = r.dtstart.Add(time.Duration(1<<63 - 1)) 176 | } else { 177 | arg.Until = arg.Until.Truncate(time.Second) 178 | r.until = arg.Until 179 | } 180 | 181 | r.wkst = arg.Wkst.weekday 182 | r.bysetpos = arg.Bysetpos 183 | 184 | if len(arg.Byweekno) == 0 && 185 | len(arg.Byyearday) == 0 && 186 | len(arg.Bymonthday) == 0 && 187 | len(arg.Byweekday) == 0 && 188 | len(arg.Byeaster) == 0 { 189 | if r.freq == YEARLY { 190 | if len(arg.Bymonth) == 0 { 191 | arg.Bymonth = []int{int(r.dtstart.Month())} 192 | } 193 | arg.Bymonthday = []int{r.dtstart.Day()} 194 | } else if r.freq == MONTHLY { 195 | arg.Bymonthday = []int{r.dtstart.Day()} 196 | } else if r.freq == WEEKLY { 197 | arg.Byweekday = []Weekday{{weekday: toPyWeekday(r.dtstart.Weekday())}} 198 | } 199 | } 200 | r.bymonth = arg.Bymonth 201 | r.byyearday = arg.Byyearday 202 | r.byeaster = arg.Byeaster 203 | for _, mday := range arg.Bymonthday { 204 | if mday > 0 { 205 | r.bymonthday = append(r.bymonthday, mday) 206 | } else if mday < 0 { 207 | r.bynmonthday = append(r.bynmonthday, mday) 208 | } 209 | } 210 | r.byweekno = arg.Byweekno 211 | for _, wday := range arg.Byweekday { 212 | if wday.n == 0 || r.freq > MONTHLY { 213 | r.byweekday = append(r.byweekday, wday.weekday) 214 | } else { 215 | r.bynweekday = append(r.bynweekday, wday) 216 | } 217 | } 218 | if len(arg.Byhour) == 0 { 219 | if r.freq < HOURLY { 220 | r.byhour = []int{r.dtstart.Hour()} 221 | } 222 | } else { 223 | r.byhour = arg.Byhour 224 | } 225 | if len(arg.Byminute) == 0 { 226 | if r.freq < MINUTELY { 227 | r.byminute = []int{r.dtstart.Minute()} 228 | } 229 | } else { 230 | r.byminute = arg.Byminute 231 | } 232 | if len(arg.Bysecond) == 0 { 233 | if r.freq < SECONDLY { 234 | r.bysecond = []int{r.dtstart.Second()} 235 | } 236 | } else { 237 | r.bysecond = arg.Bysecond 238 | } 239 | 240 | // Reset the timeset value 241 | r.timeset = nil 242 | 243 | if r.freq < HOURLY { 244 | r.timeset = make([]time.Time, 0, len(r.byhour)*len(r.byminute)*len(r.bysecond)) 245 | for _, hour := range r.byhour { 246 | for _, minute := range r.byminute { 247 | for _, second := range r.bysecond { 248 | r.timeset = append(r.timeset, time.Date(1, 1, 1, hour, minute, second, 0, r.dtstart.Location())) 249 | } 250 | } 251 | } 252 | sort.Sort(timeSlice(r.timeset)) 253 | } 254 | 255 | r.Options = arg 256 | return r 257 | } 258 | 259 | // validateBounds checks the RRule's options are within the boundaries defined 260 | // in RRFC 5545. This is useful to ensure that the RRule can even have any times, 261 | // as going outside these bounds trivially will never have any dates. This can catch 262 | // obvious user error. 263 | func validateBounds(arg ROption) error { 264 | bounds := []struct { 265 | field []int 266 | param string 267 | bound []int 268 | plusMinus bool // If the bound also applies for -x to -y. 269 | }{ 270 | {arg.Bysecond, "bysecond", []int{0, 59}, false}, 271 | {arg.Byminute, "byminute", []int{0, 59}, false}, 272 | {arg.Byhour, "byhour", []int{0, 23}, false}, 273 | {arg.Bymonthday, "bymonthday", []int{1, 31}, true}, 274 | {arg.Byyearday, "byyearday", []int{1, 366}, true}, 275 | {arg.Byweekno, "byweekno", []int{1, 53}, true}, 276 | {arg.Bymonth, "bymonth", []int{1, 12}, false}, 277 | {arg.Bysetpos, "bysetpos", []int{1, 366}, true}, 278 | } 279 | 280 | checkBounds := func(param string, value int, bounds []int, plusMinus bool) error { 281 | if !(value >= bounds[0] && value <= bounds[1]) && (!plusMinus || !(value <= -bounds[0] && value >= -bounds[1])) { 282 | plusMinusBounds := "" 283 | if plusMinus { 284 | plusMinusBounds = fmt.Sprintf(" or %d and %d", -bounds[0], -bounds[1]) 285 | } 286 | return fmt.Errorf("%s must be between %d and %d%s", param, bounds[0], bounds[1], plusMinusBounds) 287 | } 288 | return nil 289 | } 290 | 291 | for _, b := range bounds { 292 | for _, value := range b.field { 293 | if err := checkBounds(b.param, value, b.bound, b.plusMinus); err != nil { 294 | return err 295 | } 296 | } 297 | } 298 | 299 | // Days can optionally specify weeks, like BYDAY=+2MO for the 2nd Monday 300 | // of the month/year. 301 | for _, w := range arg.Byweekday { 302 | if w.n > 53 || w.n < -53 { 303 | return errors.New("byday must be between 1 and 53 or -1 and -53") 304 | } 305 | } 306 | 307 | if arg.Interval < 0 { 308 | return errors.New("interval must be greater than 0") 309 | } 310 | 311 | return nil 312 | } 313 | 314 | type iterInfo struct { 315 | rrule *RRule 316 | lastyear int 317 | lastmonth time.Month 318 | yearlen int 319 | nextyearlen int 320 | firstyday time.Time 321 | yearweekday int 322 | mmask []int 323 | mrange []int 324 | mdaymask []int 325 | nmdaymask []int 326 | wdaymask []int 327 | wnomask []int 328 | nwdaymask []int 329 | eastermask []int 330 | } 331 | 332 | func (info *iterInfo) rebuild(year int, month time.Month) { 333 | // Every mask is 7 days longer to handle cross-year weekly periods. 334 | if year != info.lastyear { 335 | info.yearlen = 365 + isLeap(year) 336 | info.nextyearlen = 365 + isLeap(year+1) 337 | info.firstyday = time.Date( 338 | year, time.January, 1, 0, 0, 0, 0, 339 | info.rrule.dtstart.Location()) 340 | info.yearweekday = toPyWeekday(info.firstyday.Weekday()) 341 | info.wdaymask = WDAYMASK[info.yearweekday:] 342 | if info.yearlen == 365 { 343 | info.mmask = M365MASK 344 | info.mdaymask = MDAY365MASK 345 | info.nmdaymask = NMDAY365MASK 346 | info.mrange = M365RANGE 347 | } else { 348 | info.mmask = M366MASK 349 | info.mdaymask = MDAY366MASK 350 | info.nmdaymask = NMDAY366MASK 351 | info.mrange = M366RANGE 352 | } 353 | if len(info.rrule.byweekno) == 0 { 354 | info.wnomask = nil 355 | } else { 356 | info.wnomask = make([]int, info.yearlen+7) 357 | firstwkst := pymod(7-info.yearweekday+info.rrule.wkst, 7) 358 | no1wkst := firstwkst 359 | var wyearlen int 360 | if no1wkst >= 4 { 361 | no1wkst = 0 362 | // Number of days in the year, plus the days we got from last year. 363 | wyearlen = info.yearlen + pymod(info.yearweekday-info.rrule.wkst, 7) 364 | } else { 365 | // Number of days in the year, minus the days we left in last year. 366 | wyearlen = info.yearlen - no1wkst 367 | } 368 | div, mod := divmod(wyearlen, 7) 369 | numweeks := div + mod/4 370 | for _, n := range info.rrule.byweekno { 371 | if n < 0 { 372 | n += numweeks + 1 373 | } 374 | if !(0 < n && n <= numweeks) { 375 | continue 376 | } 377 | var i int 378 | if n > 1 { 379 | i = no1wkst + (n-1)*7 380 | if no1wkst != firstwkst { 381 | i -= 7 - firstwkst 382 | } 383 | } else { 384 | i = no1wkst 385 | } 386 | for j := 0; j < 7; j++ { 387 | info.wnomask[i] = 1 388 | i++ 389 | if info.wdaymask[i] == info.rrule.wkst { 390 | break 391 | } 392 | } 393 | } 394 | if contains(info.rrule.byweekno, 1) { 395 | // Check week number 1 of next year as well 396 | // TODO: Check -numweeks for next year. 397 | i := no1wkst + numweeks*7 398 | if no1wkst != firstwkst { 399 | i -= 7 - firstwkst 400 | } 401 | if i < info.yearlen { 402 | // If week starts in next year, we 403 | // don't care about it. 404 | for j := 0; j < 7; j++ { 405 | info.wnomask[i] = 1 406 | i++ 407 | if info.wdaymask[i] == info.rrule.wkst { 408 | break 409 | } 410 | } 411 | } 412 | } 413 | if no1wkst != 0 { 414 | // Check last week number of last year as 415 | // well. If no1wkst is 0, either the year 416 | // started on week start, or week number 1 417 | // got days from last year, so there are no 418 | // days from last year's last week number in 419 | // this year. 420 | var lnumweeks int 421 | if !contains(info.rrule.byweekno, -1) { 422 | lyearweekday := toPyWeekday(time.Date( 423 | year-1, 1, 1, 0, 0, 0, 0, 424 | info.rrule.dtstart.Location()).Weekday()) 425 | lno1wkst := pymod(7-lyearweekday+info.rrule.wkst, 7) 426 | lyearlen := 365 + isLeap(year-1) 427 | if lno1wkst >= 4 { 428 | lno1wkst = 0 429 | lnumweeks = 52 + pymod(lyearlen+pymod(lyearweekday-info.rrule.wkst, 7), 7)/4 430 | } else { 431 | lnumweeks = 52 + pymod(info.yearlen-no1wkst, 7)/4 432 | } 433 | } else { 434 | lnumweeks = -1 435 | } 436 | if contains(info.rrule.byweekno, lnumweeks) { 437 | for i := 0; i < no1wkst; i++ { 438 | info.wnomask[i] = 1 439 | } 440 | } 441 | } 442 | } 443 | } 444 | if len(info.rrule.bynweekday) != 0 && (month != info.lastmonth || year != info.lastyear) { 445 | var ranges [][]int 446 | if info.rrule.freq == YEARLY { 447 | if len(info.rrule.bymonth) != 0 { 448 | for _, month := range info.rrule.bymonth { 449 | ranges = append(ranges, info.mrange[month-1:month+1]) 450 | } 451 | } else { 452 | ranges = [][]int{{0, info.yearlen}} 453 | } 454 | } else if info.rrule.freq == MONTHLY { 455 | ranges = [][]int{info.mrange[month-1 : month+1]} 456 | } 457 | if len(ranges) != 0 { 458 | // Weekly frequency won't get here, so we may not 459 | // care about cross-year weekly periods. 460 | info.nwdaymask = make([]int, info.yearlen) 461 | for _, x := range ranges { 462 | first, last := x[0], x[1] 463 | last-- 464 | for _, y := range info.rrule.bynweekday { 465 | wday, n := y.weekday, y.n 466 | var i int 467 | if n < 0 { 468 | i = last + (n+1)*7 469 | i -= pymod(info.wdaymask[i]-wday, 7) 470 | } else { 471 | i = first + (n-1)*7 472 | i += pymod(7-info.wdaymask[i]+wday, 7) 473 | } 474 | if first <= i && i <= last { 475 | info.nwdaymask[i] = 1 476 | } 477 | } 478 | } 479 | } 480 | } 481 | if len(info.rrule.byeaster) != 0 { 482 | info.eastermask = make([]int, info.yearlen+7) 483 | eyday := easter(year).YearDay() - 1 484 | for _, offset := range info.rrule.byeaster { 485 | info.eastermask[eyday+offset] = 1 486 | } 487 | } 488 | info.lastyear = year 489 | info.lastmonth = month 490 | } 491 | 492 | func (info *iterInfo) calcDaySet(freq Frequency, year int, month time.Month, day int) (start, end int) { 493 | switch freq { 494 | case YEARLY: 495 | return 0, info.yearlen 496 | 497 | case MONTHLY: 498 | start, end = info.mrange[month-1], info.mrange[month] 499 | return start, end 500 | 501 | case WEEKLY: 502 | // We need to handle cross-year weeks here. 503 | i := time.Date(year, month, day, 0, 0, 0, 0, time.UTC).YearDay() - 1 504 | start, end = i, i+1 505 | for j := 0; j < 7; j++ { 506 | i++ 507 | // if (not (0 <= i < self.yearlen) or 508 | // self.wdaymask[i] == self.rrule._wkst): 509 | // This will cross the year boundary, if necessary. 510 | if info.wdaymask[i] == info.rrule.wkst { 511 | break 512 | } 513 | 514 | end = i + 1 515 | } 516 | 517 | return start, end 518 | 519 | default: 520 | // DAILY, HOURLY, MINUTELY, SECONDLY: 521 | i := time.Date(year, month, day, 0, 0, 0, 0, time.UTC).YearDay() - 1 522 | return i, i + 1 523 | } 524 | } 525 | 526 | func (info *iterInfo) fillTimeSet(set *[]time.Time, freq Frequency, hour, minute, second int) { 527 | switch freq { 528 | case HOURLY: 529 | prepareTimeSet(set, len(info.rrule.byminute)*len(info.rrule.bysecond)) 530 | for _, minute := range info.rrule.byminute { 531 | for _, second := range info.rrule.bysecond { 532 | *set = append(*set, time.Date(1, 1, 1, hour, minute, second, 0, info.rrule.dtstart.Location())) 533 | } 534 | } 535 | sort.Sort(timeSlice(*set)) 536 | case MINUTELY: 537 | prepareTimeSet(set, len(info.rrule.bysecond)) 538 | for _, second := range info.rrule.bysecond { 539 | *set = append(*set, time.Date(1, 1, 1, hour, minute, second, 0, info.rrule.dtstart.Location())) 540 | } 541 | sort.Sort(timeSlice(*set)) 542 | case SECONDLY: 543 | prepareTimeSet(set, 1) 544 | *set = append(*set, time.Date(1, 1, 1, hour, minute, second, 0, info.rrule.dtstart.Location())) 545 | default: 546 | prepareTimeSet(set, 0) 547 | } 548 | } 549 | 550 | func prepareTimeSet(set *[]time.Time, length int) { 551 | if len(*set) < length { 552 | *set = make([]time.Time, 0, length) 553 | return 554 | } 555 | 556 | *set = (*set)[:0] 557 | } 558 | 559 | // rIterator is a iterator of RRule 560 | type rIterator struct { 561 | year int 562 | month time.Month 563 | day int 564 | hour int 565 | minute int 566 | second int 567 | weekday int 568 | ii iterInfo 569 | timeset []time.Time 570 | total int 571 | count int 572 | remain reusingRemainSlice 573 | finished bool 574 | dayset []optInt 575 | } 576 | 577 | func (iterator *rIterator) generate() { 578 | if iterator.finished { 579 | return 580 | } 581 | 582 | r := iterator.ii.rrule 583 | for iterator.remain.Len() == 0 { 584 | // Get dayset with the right frequency 585 | setStart, setEnd := iterator.ii.calcDaySet(r.freq, iterator.year, iterator.month, iterator.day) 586 | iterator.fillDaySetMonotonic(setStart, setEnd) 587 | 588 | dayset := iterator.dayset 589 | filtered := false 590 | 591 | // Do the "hard" work ;-) 592 | for dayIndex, day := range dayset { 593 | i := day.Int 594 | if len(r.bymonth) != 0 && !contains(r.bymonth, iterator.ii.mmask[i]) || 595 | len(r.byweekno) != 0 && iterator.ii.wnomask[i] == 0 || 596 | len(r.byweekday) != 0 && !contains(r.byweekday, iterator.ii.wdaymask[i]) || 597 | len(iterator.ii.nwdaymask) != 0 && iterator.ii.nwdaymask[i] == 0 || 598 | len(r.byeaster) != 0 && iterator.ii.eastermask[i] == 0 || 599 | (len(r.bymonthday) != 0 || len(r.bynmonthday) != 0) && 600 | !contains(r.bymonthday, iterator.ii.mdaymask[i]) && 601 | !contains(r.bynmonthday, iterator.ii.nmdaymask[i]) || 602 | len(r.byyearday) != 0 && 603 | (i < iterator.ii.yearlen && 604 | !contains(r.byyearday, i+1) && 605 | !contains(r.byyearday, -iterator.ii.yearlen+i) || 606 | i >= iterator.ii.yearlen && 607 | !contains(r.byyearday, i+1-iterator.ii.yearlen) && 608 | !contains(r.byyearday, -iterator.ii.nextyearlen+i-iterator.ii.yearlen)) { 609 | dayset[dayIndex].Defined = false 610 | filtered = true 611 | } 612 | } 613 | 614 | // Output results 615 | if len(r.bysetpos) != 0 && len(iterator.timeset) != 0 { 616 | var poslist []time.Time 617 | for _, pos := range r.bysetpos { 618 | var daypos, timepos int 619 | if pos < 0 { 620 | daypos, timepos = divmod(pos, len(iterator.timeset)) 621 | } else { 622 | daypos, timepos = divmod(pos-1, len(iterator.timeset)) 623 | } 624 | var temp []int 625 | for _, day := range dayset { 626 | if day.Defined { 627 | temp = append(temp, day.Int) 628 | } 629 | } 630 | i, err := pySubscript(temp, daypos) 631 | if err != nil { 632 | continue 633 | } 634 | timeTemp := iterator.timeset[timepos] 635 | dateYear, dateMonth, dateDay := iterator.ii.firstyday.AddDate(0, 0, i).Date() 636 | tempHour, tempMinute, tempSecond := timeTemp.Clock() 637 | res := time.Date(dateYear, dateMonth, dateDay, 638 | tempHour, tempMinute, tempSecond, 639 | timeTemp.Nanosecond(), timeTemp.Location()) 640 | if !timeContains(poslist, res) { 641 | poslist = append(poslist, res) 642 | } 643 | } 644 | sort.Sort(timeSlice(poslist)) 645 | for _, res := range poslist { 646 | if !r.until.IsZero() && res.After(r.until) { 647 | r.len = iterator.total 648 | iterator.finished = true 649 | return 650 | } else if !res.Before(r.dtstart) { 651 | iterator.total++ 652 | iterator.remain.Append(res) 653 | if iterator.count != 0 { 654 | iterator.count-- 655 | if iterator.count == 0 { 656 | r.len = iterator.total 657 | iterator.finished = true 658 | return 659 | } 660 | } 661 | } 662 | } 663 | } else { 664 | for _, day := range dayset { 665 | if !day.Defined { 666 | continue 667 | } 668 | i := day.Int 669 | dateYear, dateMonth, dateDay := iterator.ii.firstyday.AddDate(0, 0, i).Date() 670 | for _, timeTemp := range iterator.timeset { 671 | tempHour, tempMinute, tempSecond := timeTemp.Clock() 672 | res := time.Date(dateYear, dateMonth, dateDay, 673 | tempHour, tempMinute, tempSecond, 674 | timeTemp.Nanosecond(), timeTemp.Location()) 675 | if !r.until.IsZero() && res.After(r.until) { 676 | r.len = iterator.total 677 | iterator.finished = true 678 | return 679 | } else if !res.Before(r.dtstart) { 680 | iterator.total++ 681 | iterator.remain.Append(res) 682 | if iterator.count != 0 { 683 | iterator.count-- 684 | if iterator.count == 0 { 685 | r.len = iterator.total 686 | iterator.finished = true 687 | return 688 | } 689 | } 690 | } 691 | } 692 | } 693 | } 694 | // Handle frequency and interval 695 | fixday := false 696 | if r.freq == YEARLY { 697 | iterator.year += r.interval 698 | if iterator.year > MAXYEAR { 699 | r.len = iterator.total 700 | iterator.finished = true 701 | return 702 | } 703 | iterator.ii.rebuild(iterator.year, iterator.month) 704 | } else if r.freq == MONTHLY { 705 | iterator.month += time.Month(r.interval) 706 | if iterator.month > 12 { 707 | div, mod := divmod(int(iterator.month), 12) 708 | iterator.month = time.Month(mod) 709 | iterator.year += div 710 | if iterator.month == 0 { 711 | iterator.month = 12 712 | iterator.year-- 713 | } 714 | if iterator.year > MAXYEAR { 715 | r.len = iterator.total 716 | iterator.finished = true 717 | return 718 | } 719 | } 720 | iterator.ii.rebuild(iterator.year, iterator.month) 721 | } else if r.freq == WEEKLY { 722 | if r.wkst > iterator.weekday { 723 | iterator.day += -(iterator.weekday + 1 + (6 - r.wkst)) + r.interval*7 724 | } else { 725 | iterator.day += -(iterator.weekday - r.wkst) + r.interval*7 726 | } 727 | iterator.weekday = r.wkst 728 | fixday = true 729 | } else if r.freq == DAILY { 730 | iterator.day += r.interval 731 | fixday = true 732 | } else if r.freq == HOURLY { 733 | if filtered { 734 | // Jump to one iteration before next day 735 | iterator.hour += ((23 - iterator.hour) / r.interval) * r.interval 736 | } 737 | for { 738 | iterator.hour += r.interval 739 | div, mod := divmod(iterator.hour, 24) 740 | if div != 0 { 741 | iterator.hour = mod 742 | iterator.day += div 743 | fixday = true 744 | } 745 | if len(r.byhour) == 0 || contains(r.byhour, iterator.hour) { 746 | break 747 | } 748 | } 749 | iterator.ii.fillTimeSet(&iterator.timeset, r.freq, iterator.hour, iterator.minute, iterator.second) 750 | } else if r.freq == MINUTELY { 751 | if filtered { 752 | // Jump to one iteration before next day 753 | iterator.minute += ((1439 - (iterator.hour*60 + iterator.minute)) / r.interval) * r.interval 754 | } 755 | for { 756 | iterator.minute += r.interval 757 | div, mod := divmod(iterator.minute, 60) 758 | if div != 0 { 759 | iterator.minute = mod 760 | iterator.hour += div 761 | div, mod = divmod(iterator.hour, 24) 762 | if div != 0 { 763 | iterator.hour = mod 764 | iterator.day += div 765 | fixday = true 766 | } 767 | } 768 | if (len(r.byhour) == 0 || contains(r.byhour, iterator.hour)) && 769 | (len(r.byminute) == 0 || contains(r.byminute, iterator.minute)) { 770 | break 771 | } 772 | } 773 | iterator.ii.fillTimeSet(&iterator.timeset, r.freq, iterator.hour, iterator.minute, iterator.second) 774 | } else if r.freq == SECONDLY { 775 | if filtered { 776 | // Jump to one iteration before next day 777 | iterator.second += (((86399 - (iterator.hour*3600 + iterator.minute*60 + iterator.second)) / r.interval) * r.interval) 778 | } 779 | for { 780 | iterator.second += r.interval 781 | div, mod := divmod(iterator.second, 60) 782 | if div != 0 { 783 | iterator.second = mod 784 | iterator.minute += div 785 | div, mod = divmod(iterator.minute, 60) 786 | if div != 0 { 787 | iterator.minute = mod 788 | iterator.hour += div 789 | div, mod = divmod(iterator.hour, 24) 790 | if div != 0 { 791 | iterator.hour = mod 792 | iterator.day += div 793 | fixday = true 794 | } 795 | } 796 | } 797 | if (len(r.byhour) == 0 || contains(r.byhour, iterator.hour)) && 798 | (len(r.byminute) == 0 || contains(r.byminute, iterator.minute)) && 799 | (len(r.bysecond) == 0 || contains(r.bysecond, iterator.second)) { 800 | break 801 | } 802 | } 803 | iterator.ii.fillTimeSet(&iterator.timeset, r.freq, iterator.hour, iterator.minute, iterator.second) 804 | } 805 | if fixday && iterator.day > 28 { 806 | daysinmonth := daysIn(iterator.month, iterator.year) 807 | if iterator.day > daysinmonth { 808 | for iterator.day > daysinmonth { 809 | iterator.day -= daysinmonth 810 | iterator.month++ 811 | if iterator.month == 13 { 812 | iterator.month = 1 813 | iterator.year++ 814 | if iterator.year > MAXYEAR { 815 | r.len = iterator.total 816 | iterator.finished = true 817 | return 818 | } 819 | } 820 | daysinmonth = daysIn(iterator.month, iterator.year) 821 | } 822 | iterator.ii.rebuild(iterator.year, iterator.month) 823 | } 824 | } 825 | } 826 | } 827 | 828 | func (iterator *rIterator) fillDaySetMonotonic(start, end int) { 829 | desiredLen := end - start 830 | 831 | if cap(iterator.dayset) < desiredLen { 832 | iterator.dayset = make([]optInt, 0, desiredLen) 833 | } else { 834 | iterator.dayset = iterator.dayset[:0] 835 | } 836 | 837 | for i := start; i < end; i++ { 838 | iterator.dayset = append(iterator.dayset, optInt{ 839 | Int: i, 840 | Defined: true, 841 | }) 842 | } 843 | } 844 | 845 | // next returns next occurrence and true if it exists, else zero value and false 846 | func (iterator *rIterator) next() (time.Time, bool) { 847 | iterator.generate() 848 | return iterator.remain.Pop() 849 | } 850 | 851 | type reusingRemainSlice struct { 852 | storage []time.Time 853 | backup []time.Time 854 | } 855 | 856 | func (s reusingRemainSlice) Len() int { 857 | return len(s.storage) 858 | } 859 | 860 | func (s *reusingRemainSlice) Append(t time.Time) { 861 | s.storage = append(s.storage, t) 862 | s.backup = s.storage 863 | } 864 | 865 | func (s *reusingRemainSlice) Pop() (ret time.Time, ok bool) { 866 | if len(s.storage) == 0 { 867 | return time.Time{}, false 868 | } 869 | 870 | ret, s.storage = s.storage[0], s.storage[1:] 871 | 872 | if len(s.storage) == 0 { 873 | // flush storage 874 | s.storage = s.backup[:0] 875 | } 876 | 877 | return ret, true 878 | } 879 | 880 | // Iterator return an iterator for RRule 881 | func (r *RRule) Iterator() Next { 882 | iterator := rIterator{} 883 | iterator.year, iterator.month, iterator.day = r.dtstart.Date() 884 | iterator.hour, iterator.minute, iterator.second = r.dtstart.Clock() 885 | iterator.weekday = toPyWeekday(r.dtstart.Weekday()) 886 | 887 | iterator.ii = iterInfo{rrule: r} 888 | iterator.ii.rebuild(iterator.year, iterator.month) 889 | 890 | if r.freq < HOURLY { 891 | iterator.timeset = r.timeset 892 | } else { 893 | if r.freq >= HOURLY && len(r.byhour) != 0 && !contains(r.byhour, iterator.hour) || 894 | r.freq >= MINUTELY && len(r.byminute) != 0 && !contains(r.byminute, iterator.minute) || 895 | r.freq >= SECONDLY && len(r.bysecond) != 0 && !contains(r.bysecond, iterator.second) { 896 | iterator.timeset = nil 897 | } else { 898 | iterator.ii.fillTimeSet(&iterator.timeset, r.freq, iterator.hour, iterator.minute, iterator.second) 899 | } 900 | } 901 | iterator.count = r.count 902 | return iterator.next 903 | } 904 | 905 | // All returns all occurrences of the RRule. 906 | // It is only supported second precision. 907 | func (r *RRule) All() []time.Time { 908 | return all(r.Iterator()) 909 | } 910 | 911 | // Between returns all the occurrences of the RRule between after and before. 912 | // The inc keyword defines what happens if after and/or before are themselves occurrences. 913 | // With inc == True, they will be included in the list, if they are found in the recurrence set. 914 | // It is only supported second precision. 915 | func (r *RRule) Between(after, before time.Time, inc bool) []time.Time { 916 | return between(r.Iterator(), after, before, inc) 917 | } 918 | 919 | // Before returns the last recurrence before the given datetime instance, 920 | // or time.Time's zero value if no recurrence match. 921 | // The inc keyword defines what happens if dt is an occurrence. 922 | // With inc == True, if dt itself is an occurrence, it will be returned. 923 | // It is only supported second precision. 924 | func (r *RRule) Before(dt time.Time, inc bool) time.Time { 925 | return before(r.Iterator(), dt, inc) 926 | } 927 | 928 | // After returns the first recurrence after the given datetime instance, 929 | // or time.Time's zero value if no recurrence match. 930 | // The inc keyword defines what happens if dt is an occurrence. 931 | // With inc == True, if dt itself is an occurrence, it will be returned. 932 | // It is only supported second precision. 933 | func (r *RRule) After(dt time.Time, inc bool) time.Time { 934 | return after(r.Iterator(), dt, inc) 935 | } 936 | 937 | // DTStart set a new DTSTART for the rule and recalculates the timeset if needed. 938 | // It will be truncated to second precision. 939 | // Default to `time.Now().UTC().Truncate(time.Second)`. 940 | func (r *RRule) DTStart(dt time.Time) { 941 | r.OrigOptions.Dtstart = dt.Truncate(time.Second) 942 | *r = buildRRule(r.OrigOptions) 943 | } 944 | 945 | // GetDTStart gets DTSTART time for rrule 946 | func (r *RRule) GetDTStart() time.Time { 947 | return r.dtstart 948 | } 949 | 950 | // Until set a new UNTIL for the rule and recalculates the timeset if needed. 951 | // It will be truncated to second precision. 952 | // Default to `Dtstart.Add(time.Duration(1<<63 - 1))`, approximately 290 years. 953 | func (r *RRule) Until(ut time.Time) { 954 | r.OrigOptions.Until = ut.Truncate(time.Second) 955 | *r = buildRRule(r.OrigOptions) 956 | } 957 | 958 | // GetUntil gets UNTIL time for rrule 959 | func (r *RRule) GetUntil() time.Time { 960 | return r.until 961 | } 962 | --------------------------------------------------------------------------------