├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.yml ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml ├── release_template.md └── workflows │ └── coding-standards.yml ├── .gitignore ├── CONTRIBUTING.md ├── FUNDING.yml ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── ecs.php ├── examples ├── ICal.ics └── index.php ├── phpstan.neon.dist ├── phpunit.xml ├── rector.php ├── src └── ICal │ ├── Event.php │ └── ICal.php └── tests ├── CleanCharacterTest.php ├── DynamicPropertiesTest.php ├── KeyValueTest.php ├── RecurrencesTest.php ├── Rfc5545RecurrenceTest.php ├── SingleEventsTest.php └── ical ├── ical-monthly.ics └── issue-196.ics /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: "Report something that's broken." 3 | labels: ["bug-normal"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: "Before raising an issue, please check the issue has not already been fixed in `dev-master`. You can also search through our [closed issues](../issues?q=is%3Aissue+is%3Aclosed+)." 8 | - type: input 9 | id: php-version 10 | attributes: 11 | label: PHP Version 12 | description: Provide the PHP version that you are using. 13 | placeholder: 8.1.4 14 | validations: 15 | required: true 16 | - type: input 17 | id: php-date-timezone 18 | attributes: 19 | label: PHP date.timezone 20 | description: Provide the PHP date.timezone that you are using. 21 | placeholder: "[Country] / [City]" 22 | validations: 23 | required: true 24 | - type: input 25 | id: ics-parser-version 26 | attributes: 27 | label: ICS Parser Version 28 | description: Provide the `ics-parser` library version that you are using. 29 | placeholder: 3.2.1 30 | validations: 31 | required: true 32 | - type: input 33 | id: operating-system 34 | attributes: 35 | label: Operating System 36 | description: Provide the operating system that you are using. 37 | placeholder: "Windows / Mac / Linux" 38 | validations: 39 | required: true 40 | - type: textarea 41 | id: description 42 | attributes: 43 | label: Description 44 | description: Provide a detailed description of the issue that you are facing. 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: steps-to-reproduce 49 | attributes: 50 | label: Steps to Reproduce 51 | description: Provide detailed steps to reproduce your issue. It is **essential** that you supply a copy of the iCal file that is causing the parser to behave incorrectly to allow us to investigate. Prior to uploading the iCal file, please remove any personal or identifying information. 52 | validations: 53 | required: true 54 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | > :information_source: 2 | > - File a bug on our [issue tracker](https://github.com/u01jmg3/ics-parser/issues) (if there isn't one already). 3 | > - If your patch is going to be large it might be a good idea to get the discussion started early. We are happy to discuss it in a new issue beforehand. 4 | > - Please follow the coding standards already adhered to in the file you're editing before committing 5 | > - This includes the use of *4 spaces* over tabs for indentation 6 | > - Trim all trailing whitespace 7 | > - Using single quotes (`'`) where possible 8 | > - Use `PHP_EOL` where possible or default to `\n` 9 | > - Using the [1TBS](https://en.wikipedia.org/wiki/Indent_style#Variant:_1TBS_.28OTBS.29) indent style 10 | > - If a function is added or changed, please remember to update the [API documentation in the README](https://github.com/u01jmg3/ics-parser/blob/master/README.md#api) 11 | > - Please include unit tests to verify any new functionality 12 | > - Also check that existing tests still pass: `composer test` 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | reviewers: 9 | - u01jmg3 10 | - package-ecosystem: "github-actions" 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | -------------------------------------------------------------------------------- /.github/release_template.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | - [ ] Update docblock in `src/ICal/ICal.php` 4 | - [ ] Ensure the documentation is up to date 5 | - [ ] Push the code changes to GitHub (`git push`) 6 | - [ ] Tag the release (`git tag v1.2.3`) 7 | - [ ] Push the tag (`git push --tag`) 8 | - [ ] Check [Packagist](https://packagist.org/packages/johngrogg/ics-parser) is updated 9 | - [ ] Notify anyone who opened [an issue or PR](https://github.com/u01jmg3/ics-parser/issues?q=is%3Aopen) of the fix 10 | -------------------------------------------------------------------------------- /.github/workflows/coding-standards.yml: -------------------------------------------------------------------------------- 1 | name: Coding Standards 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | Scan: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | php: [5.6, 7.4, '8.0', 8.1, 8.2, 8.3, 8.4] 20 | 21 | name: PHP ${{ matrix.php }} 22 | 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | tools: composer:2.2 32 | coverage: none 33 | 34 | - name: PHP 5.6 syntax check 35 | run: | 36 | find . -type f -iname "*.php" ! -name 'ecs.php' ! -name 'rector.php' ! -path 'tests/*' -print0 | xargs -0 -L 1 php -l | (! grep -v "No syntax errors detected") 37 | echo $? 38 | if: matrix.php == 5.6 39 | 40 | - name: Install dependencies for PHP 5.6 41 | run: composer update --quiet --no-scripts 42 | if: matrix.php == 5.6 43 | 44 | - name: PHP 7.4+ syntax check 45 | run: | 46 | find . -type f -iname "*.php" -print0 | xargs -0 -L 1 php -l | (! grep -v "No syntax errors detected") 47 | echo $? 48 | if: matrix.php >= 7.4 49 | 50 | - name: Install dependencies for PHP 7.4+ 51 | run: composer install --quiet --no-scripts 52 | if: matrix.php >= 7.4 53 | 54 | - name: Execute tests 55 | run: vendor/bin/phpunit --verbose 56 | 57 | - name: Install additional dependencies 58 | run: | 59 | composer config allow-plugins.bamarni/composer-bin-plugin true --no-plugins 60 | composer require bamarni/composer-bin-plugin rector/rector squizlabs/php_codesniffer --dev --quiet --no-scripts 61 | composer bin easy-coding-standard config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true 62 | composer bin easy-coding-standard require symplify/easy-coding-standard slevomat/coding-standard --dev --quiet --no-scripts 63 | if: matrix.php == 8.3 64 | 65 | - name: Execute PHPCodeSniffer 66 | run: vendor/bin/phpcs -n -s --standard=PSR12 src 67 | if: matrix.php == 8.3 68 | 69 | - name: Execute Rector 70 | run: vendor/bin/rector process src --dry-run 71 | if: matrix.php == 8.3 72 | 73 | - name: Execute ECS 74 | run: vendor/bin/ecs check src 75 | if: matrix.php == 8.3 76 | 77 | - name: Execute PHPStan 78 | run: vendor/bin/phpstan -v analyse src 79 | if: matrix.php == 8.3 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################### 2 | # Compiled Source # 3 | ################### 4 | *.com 5 | *.class 6 | *.dll 7 | *.exe 8 | *.o 9 | *.so 10 | 11 | ############ 12 | # Packages # 13 | ############ 14 | *.7z 15 | *.dmg 16 | *.gz 17 | *.iso 18 | *.jar 19 | *.rar 20 | *.tar 21 | *.zip 22 | 23 | ###################### 24 | # Logs and Databases # 25 | ###################### 26 | *.log 27 | *.sqlite 28 | 29 | ###################### 30 | # OS Generated Files # 31 | ###################### 32 | .DS_Store 33 | .DS_Store? 34 | ._* 35 | .Spotlight-V100 36 | .Trashes 37 | .phpunit.result.cache 38 | ehthumbs.db 39 | Thumbs.db 40 | workbench 41 | 42 | #################### 43 | # Package Managers # 44 | #################### 45 | auth.json 46 | node_modules 47 | vendor 48 | 49 | ########## 50 | # Custom # 51 | ########## 52 | *.git 53 | *-report.* 54 | 55 | ######## 56 | # IDEs # 57 | ######## 58 | .idea 59 | *.iml 60 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ICS Parser is an open source project. It is licensed under the [MIT license](https://opensource.org/licenses/MIT). 4 | We appreciate pull requests, here are our guidelines: 5 | 6 | 1. Firstly, check if your issue is present within the latest version (`dev-master`) as the problem may already have been fixed. 7 | 1. Log a bug in our [issue tracker](https://github.com/u01jmg3/ics-parser/issues) (if there isn't one already). 8 | - If your patch is going to be large it might be a good idea to get the discussion started early. 9 | - We are happy to discuss it in an issue beforehand. 10 | - If you could provide an iCal snippet causing the parser to behave incorrectly it is extremely useful for debugging 11 | - Please remove all irrelevant events 12 | 1. Please follow the coding standard already present in the file you are editing _before_ committing 13 | - Adhere to the [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) coding standard 14 | - Use *4 spaces* instead of tabs for indentation 15 | - Trim all trailing whitespace and blank lines 16 | - Use single quotes (`'`) where possible instead of double 17 | - Use `PHP_EOL` where possible or default to `\n` 18 | - Abide by the [1TBS](https://en.wikipedia.org/wiki/Indent_style#Variant:_1TBS_.28OTBS.29) indentation style 19 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: u01jmg3 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2018 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, 6 | modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the 7 | Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 10 | Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 13 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 15 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP ICS Parser 2 | 3 | [![Latest Stable Release](https://poser.pugx.org/johngrogg/ics-parser/v "Latest Stable Release")](https://packagist.org/packages/johngrogg/ics-parser) 4 | [![Total Downloads](https://poser.pugx.org/johngrogg/ics-parser/downloads "Total Downloads")](https://packagist.org/packages/johngrogg/ics-parser) 5 | 6 | --- 7 | 8 | ## Installation 9 | 10 | ### Requirements 11 | - PHP 5 (≥ 5.6.40) 12 | - [Valid ICS](https://icalendar.org/validator.html) (`.ics`, `.ical`, `.ifb`) file 13 | - [IANA](https://www.iana.org/time-zones), [Unicode CLDR](https://cldr.unicode.org) or [Windows](https://learn.microsoft.com/en-us/previous-versions/windows/embedded/ms912391(v=winembedded.11)) Time Zones 14 | 15 | ### Setup 16 | 17 | - Install [Composer](https://getcomposer.org/) 18 | - Add the following dependency to `composer.json` 19 | - :warning: **Note with Composer the owner is `johngrogg` and not `u01jmg3`** 20 | - To access the latest stable branch (`v3`) use the following 21 | - To access new features you can require [`dev-master`](https://getcomposer.org/doc/articles/aliases.md#branch-alias) 22 | 23 | ```yaml 24 | { 25 | "require": { 26 | "johngrogg/ics-parser": "^3" 27 | } 28 | } 29 | ``` 30 | 31 | ## Running tests 32 | 33 | ```sh 34 | composer test 35 | ``` 36 | 37 | ## How to use 38 | 39 | ### How to instantiate the Parser 40 | 41 | - Using the example script as a guide, [refer to this code](https://github.com/u01jmg3/ics-parser/blob/master/examples/index.php#L1-L22) 42 | 43 | #### What will the parser return? 44 | 45 | - Each key/value pair from the iCal file will be parsed creating an associative array for both the calendar and every event it contains. 46 | - Also injected will be content under `dtstart_tz` and `dtend_tz` for accessing start and end dates with time zone data applied. 47 | - Where possible [`DateTime`](https://secure.php.net/manual/en/class.datetime.php) objects are used and returned. 48 | - :information_source: **Note the parser is limited to [relative date formats](https://www.php.net/manual/en/datetime.formats.relative.php) which can inhibit how complex recurrence rule parts are processed (e.g. `BYDAY` combined with `BYSETPOS`)** 49 | 50 | ```php 51 | // Dump the whole calendar 52 | var_dump($ical->cal); 53 | 54 | // Dump every event 55 | var_dump($ical->events()); 56 | ``` 57 | 58 | - Also included are special `{property}_array` arrays which further resolve the contents of a key/value pair. 59 | 60 | ```php 61 | // Dump a parsed event's start date 62 | var_dump($event->dtstart_array); 63 | 64 | // array (size=4) 65 | // 0 => 66 | // array (size=1) 67 | // 'TZID' => string 'America/Detroit' (length=15) 68 | // 1 => string '20160409T090000' (length=15) 69 | // 2 => int 1460192400 70 | // 3 => string 'TZID=America/Detroit:20160409T090000' (length=36) 71 | ``` 72 | 73 | ### Are you using Outlook? 74 | 75 | Outlook has a quirk where it requires the User Agent string to be set in your request headers. 76 | 77 | We have done this for you by injecting a default User Agent string, if one has not been specified. 78 | 79 | If you wish to provide your own User agent string you can do so by using the `httpUserAgent` argument when creating your ICal object. 80 | 81 | ```php 82 | $ical = new ICal($url, array('httpUserAgent' => 'A Different User Agent')); 83 | ``` 84 | 85 | --- 86 | 87 | ## When Parsing an iCal Feed 88 | 89 | Parsing [iCal/iCalendar/ICS](https://en.wikipedia.org/wiki/ICalendar) resources can pose several challenges. One challenge is that 90 | the specification is a moving target; the original RFC has only been updated four times in ten years. The other challenge is that vendors 91 | were both liberal (read: creative) in interpreting the specification and productive implementing proprietary extensions. 92 | 93 | However, what impedes efficient parsing most directly are recurrence rules for events. This library parses the original 94 | calendar into an easy to work with memory model. This requires that each recurring event is expanded or exploded. Hence, 95 | a single event that occurs daily will generate a new event instance for each day as this parser processes the 96 | calendar ([`$defaultSpan`](#variables) limits this). To get an idea how this is done take a look at the 97 | [call graph](https://user-images.githubusercontent.com/624195/45904641-f3cd0a80-bded-11e8-925f-7bcee04b8575.png). 98 | 99 | As a consequence the _entire_ calendar is parsed line-by-line, and thus loaded into memory, first. As you can imagine 100 | large calendars tend to get huge when exploded i.e. with all their recurrence rules evaluated. This is exacerbated when 101 | old calendars do not remove past events as they get fatter and fatter every year. 102 | 103 | This limitation is particularly painful if you only need a window into the original calendar. It seems wasteful to parse 104 | the entire fully exploded calendar into memory if you later are going to call the 105 | [`eventsFromInterval()` or `eventsFromRange()`](#methods) on it. 106 | 107 | In late 2018 [#190](https://github.com/u01jmg3/ics-parser/pull/190) added the option to drop all events outside a given 108 | range very early in the parsing process at the cost of some precision (time zone calculations are not calculated at that point). This 109 | massively reduces the total time for parsing a calendar. The same goes for memory consumption. The precondition is that 110 | you know upfront that you don't care about events outside a given range. 111 | 112 | Let's say you are only interested in events from yesterday, today and tomorrow. To compensate for the fact that the 113 | tricky time zone transformations and calculations have not been executed yet by the time the parser has to decide whether 114 | to keep or drop an event you can set it to filter for **+-2d** instead of +-1d. Once it is done you would then call 115 | `eventsFromRange()` with +-1d to get precisely the events in the window you are interested in. That is what the variables 116 | [`$filterDaysBefore` and `$filterDaysAfter`](#variables) are for. 117 | 118 | In Q1 2019 [#213](https://github.com/u01jmg3/ics-parser/pull/213) further improved the performance by immediately 119 | dropping _non-recurring_ events once parsed if they are outside that fuzzy window. This greatly reduces the maximum 120 | memory consumption for large calendars. PHP by default does not allocate more than 128MB heap and would otherwise crash 121 | with `Fatal error: Allowed memory size of 134217728 bytes exhausted`. It goes without saying that recurring events first 122 | need to be evaluated before non-fitting events can be dropped. 123 | 124 | --- 125 | 126 | ## API 127 | 128 | ### `ICal` API 129 | 130 | #### Variables 131 | 132 | | Name | Configurable | Default Value | Description | 133 | |--------------------------------|:------------------------:|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 134 | | `$alarmCount` | :heavy_multiplication_x: | N/A | Tracks the number of alarms in the current iCal feed | 135 | | `$cal` | :heavy_multiplication_x: | N/A | The parsed calendar | 136 | | `$defaultSpan` | :ballot_box_with_check: | `2` | The value in years to use for indefinite, recurring events | 137 | | `$defaultTimeZone` | :ballot_box_with_check: | [System default](https://secure.php.net/manual/en/function.date-default-timezone-get.php) | Enables customisation of the default time zone | 138 | | `$defaultWeekStart` | :ballot_box_with_check: | `MO` | The two letter representation of the first day of the week | 139 | | `$disableCharacterReplacement` | :ballot_box_with_check: | `false` | Toggles whether to disable all character replacement. Will replace curly quotes and other special characters with their standard equivalents if `false`. Can be a costly operation! | 140 | | `$eventCount` | :heavy_multiplication_x: | N/A | Tracks the number of events in the current iCal feed | 141 | | `$filterDaysAfter` | :ballot_box_with_check: | `null` | When set the parser will ignore all events more than roughly this many days _after_ now. To be on the safe side it is advised that you make the filter window `+/- 1` day larger than necessary. For performance reasons this filter is applied before any date and time zone calculations are done. Hence, depending the time zone settings of the parser and the calendar the cut-off date is not "calibrated". You can then use `$ical->eventsFromRange()` to precisely shrink the window. | 142 | | `$filterDaysBefore` | :ballot_box_with_check: | `null` | When set the parser will ignore all events more than roughly this many days _before_ now. See `$filterDaysAfter` above for more details. | 143 | | `$freeBusyCount` | :heavy_multiplication_x: | N/A | Tracks the free/busy count in the current iCal feed | 144 | | `$httpBasicAuth` | :heavy_multiplication_x: | `array()` | Holds the username and password for HTTP basic authentication | 145 | | `$httpUserAgent` | :ballot_box_with_check: | `null` | Holds the custom User Agent string header | 146 | | `$httpAcceptLanguage` | :heavy_multiplication_x: | `null` | Holds the custom Accept Language request header, e.g. "en" or "de" | 147 | | `$httpProtocolVersion` | :heavy_multiplication_x: | `null` | Holds the custom HTTP Protocol version, e.g. "1.0" or "1.1" | 148 | | `$shouldFilterByWindow` | :heavy_multiplication_x: | `false` | `true` if either `$filterDaysBefore` or `$filterDaysAfter` are set | 149 | | `$skipRecurrence` | :ballot_box_with_check: | `false` | Toggles whether to skip the parsing of recurrence rules | 150 | | `$todoCount` | :heavy_multiplication_x: | N/A | Tracks the number of todos in the current iCal feed | 151 | | `$windowMaxTimestamp` | :heavy_multiplication_x: | `null` | If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined by this field and `$windowMinTimestamp` | 152 | | `$windowMinTimestamp` | :heavy_multiplication_x: | `null` | If `$filterDaysBefore` or `$filterDaysAfter` are set then the events are filtered according to the window defined by this field and `$windowMaxTimestamp` | 153 | 154 | #### Methods 155 | 156 | | Method | Parameter(s) | Visibility | Description | 157 | |-------------------------------------------------|-----------------------------------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| 158 | | `__construct` | `$files = false`, `$options = array()` | `public` | Creates the ICal object | 159 | | `initFile` | `$file` | `protected` | Initialises lines from a file | 160 | | `initLines` | `$lines` | `protected` | Initialises the parser using an array containing each line of iCal content | 161 | | `initString` | `$string` | `protected` | Initialises lines from a string | 162 | | `initUrl` | `$url`, `$username = null`, `$password = null`, `$userAgent = null`, `$acceptLanguage = null` | `protected` | Initialises lines from a URL. Accepts a username/password combination for HTTP basic authentication, a custom User Agent string and the accepted client language | 163 | | `addCalendarComponentWithKeyAndValue` | `$component`, `$keyword`, `$value` | `protected` | Add one key and value pair to the `$this->cal` array | 164 | | `calendarDescription` | - | `public` | Returns the calendar description | 165 | | `calendarName` | - | `public` | Returns the calendar name | 166 | | `calendarTimeZone` | `$ignoreUtc` | `public` | Returns the calendar time zone | 167 | | `cleanCharacters` | `$data` | `protected` | Replaces curly quotes and other special characters with their standard equivalents | 168 | | `eventsFromInterval` | `$interval` | `public` | Returns a sorted array of events following a given string | 169 | | `eventsFromRange` | `$rangeStart = false`, `$rangeEnd = false` | `public` | Returns a sorted array of events in a given range, or an empty array if no events exist in the range | 170 | | `events` | - | `public` | Returns an array of Events | 171 | | `fileOrUrl` | `$filename` | `protected` | Reads an entire file or URL into an array | 172 | | `filterValuesUsingBySetPosRRule` | `$bysetpos`, `$valueslist` | `protected` | Filters a provided values-list by applying a BYSETPOS RRule | 173 | | `freeBusyEvents` | - | `public` | Returns an array of arrays with all free/busy events | 174 | | `getDaysOfMonthMatchingByDayRRule` | `$bydays`, `$initialDateTime` | `protected` | Find all days of a month that match the BYDAY stanza of an RRULE | 175 | | `getDaysOfMonthMatchingByMonthDayRRule` | `$byMonthDays`, `$initialDateTime` | `protected` | Find all days of a month that match the BYMONTHDAY stanza of an RRULE | 176 | | `getDaysOfYearMatchingByDayRRule` | `$byDays`, `$initialDateTime` | `protected` | Find all days of a year that match the BYDAY stanza of an RRULE | 177 | | `getDaysOfYearMatchingByMonthDayRRule` | `$byMonthDays`, `$initialDateTime` | `protected` | Find all days of a year that match the BYMONTHDAY stanza of an RRULE | 178 | | `getDaysOfYearMatchingByWeekNoRRule` | `$byWeekNums`, `$initialDateTime` | `protected` | Find all days of a year that match the BYWEEKNO stanza of an RRULE | 179 | | `getDaysOfYearMatchingByYearDayRRule` | `$byYearDays`, `$initialDateTime` | `protected` | Find all days of a year that match the BYYEARDAY stanza of an RRULE | 180 | | `getDefaultTimeZone` | `$forceReturnSystemDefault` | `private` | Returns the default time zone if set or falls back to the system default if not set | 181 | | `hasEvents` | - | `public` | Returns a boolean value whether the current calendar has events or not | 182 | | `iCalDateToDateTime` | `$icalDate` | `public` | Returns a `DateTime` object from an iCal date time format | 183 | | `iCalDateToUnixTimestamp` | `$icalDate` | `public` | Returns a Unix timestamp from an iCal date time format | 184 | | `iCalDateWithTimeZone` | `$event`, `$key`, `$format = DATE_TIME_FORMAT` | `public` | Returns a date adapted to the calendar time zone depending on the event `TZID` | 185 | | `doesEventStartOutsideWindow` | `$event` | `protected` | Determines whether the event start date is outside `$windowMinTimestamp` / `$windowMaxTimestamp` | 186 | | `isFileOrUrl` | `$filename` | `protected` | Checks if a filename exists as a file or URL | 187 | | `isOutOfRange` | `$calendarDate`, `$minTimestamp`, `$maxTimestamp` | `protected` | Determines whether a valid iCalendar date is within a given range | 188 | | `isValidCldrTimeZoneId` | `$timeZone` | `protected` | Checks if a time zone is a valid CLDR time zone | 189 | | `isValidDate` | `$value` | `public` | Checks if a date string is a valid date | 190 | | `isValidIanaTimeZoneId` | `$timeZone` | `protected` | Checks if a time zone is a valid IANA time zone | 191 | | `isValidWindowsTimeZoneId` | `$timeZone` | `protected` | Checks if a time zone is a recognised Windows (non-CLDR) time zone | 192 | | `isValidTimeZoneId` | `$timeZone` | `protected` | Checks if a time zone is valid (IANA, CLDR, or Windows) | 193 | | `keyValueFromString` | `$text` | `public` | Gets the key value pair from an iCal string | 194 | | `parseLine` | `$line` | `protected` | Parses a line from an iCal file into an array of tokens | 195 | | `mb_chr` | `$code` | `protected` | Provides a polyfill for PHP 7.2's `mb_chr()`, which is a multibyte safe version of `chr()` | 196 | | `escapeParamText` | `$candidateText` | `protected` | Places double-quotes around texts that have characters not permitted in parameter-texts, but are permitted in quoted-texts. | 197 | | `parseDuration` | `$date`, `$duration` | `protected` | Parses a duration and applies it to a date | 198 | | `parseExdates` | `$event` | `public` | Parses a list of excluded dates to be applied to an Event | 199 | | `processDateConversions` | - | `protected` | Processes date conversions using the time zone | 200 | | `processEvents` | - | `protected` | Performs admin tasks on all events as read from the iCal file | 201 | | `processRecurrences` | - | `protected` | Processes recurrence rules | 202 | | `reduceEventsToMinMaxRange` | | `protected` | Reduces the number of events to the defined minimum and maximum range | 203 | | `removeLastEventIfOutsideWindowAndNonRecurring` | | `protected` | Removes the last event (i.e. most recently parsed) if its start date is outside the window spanned by `$windowMinTimestamp` / `$windowMaxTimestamp` | 204 | | `removeUnprintableChars` | `$data` | `protected` | Removes unprintable ASCII and UTF-8 characters | 205 | | `resolveIndicesOfRange` | `$indexes`, `$limit` | `protected` | Resolves values from indices of the range 1 -> `$limit` | 206 | | `sortEventsWithOrder` | `$events`, `$sortOrder = SORT_ASC` | `public` | Sorts events based on a given sort order | 207 | | `timeZoneStringToDateTimeZone` | `$timeZoneString` | `public` | Returns a `DateTimeZone` object based on a string containing a time zone name. | 208 | | `unfold` | `$lines` | `protected` | Unfolds an iCal file in preparation for parsing | 209 | 210 | #### Constants 211 | 212 | | Name | Description | 213 | |---------------------------|-----------------------------------------------| 214 | | `DATE_TIME_FORMAT_PRETTY` | Default pretty date time format to use | 215 | | `DATE_TIME_FORMAT` | Default date time format to use | 216 | | `ICAL_DATE_TIME_TEMPLATE` | String template to generate an iCal date time | 217 | | `ISO_8601_WEEK_START` | First day of the week, as defined by ISO-8601 | 218 | | `RECURRENCE_EVENT` | Used to isolate generated recurrence events | 219 | | `SECONDS_IN_A_WEEK` | The number of seconds in a week | 220 | | `TIME_FORMAT` | Default time format to use | 221 | | `TIME_ZONE_UTC` | UTC time zone string | 222 | | `UNIX_FORMAT` | Unix timestamp date format | 223 | | `UNIX_MIN_YEAR` | The year Unix time began | 224 | 225 | --- 226 | 227 | ### `Event` API (extends `ICal` API) 228 | 229 | #### Methods 230 | 231 | | Method | Parameter(s) | Visibility | Description | 232 | |---------------|---------------------------------------------|-------------|---------------------------------------------------------------------| 233 | | `__construct` | `$data = array()` | `public` | Creates the Event object | 234 | | `prepareData` | `$value` | `protected` | Prepares the data for output | 235 | | `printData` | `$html = HTML_TEMPLATE` | `public` | Returns Event data excluding anything blank within an HTML template | 236 | | `snakeCase` | `$input`, `$glue = '_'`, `$separator = '-'` | `protected` | Converts the given input to snake_case | 237 | 238 | #### Constants 239 | 240 | | Name | Description | 241 | |-----------------|-----------------------------------------------------| 242 | | `HTML_TEMPLATE` | String template to use when pretty printing content | 243 | 244 | --- 245 | 246 | ## Credits 247 | - [Jonathan Goode](https://github.com/u01jmg3) (programming, bug fixing, codebase enhancement, coding standard adoption) 248 | - [s0600204](https://github.com/s0600204) (major enhancements to RRULE support, many bug fixes and other contributions) 249 | 250 | --- 251 | 252 | ## Tools for Testing 253 | 254 | - [iCal Validator](https://icalendar.org/validator.html) 255 | - [Recurrence Rule Tester](https://jkbrzt.github.io/rrule/) 256 | - [Unix Timestamp Converter](https://www.unixtimestamp.com) 257 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "johngrogg/ics-parser", 3 | "description": "ICS Parser", 4 | "homepage": "https://github.com/u01jmg3/ics-parser", 5 | "keywords": [ 6 | "ical", 7 | "ical-parser", 8 | "icalendar", 9 | "ics", 10 | "ics-parser", 11 | "ifb" 12 | ], 13 | "type": "library", 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Jonathan Goode", 18 | "role": "Developer/Owner" 19 | }, 20 | { 21 | "name": "John Grogg", 22 | "email": "john.grogg@gmail.com", 23 | "role": "Developer/Prior Owner" 24 | } 25 | ], 26 | "funding": [ 27 | { 28 | "type": "github", 29 | "url": "https://github.com/sponsors/u01jmg3" 30 | } 31 | ], 32 | "require": { 33 | "php": ">=5.6.40", 34 | "ext-mbstring": "*" 35 | }, 36 | "require-dev": { 37 | "phpunit/phpunit": "^5|^9|^10" 38 | }, 39 | "autoload": { 40 | "psr-0": { 41 | "ICal": "src/" 42 | } 43 | }, 44 | "scripts": { 45 | "test": [ 46 | "phpunit --colors=always" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | disableParallel(); 85 | 86 | // https://github.com/easy-coding-standard/easy-coding-standard/blob/main/config/set/psr12.php 87 | $ecsConfig->import(SetList::PSR_12); 88 | 89 | $ecsConfig->lineEnding("\n"); 90 | 91 | $ecsConfig->skip(array( 92 | // Fixers 93 | 'PhpCsFixer\Fixer\Whitespace\StatementIndentationFixer' => array('examples/index.php'), 94 | 'PhpCsFixer\Fixer\Basic\BracesFixer' => null, 95 | 'PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer' => null, 96 | 'PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer' => null, 97 | 'PhpCsFixer\Fixer\Phpdoc\PhpdocScalarFixer' => null, 98 | 'PhpCsFixer\Fixer\Phpdoc\PhpdocSummaryFixer' => null, 99 | 'PhpCsFixer\Fixer\Phpdoc\PhpdocVarWithoutNameFixer' => null, 100 | 'PhpCsFixer\Fixer\ReturnNotation\SimplifiedNullReturnFixer' => null, 101 | // Requires PHP 7.1 and above 102 | 'PhpCsFixer\Fixer\ClassNotation\VisibilityRequiredFixer' => null, 103 | )); 104 | 105 | $ecsConfig->ruleWithConfiguration(SpaceAfterNotSniff::class, array('spacing' => 0)); 106 | 107 | $ecsConfig->ruleWithConfiguration(ArraySyntaxFixer::class, array('syntax' => 'long')); 108 | 109 | $ecsConfig->ruleWithConfiguration( 110 | YodaStyleFixer::class, 111 | array( 112 | 'equal' => false, 113 | 'identical' => false, 114 | 'less_and_greater' => false, 115 | ) 116 | ); 117 | 118 | $ecsConfig->ruleWithConfiguration(ListSyntaxFixer::class, array('syntax' => 'long')); // PHP 5.6 119 | 120 | $ecsConfig->ruleWithConfiguration( 121 | BlankLineBeforeStatementFixer::class, 122 | array( 123 | 'statements' => array( 124 | 'continue', 125 | 'declare', 126 | 'return', 127 | 'throw', 128 | 'try', 129 | ), 130 | ) 131 | ); 132 | 133 | $ecsConfig->rules( 134 | array( 135 | AlphabeticallySortedUsesSniff::class, 136 | UnusedVariableSniff::class, 137 | SelfMemberReferenceSniff::class, 138 | BlankLinesBeforeNamespaceFixer::class, 139 | CastSpacesFixer::class, 140 | ClassDefinitionFixer::class, 141 | CompactNullableTypehintFixer::class, 142 | ConstantCaseFixer::class, 143 | ElseifFixer::class, 144 | EncodingFixer::class, 145 | FullOpeningTagFixer::class, 146 | FunctionDeclarationFixer::class, 147 | HeredocToNowdocFixer::class, 148 | IncludeFixer::class, 149 | LambdaNotUsedImportFixer::class, 150 | LineEndingFixer::class, 151 | LowercaseKeywordsFixer::class, 152 | LowercaseStaticReferenceFixer::class, 153 | MagicConstantCasingFixer::class, 154 | MagicMethodCasingFixer::class, 155 | MethodArgumentSpaceFixer::class, 156 | MultilineWhitespaceBeforeSemicolonsFixer::class, 157 | NativeFunctionCasingFixer::class, 158 | NativeFunctionTypeDeclarationCasingFixer::class, 159 | NoAliasFunctionsFixer::class, 160 | NoClosingTagFixer::class, 161 | NoEmptyPhpdocFixer::class, 162 | NoEmptyStatementFixer::class, 163 | NoExtraBlankLinesFixer::class, 164 | NoLeadingNamespaceWhitespaceFixer::class, 165 | NoMixedEchoPrintFixer::class, 166 | NoMultilineWhitespaceAroundDoubleArrowFixer::class, 167 | NoShortBoolCastFixer::class, 168 | NoSpacesAfterFunctionNameFixer::class, 169 | NoSpacesInsideParenthesisFixer::class, 170 | NoTrailingCommaInSinglelineFixer::class, 171 | NoTrailingWhitespaceInCommentFixer::class, 172 | NoUnneededControlParenthesesFixer::class, 173 | NoUnneededCurlyBracesFixer::class, 174 | NoUnreachableDefaultArgumentValueFixer::class, 175 | NoUnusedImportsFixer::class, 176 | NoUselessReturnFixer::class, 177 | NoWhitespaceInBlankLineFixer::class, 178 | NormalizeIndexBraceFixer::class, 179 | ObjectOperatorWithoutWhitespaceFixer::class, 180 | PhpdocIndentFixer::class, 181 | PhpdocInlineTagNormalizerFixer::class, 182 | PhpdocNoAccessFixer::class, 183 | PhpdocNoPackageFixer::class, 184 | PhpdocNoUselessInheritdocFixer::class, 185 | PhpdocParamOrderFixer::class, 186 | PhpdocSingleLineVarSpacingFixer::class, 187 | PhpdocToCommentFixer::class, 188 | PhpdocTrimFixer::class, 189 | PhpdocTypesFixer::class, 190 | SingleBlankLineAtEofFixer::class, 191 | SingleClassElementPerStatementFixer::class, 192 | SingleImportPerStatementFixer::class, 193 | SingleLineAfterImportsFixer::class, 194 | SingleLineCommentStyleFixer::class, 195 | SingleQuoteFixer::class, 196 | SpaceAfterSemicolonFixer::class, 197 | StandardizeNotEqualsFixer::class, 198 | SwitchCaseSemicolonToColonFixer::class, 199 | SwitchCaseSpaceFixer::class, 200 | TrailingCommaInMultilineFixer::class, 201 | TrimArraySpacesFixer::class, 202 | TypeDeclarationSpacesFixer::class, 203 | ) 204 | ); 205 | }; 206 | -------------------------------------------------------------------------------- /examples/ICal.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:Testkalender 7 | X-WR-TIMEZONE:UTC 8 | X-WR-CALDESC:Nur zum testen vom Google Kalender 9 | BEGIN:VFREEBUSY 10 | UID:f06ff6b3564b2f696bf42d393f8dea59 11 | ORGANIZER:MAILTO:jane_smith@host1.com 12 | DTSTAMP:20170316T204607Z 13 | DTSTART:20170213T204607Z 14 | DTEND:20180517T204607Z 15 | URL:https://www.host.com/calendar/busytime/jsmith.ifb 16 | FREEBUSY;FBTYPE=BUSY:20170623T070000Z/20170223T110000Z 17 | FREEBUSY;FBTYPE=BUSY:20170624T131500Z/20170316T151500Z 18 | FREEBUSY;FBTYPE=BUSY:20170715T131500Z/20170416T150000Z 19 | FREEBUSY;FBTYPE=BUSY:20170716T131500Z/20170516T100500Z 20 | END:VFREEBUSY 21 | BEGIN:VEVENT 22 | DTSTART:20171032T000000 23 | DTEND:20171101T2300 24 | DESCRIPTION:Invalid date - parser will skip the event 25 | SUMMARY:Invalid date - parser will skip the event 26 | DTSTAMP:20170406T063924 27 | LOCATION: 28 | UID:f81b0b41a2e138ae0903daee0a966e1e 29 | SEQUENCE:0 30 | END:VEVENT 31 | BEGIN:VEVENT 32 | DTSTART;VALUE=DATE;TZID=America/Los_Angeles:19410512 33 | DTEND;VALUE=DATE;TZID=America/Los_Angeles:19410512 34 | DTSTAMP;TZID=America/Los_Angeles:19410512T195741Z 35 | UID:dh3fki5du0opa7cs5n5s87ca02@google.com 36 | CREATED:20380101T141901Z 37 | DESCRIPTION;LANGUAGE=en-gb: 38 | LAST-MODIFIED:20380101T141901Z 39 | LOCATION: 40 | SEQUENCE:0 41 | STATUS:CONFIRMED 42 | SUMMARY;LANGUAGE=en-gb:Before 1970-Test: Konrad Zuse invents the Z3, the "first 43 | digital Computer" 44 | TRANSP:TRANSPARENT 45 | END:VEVENT 46 | BEGIN:VEVENT 47 | DTSTART;VALUE=DATE:20380201 48 | DTEND;VALUE=DATE:20380202 49 | DTSTAMP;TZID="GMT Standard Time":20380101T195741Z 50 | UID:dh3fki5du0opa7cs5n5s87ca01@google.com 51 | CREATED:20380101T141901Z 52 | DESCRIPTION;LANGUAGE=en-gb: 53 | LAST-MODIFIED:20380101T141901Z 54 | LOCATION: 55 | SEQUENCE:0 56 | STATUS:CONFIRMED 57 | SUMMARY;LANGUAGE=en-gb:Year 2038 problem test 58 | TRANSP:TRANSPARENT 59 | END:VEVENT 60 | BEGIN:VEVENT 61 | DTSTART:20160105T090000Z 62 | DTEND:20160107T173000Z 63 | DTSTAMP;TZID="Greenwich Mean Time:Dublin; Edinburgh; Lisbon; London":20110121T195741Z 64 | UID:15lc1nvupht8dtfiptenljoiv4@google.com 65 | CREATED:20110121T195616Z 66 | DESCRIPTION;LANGUAGE=en-gb:This is a short description\nwith a new line. Some "special" 's 67 | igns' may be interesting\, too. 68 |   And a non-breaking space. 69 | LAST-MODIFIED:20150409T150000Z 70 | LOCATION:Kansas 71 | SEQUENCE:2 72 | STATUS:CONFIRMED 73 | SUMMARY;LANGUAGE=en-gb:My Holidays 74 | TRANSP:TRANSPARENT 75 | ORGANIZER;CN="My Name":mailto:my.name@mydomain.com 76 | END:VEVENT 77 | BEGIN:VEVENT 78 | ATTENDEE;CN="Page, Larry (l.page@google.com)";ROLE=REQ-PARTICIPANT;RSVP=FALSE:mailto:l.page@google.com 79 | ATTENDEE;CN="Brin, Sergey (s.brin@google.com)";ROLE=REQ-PARTICIPANT;RSVP=TRUE:mailto:s.brin@google.com 80 | DTSTART;VALUE=DATE:20160112 81 | DTEND;VALUE=DATE:20160116 82 | DTSTAMP;TZID="GMT Standard Time":20110121T195741Z 83 | UID:1koigufm110c5hnq6ln57murd4@google.com 84 | CREATED:20110119T142901Z 85 | DESCRIPTION;LANGUAGE=en-gb:Project xyz Review Meeting Minutes\n 86 | Agenda\n1. Review of project version 1.0 requirements.\n2. 87 | Definition 88 | of project processes.\n3. Review of project schedule.\n 89 | Participants: John Smith, Jane Doe, Jim Dandy\n-It was 90 | decided that the requirements need to be signed off by 91 | product marketing.\n-Project processes were accepted.\n 92 | -Project schedule needs to account for scheduled holidays 93 | and employee vacation time. Check with HR for specific 94 | dates.\n-New schedule will be distributed by Friday.\n- 95 | Next weeks meeting is cancelled. No meeting until 3/23. 96 | LAST-MODIFIED:20150409T150000Z 97 | LOCATION: 98 | SEQUENCE:2 99 | STATUS:CONFIRMED 100 | SUMMARY;LANGUAGE=en-gb:Test 2 101 | TRANSP:TRANSPARENT 102 | END:VEVENT 103 | BEGIN:VEVENT 104 | DTSTART;VALUE=DATE:20160119 105 | DTEND;VALUE=DATE:20160120 106 | DTSTAMP;TZID="GMT Standard Time":20110121T195741Z 107 | UID:rq8jng4jgq0m1lvpj8486fttu0@google.com 108 | CREATED:20110119T141904Z 109 | DESCRIPTION;LANGUAGE=en-gb: 110 | LAST-MODIFIED:20150409T150000Z 111 | LOCATION: 112 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 113 | SEQUENCE:0 114 | STATUS:CONFIRMED 115 | SUMMARY;LANGUAGE=en-gb:DST Change 116 | TRANSP:TRANSPARENT 117 | END:VEVENT 118 | BEGIN:VEVENT 119 | DTSTART;VALUE=DATE:20160119 120 | DTEND;VALUE=DATE:20160120 121 | DTSTAMP;TZID="GMT Standard Time":20110121T195741Z 122 | UID:dh3fki5du0opa7cs5n5s87ca00@google.com 123 | CREATED:20110119T141901Z 124 | DESCRIPTION;LANGUAGE=en-gb: 125 | LAST-MODIFIED:20150409T150000Z 126 | LOCATION: 127 | RRULE:FREQ=WEEKLY;COUNT=5;INTERVAL=2;BYDAY=TU 128 | SEQUENCE:0 129 | STATUS:CONFIRMED 130 | SUMMARY;LANGUAGE=en-gb:Test 1 131 | TRANSP:TRANSPARENT 132 | END:VEVENT 133 | BEGIN:VEVENT 134 | SUMMARY:Duration Test 135 | DTSTART:20160425T150000Z 136 | DTSTAMP:20160424T150000Z 137 | DURATION:PT1H15M5S 138 | RRULE:FREQ=DAILY;COUNT=2 139 | UID:calendar-62-e7c39bf02382917349672271dd781c89 140 | END:VEVENT 141 | BEGIN:VEVENT 142 | SUMMARY:BYMONTHDAY Test 143 | DTSTART:20160922T130000Z 144 | DTEND:20160922T150000Z 145 | DTSTAMP:20160921T130000Z 146 | RRULE:FREQ=MONTHLY;UNTIL=20170923T000000Z;INTERVAL=1;BYMONTHDAY=23 147 | UID:33844fe8df15fbfc13c97fc41c0c4b00392c6870@google.com 148 | END:VEVENT 149 | BEGIN:VEVENT 150 | DTSTART;TZID=Europe/Paris:20160921T080000 151 | DTEND;TZID=Europe/Paris:20160921T090000 152 | RRULE:FREQ=WEEKLY;BYDAY=WE 153 | DTSTAMP:20161117T165045Z 154 | UID:884bc8350185031337d9ec49d2e7e101dd5ae5fb@google.com 155 | CREATED:20160920T133918Z 156 | DESCRIPTION: 157 | LAST-MODIFIED:20160920T133923Z 158 | LOCATION: 159 | SEQUENCE:1 160 | STATUS:CONFIRMED 161 | SUMMARY:Paris TimeZone Test 162 | TRANSP:OPAQUE 163 | END:VEVENT 164 | BEGIN:VEVENT 165 | DTSTART:20160215T080000Z 166 | DTEND:20160515T090000Z 167 | DTSTAMP:20161121T113027Z 168 | CREATED:20161121T113027Z 169 | UID:65323c541a30dd1f180e2bbfa2724995 170 | DESCRIPTION: 171 | LAST-MODIFIED:20161121T113027Z 172 | LOCATION: 173 | SEQUENCE:1 174 | STATUS:CONFIRMED 175 | SUMMARY:Long event covering the range from example with special chars: 176 | ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÕÖÒÓÔØÙÚÛÜÝÞß 177 | àáâãäåæçèéêėëìíîïðñòóôõöøùúûüūýþÿž 178 | ‘ ’ ‚ ‛ “ ” „ ‟ – — … 179 | TRANSP:OPAQUE 180 | END:VEVENT 181 | BEGIN:VEVENT 182 | CLASS:PUBLIC 183 | CREATED:20160706T161104Z 184 | DTEND;TZID="(UTC-05:00) Eastern Time (US & Canada)":20160409T110000 185 | DTSTAMP:20160706T150005Z 186 | DTSTART;TZID="(UTC-05:00) Eastern Time (US & Canada)":20160409T090000 187 | EXDATE;TZID="(UTC-05:00) Eastern Time (US & Canada)": 188 | 20160528T090000, 189 | 20160625T090000 190 | LAST-MODIFIED:20160707T182011Z 191 | EXDATE;TZID="(UTC-05:00) Eastern Time (US & Canada)":20160709T090000 192 | EXDATE;TZID="(UTC-05:00) Eastern Time (US & Canada)":20160723T090000 193 | LOCATION:Sanctuary 194 | PRIORITY:5 195 | RRULE:FREQ=WEEKLY;COUNT=15;BYDAY=SA 196 | SEQUENCE:0 197 | SUMMARY:Microsoft Unicode CLDR EXDATE Test 198 | TRANSP:OPAQUE 199 | UID:040000008200E00074C5B7101A82E0080000000020F6512D0B48CF0100000000000000001000000058BFB8CBB85D504CB99FBA637BCFD6BF 200 | X-MICROSOFT-CDO-BUSYSTATUS:BUSY 201 | X-MICROSOFT-CDO-IMPORTANCE:1 202 | X-MICROSOFT-DISALLOW-COUNTER:FALSE 203 | END:VEVENT 204 | BEGIN:VEVENT 205 | DTSTART;VALUE=DATE:20170118 206 | DTEND;VALUE=DATE:20170118 207 | DTSTAMP;TZID="GMT Standard Time":20170121T195741Z 208 | RRULE:FREQ=MONTHLY;BYSETPOS=3;BYDAY=WE;COUNT=5 209 | UID:4dnsuc3nknin15kv25cn7ridss@google.com 210 | CREATED:20170119T142059Z 211 | DESCRIPTION;LANGUAGE=en-gb:BYDAY Test 1 212 | LAST-MODIFIED:20170409T150000Z 213 | SEQUENCE:0 214 | STATUS:CONFIRMED 215 | SUMMARY;LANGUAGE=en-gb:BYDAY Test 1 216 | TRANSP:TRANSPARENT 217 | END:VEVENT 218 | BEGIN:VEVENT 219 | DTSTART;VALUE=DATE:20190101 220 | DTEND;VALUE=DATE:20190101 221 | DTSTAMP;TZID="GMT Standard Time":20190101T195741Z 222 | RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1 223 | UID:4dnsuc3nknin15kv25cn7ridssy@google.com 224 | CREATED:20190101T142059Z 225 | DESCRIPTION;LANGUAGE=en-gb:BYSETPOS First weekday of every month 226 | LAST-MODIFIED:20190101T150000Z 227 | SEQUENCE:0 228 | STATUS:CONFIRMED 229 | SUMMARY;LANGUAGE=en-gb:BYSETPOS First weekday of every month 230 | TRANSP:TRANSPARENT 231 | END:VEVENT 232 | BEGIN:VEVENT 233 | DTSTART;VALUE=DATE:20190131 234 | DTEND;VALUE=DATE:20190131 235 | DTSTAMP;TZID="GMT Standard Time":20190121T195741Z 236 | RRULE:FREQ=MONTHLY;INTERVAL=1;BYDAY=SU,MO,TU,WE,TH,FR,SA;BYSETPOS=-1 237 | UID:4dnsuc3nknin15kv25cn7ridssx@google.com 238 | CREATED:20190119T142059Z 239 | DESCRIPTION;LANGUAGE=en-gb:BYSETPOS Last day of every month 240 | LAST-MODIFIED:20190409T150000Z 241 | SEQUENCE:0 242 | STATUS:CONFIRMED 243 | SUMMARY;LANGUAGE=en-gb:BYSETPOS Last day of every month 244 | TRANSP:TRANSPARENT 245 | END:VEVENT 246 | BEGIN:VEVENT 247 | DTSTART;VALUE=DATE:20170301 248 | DTEND;VALUE=DATE:20170301 249 | DTSTAMP;TZID="GMT Standard Time":20170121T195741Z 250 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=WE 251 | UID:h6f7sdjbpt47v3dkral8lnsgcc@google.com 252 | CREATED:20170119T142040Z 253 | DESCRIPTION;LANGUAGE=en-gb:BYDAY Test 2 254 | LAST-MODIFIED:20170409T150000Z 255 | SEQUENCE:0 256 | STATUS:CONFIRMED 257 | SUMMARY;LANGUAGE=en-gb:BYDAY Test 2 258 | TRANSP:TRANSPARENT 259 | END:VEVENT 260 | BEGIN:VEVENT 261 | DTSTART;VALUE=DATE:20170111 262 | DTEND;VALUE=DATE:20170111 263 | DTSTAMP;TZID="GMT Standard Time":20170121T195741Z 264 | RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=5;BYMONTH=1,2,3 265 | UID:f50e8b89a4a3b0070e0b687d03@google.com 266 | CREATED:20170119T142040Z 267 | DESCRIPTION;LANGUAGE=en-gb:BYMONTH Multiple Test 1 268 | LAST-MODIFIED:20170409T150000Z 269 | SEQUENCE:0 270 | STATUS:CONFIRMED 271 | SUMMARY;LANGUAGE=en-gb:BYMONTH Multiple Test 1 272 | TRANSP:TRANSPARENT 273 | END:VEVENT 274 | BEGIN:VEVENT 275 | DTSTART;VALUE=DATE:20170405 276 | DTEND;VALUE=DATE:20170405 277 | DTSTAMP;TZID="GMT Standard Time":20170121T195741Z 278 | RRULE:FREQ=YEARLY;BYMONTH=4,5,6;BYDAY=WE;COUNT=5 279 | UID:675f06aa795665ae50904ebf0e@google.com 280 | CREATED:20170119T142040Z 281 | DESCRIPTION;LANGUAGE=en-gb:BYMONTH Multiple Test 2 282 | LAST-MODIFIED:20170409T150000Z 283 | SEQUENCE:0 284 | STATUS:CONFIRMED 285 | SUMMARY;LANGUAGE=en-gb:BYMONTH Multiple Test 2 286 | TRANSP:TRANSPARENT 287 | END:VEVENT 288 | BEGIN:VEVENT 289 | BEGIN:VALARM 290 | TRIGGER;VALUE=DURATION:-PT30M 291 | ACTION:DISPLAY 292 | DESCRIPTION:Buzz buzz 293 | END:VALARM 294 | DTSTART;VALUE=DATE;TZID=Germany/Berlin:20170123 295 | DTEND;VALUE=DATE;TZID=Germany/Berlin:20170123 296 | DTSTAMP;TZID="GMT Standard Time":20170121T195741Z 297 | RRULE:FREQ=MONTHLY;BYDAY=-2MO;COUNT=5 298 | EXDATE;VALUE=DATE:20171020 299 | UID:d287b7ec808fcf084983f10837@google.com 300 | CREATED:20170119T142040Z 301 | DESCRIPTION;LANGUAGE=en-gb:Negative BYDAY 302 | LAST-MODIFIED:20170409T150000Z 303 | SEQUENCE:0 304 | STATUS:CONFIRMED 305 | SUMMARY;LANGUAGE=en-gb:Negative BYDAY 306 | TRANSP:TRANSPARENT 307 | END:VEVENT 308 | BEGIN:VEVENT 309 | DTSTART;TZID=Australia/Sydney:20170813T190000 310 | DTEND;TZID=Australia/Sydney:20170813T213000 311 | RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=2SU;COUNT=2 312 | DTSTAMP:20170809T114431Z 313 | UID:testuid@google.com 314 | CREATED:20170802T135539Z 315 | DESCRIPTION: 316 | LAST-MODIFIED:20170802T135935Z 317 | LOCATION: 318 | SEQUENCE:1 319 | STATUS:CONFIRMED 320 | SUMMARY:Parent Recurrence Event 321 | TRANSP:OPAQUE 322 | END:VEVENT 323 | BEGIN:VEVENT 324 | DTSTART;TZID=Australia/Sydney:20170813T190000 325 | DTEND;TZID=Australia/Sydney:20170813T213000 326 | DTSTAMP:20170809T114431Z 327 | UID:testuid@google.com 328 | RECURRENCE-ID;TZID=Australia/Sydney:20170813T190000 329 | CREATED:20170802T135539Z 330 | DESCRIPTION: 331 | LAST-MODIFIED:20170809T105604Z 332 | LOCATION:Melbourne VIC\, Australia 333 | SEQUENCE:1 334 | STATUS:CONFIRMED 335 | SUMMARY:Override Parent Recurrence Event 336 | TRANSP:OPAQUE 337 | END:VEVENT 338 | END:VCALENDAR 339 | -------------------------------------------------------------------------------- /examples/index.php: -------------------------------------------------------------------------------- 1 | 2, // Default value 11 | 'defaultTimeZone' => 'UTC', 12 | 'defaultWeekStart' => 'MO', // Default value 13 | 'disableCharacterReplacement' => false, // Default value 14 | 'filterDaysAfter' => null, // Default value 15 | 'filterDaysBefore' => null, // Default value 16 | 'httpUserAgent' => null, // Default value 17 | 'skipRecurrence' => false, // Default value 18 | )); 19 | // $ical->initFile('ICal.ics'); 20 | // $ical->initUrl('https://raw.githubusercontent.com/u01jmg3/ics-parser/master/examples/ICal.ics', $username = null, $password = null, $userAgent = null); 21 | } catch (\Exception $e) { 22 | die($e); 23 | } 24 | ?> 25 | 26 | 27 | 28 | 29 | 30 | 31 | PHP ICS Parser example 32 | 33 | 34 | 35 |
36 |

PHP ICS Parser example

37 |
    38 |
  • 39 | The number of events 40 | eventCount ?> 41 |
  • 42 |
  • 43 | The number of free/busy time slots 44 | freeBusyCount ?> 45 |
  • 46 |
  • 47 | The number of todos 48 | todoCount ?> 49 |
  • 50 |
  • 51 | The number of alarms 52 | alarmCount ?> 53 |
  • 54 |
55 | 56 | true, 59 | 'range' => true, 60 | 'all' => true, 61 | ); 62 | ?> 63 | 64 | eventsFromInterval('1 week'); 67 | 68 | if ($events) { 69 | echo '

Events in the next 7 days:

'; 70 | } 71 | 72 | $count = 1; 73 | ?> 74 |
75 | 77 |
78 |
79 |
80 |

iCalDateToDateTime($event->dtstart_array[3]); 82 | echo $event->summary . ' (' . $dtstart->format('d-m-Y H:i') . ')'; 83 | ?>

84 | printData() ?> 85 |
86 |
87 |
88 | 1 && $count % 3 === 0) { 90 | echo '
'; 91 | } 92 | 93 | $count++; 94 | ?> 95 | 98 |
99 | 100 | 101 | eventsFromRange('2017-03-01 12:00:00', '2017-04-31 17:00:00'); 104 | 105 | if ($events) { 106 | echo '

Events March through April:

'; 107 | } 108 | 109 | $count = 1; 110 | ?> 111 |
112 | 114 |
115 |
116 |
117 |

iCalDateToDateTime($event->dtstart_array[3]); 119 | echo $event->summary . ' (' . $dtstart->format('d-m-Y H:i') . ')'; 120 | ?>

121 | printData() ?> 122 |
123 |
124 |
125 | 1 && $count % 3 === 0) { 127 | echo '
'; 128 | } 129 | 130 | $count++; 131 | ?> 132 | 135 |
136 | 137 | 138 | sortEventsWithOrder($ical->events()); 141 | 142 | if ($events) { 143 | echo '

All Events:

'; 144 | } 145 | ?> 146 |
147 | 150 |
151 |
152 |
153 |

iCalDateToDateTime($event->dtstart_array[3]); 155 | echo $event->summary . ' (' . $dtstart->format('d-m-Y H:i') . ')'; 156 | ?>

157 | printData() ?> 158 |
159 |
160 |
161 | 1 && $count % 3 === 0) { 163 | echo '
'; 164 | } 165 | 166 | $count++; 167 | ?> 168 | 171 |
172 | 173 |
174 | 175 | 176 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src 4 | 5 | level: 9 6 | 7 | ignoreErrors: 8 | - 9 | identifier: missingType.iterableValue 10 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | disableParallel(); 16 | 17 | $rectorConfig->importShortClasses(false); 18 | 19 | $rectorConfig->phpVersion(PhpVersion::PHP_56); 20 | 21 | $rectorConfig->skip( 22 | array( 23 | Rector\CodeQuality\Rector\Class_\CompleteDynamicPropertiesRector::class, 24 | Rector\CodeQuality\Rector\Concat\JoinStringConcatRector::class, 25 | Rector\CodeQuality\Rector\FuncCall\ChangeArrayPushToArrayAssignRector::class, 26 | Rector\CodeQuality\Rector\FuncCall\CompactToVariablesRector::class, 27 | Rector\CodeQuality\Rector\FuncCall\InlineIsAInstanceOfRector::class, 28 | Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector::class, 29 | Rector\CodeQuality\Rector\Identical\BooleanNotIdenticalToNotIdenticalRector::class, 30 | Rector\CodeQuality\Rector\Identical\SimplifyBoolIdenticalTrueRector::class, 31 | Rector\CodeQuality\Rector\If_\CombineIfRector::class, 32 | Rector\CodeQuality\Rector\If_\ExplicitBoolCompareRector::class, 33 | Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector::class, 34 | Rector\CodeQuality\Rector\If_\SimplifyIfReturnBoolRector::class, 35 | Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector::class, 36 | Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector::class, 37 | Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector::class, 38 | Rector\CodingStyle\Rector\String_\SymplifyQuoteEscapeRector::class, 39 | Rector\DeadCode\Rector\Assign\RemoveUnusedVariableAssignRector::class, 40 | Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPromotedPropertyRector::class, 41 | Rector\DeadCode\Rector\ClassMethod\RemoveUselessParamTagRector::class, 42 | Rector\DeadCode\Rector\ClassMethod\RemoveUselessReturnTagRector::class, 43 | Rector\DeadCode\Rector\StaticCall\RemoveParentCallWithoutParentRector::class, 44 | Rector\Php70\Rector\MethodCall\ThisCallOnStaticMethodToStaticCallRector::class, 45 | Rector\Php70\Rector\StaticCall\StaticCallOnNonStaticToInstanceCallRector::class, 46 | Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector::class, 47 | // PHP 5.6 incompatible 48 | Rector\CodeQuality\Rector\Ternary\ArrayKeyExistsTernaryThenValueToCoalescingRector::class, // PHP 7 49 | Rector\Php70\Rector\If_\IfToSpaceshipRector::class, 50 | Rector\Php70\Rector\Ternary\TernaryToSpaceshipRector::class, 51 | Rector\Php71\Rector\BooleanOr\IsIterableRector::class, 52 | Rector\Php71\Rector\List_\ListToArrayDestructRector::class, 53 | Rector\Php71\Rector\TryCatch\MultiExceptionCatchRector::class, 54 | Rector\Php73\Rector\FuncCall\ArrayKeyFirstLastRector::class, 55 | Rector\Php73\Rector\BooleanOr\IsCountableRector::class, 56 | Rector\Php74\Rector\Assign\NullCoalescingOperatorRector::class, 57 | Rector\Php74\Rector\StaticCall\ExportToReflectionFunctionRector::class, 58 | Rector\CodingStyle\Rector\ClassConst\RemoveFinalFromConstRector::class, // PHP 8 59 | ) 60 | ); 61 | 62 | $rectorConfig->sets( 63 | array( 64 | SetList::CODE_QUALITY, 65 | SetList::CODING_STYLE, 66 | SetList::DEAD_CODE, 67 | SetList::PHP_70, 68 | SetList::PHP_71, 69 | SetList::PHP_72, 70 | SetList::PHP_73, 71 | SetList::PHP_74, 72 | SetList::PHP_80, 73 | SetList::PHP_81, 74 | SetList::PHP_82, 75 | ) 76 | ); 77 | 78 | $rectorConfig->rule(TernaryToElvisRector::class); 79 | }; 80 | -------------------------------------------------------------------------------- /src/ICal/Event.php: -------------------------------------------------------------------------------- 1 | %s: %s

'; 10 | 11 | /** 12 | * https://www.kanzaki.com/docs/ical/summary.html 13 | * 14 | * @var string 15 | */ 16 | public $summary; 17 | 18 | /** 19 | * https://www.kanzaki.com/docs/ical/dtstart.html 20 | * 21 | * @var string 22 | */ 23 | public $dtstart; 24 | 25 | /** 26 | * https://www.kanzaki.com/docs/ical/dtend.html 27 | * 28 | * @var string 29 | */ 30 | public $dtend; 31 | 32 | /** 33 | * https://www.kanzaki.com/docs/ical/duration.html 34 | * 35 | * @var string|null 36 | */ 37 | public $duration; 38 | 39 | /** 40 | * https://www.kanzaki.com/docs/ical/dtstamp.html 41 | * 42 | * @var string 43 | */ 44 | public $dtstamp; 45 | 46 | /** 47 | * When the event starts, represented as a timezone-adjusted string 48 | * 49 | * @var string 50 | */ 51 | public $dtstart_tz; 52 | 53 | /** 54 | * When the event ends, represented as a timezone-adjusted string 55 | * 56 | * @var string 57 | */ 58 | public $dtend_tz; 59 | 60 | /** 61 | * https://www.kanzaki.com/docs/ical/uid.html 62 | * 63 | * @var string 64 | */ 65 | public $uid; 66 | 67 | /** 68 | * https://www.kanzaki.com/docs/ical/created.html 69 | * 70 | * @var string 71 | */ 72 | public $created; 73 | 74 | /** 75 | * https://www.kanzaki.com/docs/ical/lastModified.html 76 | * 77 | * @var string 78 | */ 79 | public $last_modified; 80 | 81 | /** 82 | * https://www.kanzaki.com/docs/ical/description.html 83 | * 84 | * @var string|null 85 | */ 86 | public $description; 87 | 88 | /** 89 | * https://www.kanzaki.com/docs/ical/location.html 90 | * 91 | * @var string|null 92 | */ 93 | public $location; 94 | 95 | /** 96 | * https://www.kanzaki.com/docs/ical/sequence.html 97 | * 98 | * @var string 99 | */ 100 | public $sequence; 101 | 102 | /** 103 | * https://www.kanzaki.com/docs/ical/status.html 104 | * 105 | * @var string 106 | */ 107 | public $status; 108 | 109 | /** 110 | * https://www.kanzaki.com/docs/ical/transp.html 111 | * 112 | * @var string 113 | */ 114 | public $transp; 115 | 116 | /** 117 | * https://www.kanzaki.com/docs/ical/organizer.html 118 | * 119 | * @var string 120 | */ 121 | public $organizer; 122 | 123 | /** 124 | * https://www.kanzaki.com/docs/ical/attendee.html 125 | * 126 | * @var string 127 | */ 128 | public $attendee; 129 | 130 | /** 131 | * Manage additional properties 132 | * 133 | * @var array 134 | */ 135 | public $additionalProperties = array(); 136 | 137 | /** 138 | * Creates the Event object 139 | * 140 | * @param array $data 141 | * @return void 142 | */ 143 | public function __construct(array $data = array()) 144 | { 145 | foreach ($data as $key => $value) { 146 | $variable = self::snakeCase($key); 147 | if (property_exists($this, $variable)) { 148 | $this->{$variable} = $this->prepareData($value); 149 | } else { 150 | $this->additionalProperties[$variable] = $this->prepareData($value); 151 | } 152 | } 153 | } 154 | 155 | /** 156 | * Magic getter method 157 | * 158 | * @param string $additionalPropertyName 159 | * @return mixed 160 | */ 161 | public function __get($additionalPropertyName) 162 | { 163 | if (array_key_exists($additionalPropertyName, $this->additionalProperties)) { 164 | return $this->additionalProperties[$additionalPropertyName]; 165 | } 166 | 167 | return null; 168 | } 169 | 170 | /** 171 | * Magic isset method 172 | * 173 | * @param string $name 174 | * @return boolean 175 | */ 176 | public function __isset($name) 177 | { 178 | return is_null($this->$name) === false; 179 | } 180 | 181 | /** 182 | * Prepares the data for output 183 | * 184 | * @param mixed $value 185 | * @return mixed 186 | */ 187 | protected function prepareData($value) 188 | { 189 | if (is_string($value)) { 190 | return stripslashes(trim(str_replace('\n', "\n", $value))); 191 | } 192 | 193 | if (is_array($value)) { 194 | return array_map(function ($value) { 195 | return $this->prepareData($value); 196 | }, $value); 197 | } 198 | 199 | return $value; 200 | } 201 | 202 | /** 203 | * Returns Event data excluding anything blank 204 | * within an HTML template 205 | * 206 | * @param string $html HTML template to use 207 | * @return string 208 | */ 209 | public function printData($html = self::HTML_TEMPLATE) 210 | { 211 | $data = array( 212 | 'SUMMARY' => $this->summary, 213 | 'DTSTART' => $this->dtstart, 214 | 'DTEND' => $this->dtend, 215 | 'DTSTART_TZ' => $this->dtstart_tz, 216 | 'DTEND_TZ' => $this->dtend_tz, 217 | 'DURATION' => $this->duration, 218 | 'DTSTAMP' => $this->dtstamp, 219 | 'UID' => $this->uid, 220 | 'CREATED' => $this->created, 221 | 'LAST-MODIFIED' => $this->last_modified, 222 | 'DESCRIPTION' => $this->description, 223 | 'LOCATION' => $this->location, 224 | 'SEQUENCE' => $this->sequence, 225 | 'STATUS' => $this->status, 226 | 'TRANSP' => $this->transp, 227 | 'ORGANISER' => $this->organizer, 228 | 'ATTENDEE(S)' => $this->attendee, 229 | ); 230 | 231 | // Remove any blank values 232 | $data = array_filter($data); 233 | 234 | $output = ''; 235 | 236 | foreach ($data as $key => $value) { 237 | $output .= sprintf($html, $key, $value); 238 | } 239 | 240 | return $output; 241 | } 242 | 243 | /** 244 | * Converts the given input to snake_case 245 | * 246 | * @param string $input 247 | * @param string $glue 248 | * @param string $separator 249 | * @return string 250 | */ 251 | protected static function snakeCase($input, $glue = '_', $separator = '-') 252 | { 253 | $inputSplit = preg_split('/(?<=[a-z])(?=[A-Z])/x', $input); 254 | 255 | if ($inputSplit === false) { 256 | return $input; 257 | } 258 | 259 | $inputSplit = implode($glue, $inputSplit); 260 | $inputSplit = str_replace($separator, $glue, $inputSplit); 261 | 262 | return strtolower($inputSplit); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /tests/CleanCharacterTest.php: -------------------------------------------------------------------------------- 1 | getMethod($name); 15 | 16 | // < PHP 8.1.0 17 | $method->setAccessible(true); 18 | 19 | return $method; 20 | } 21 | 22 | public function testCleanCharactersWithUnicodeCharacters() 23 | { 24 | $ical = new ICal(); 25 | 26 | self::assertSame( 27 | '...', 28 | self::getMethod('cleanCharacters')->invokeArgs($ical, array("\xe2\x80\xa6")) 29 | ); 30 | } 31 | 32 | public function testCleanCharactersWithEmojis() 33 | { 34 | $ical = new ICal(); 35 | $input = 'Test with emoji 🔴👍🏻'; 36 | 37 | self::assertSame( 38 | $input, 39 | self::getMethod('cleanCharacters')->invokeArgs($ical, array($input)) 40 | ); 41 | } 42 | 43 | public function testCleanCharactersWithWindowsCharacters() 44 | { 45 | $ical = new ICal(); 46 | $input = self::getMethod('mb_chr')->invokeArgs($ical, array(133)); 47 | 48 | self::assertSame( 49 | '...', 50 | self::getMethod('cleanCharacters')->invokeArgs($ical, array($input)) 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/DynamicPropertiesTest.php: -------------------------------------------------------------------------------- 1 | events() as $event) { 15 | $this->assertTrue(isset($event->dtstart_array)); 16 | $this->assertTrue(isset($event->dtend_array)); 17 | $this->assertTrue(isset($event->dtstamp_array)); 18 | $this->assertTrue(isset($event->uid_array)); 19 | $this->assertTrue(isset($event->created_array)); 20 | $this->assertTrue(isset($event->last_modified_array)); 21 | $this->assertTrue(isset($event->summary_array)); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/KeyValueTest.php: -------------------------------------------------------------------------------- 1 | 'ATTENDEE', 15 | 1 => array( 16 | 0 => 'mailto:julien@ag.com', 17 | 1 => array( 18 | 'PARTSTAT' => 'TENTATIVE', 19 | 'CN' => 'ju: @ag.com = Ju ; ', 20 | ), 21 | ), 22 | ); 23 | 24 | $this->assertLines( 25 | 'ATTENDEE;PARTSTAT=TENTATIVE;CN="ju: @ag.com = Ju ; ":mailto:julien@ag.com', 26 | $checks 27 | ); 28 | } 29 | 30 | public function testUtf8Characters() 31 | { 32 | $checks = array( 33 | 0 => 'ATTENDEE', 34 | 1 => array( 35 | 0 => 'mailto:juëǯ@ag.com', 36 | 1 => array( 37 | 'PARTSTAT' => 'TENTATIVE', 38 | 'CN' => 'juëǯĻ', 39 | ), 40 | ), 41 | ); 42 | 43 | $this->assertLines( 44 | 'ATTENDEE;PARTSTAT=TENTATIVE;CN=juëǯĻ:mailto:juëǯ@ag.com', 45 | $checks 46 | ); 47 | 48 | $checks = array( 49 | 0 => 'SUMMARY', 50 | 1 => ' I love emojis 😀😁😁 ë, ǯ, Ļ', 51 | ); 52 | 53 | $this->assertLines( 54 | 'SUMMARY: I love emojis 😀😁😁 ë, ǯ, Ļ', 55 | $checks 56 | ); 57 | } 58 | 59 | public function testParametersOfKeysWithMultipleValues() 60 | { 61 | $checks = array( 62 | 0 => 'ATTENDEE', 63 | 1 => array( 64 | 0 => 'mailto:jsmith@example.com', 65 | 1 => array( 66 | 'DELEGATED-TO' => array( 67 | 0 => 'mailto:jdoe@example.com', 68 | 1 => 'mailto:jqpublic@example.com', 69 | ), 70 | ), 71 | ), 72 | ); 73 | 74 | $this->assertLines( 75 | 'ATTENDEE;DELEGATED-TO="mailto:jdoe@example.com","mailto:jqpublic@example.com":mailto:jsmith@example.com', 76 | $checks 77 | ); 78 | } 79 | 80 | private function assertLines($lines, array $checks) 81 | { 82 | $ical = new ICal(); 83 | 84 | self::assertSame($ical->keyValueFromString($lines), $checks); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/RecurrencesTest.php: -------------------------------------------------------------------------------- 1 | originalTimeZone = date_default_timezone_get(); 20 | } 21 | 22 | /** 23 | * @after 24 | */ 25 | public function tearDownFixtures() 26 | { 27 | date_default_timezone_set($this->originalTimeZone); 28 | } 29 | 30 | public function testYearlyFullDayTimeZoneBerlin() 31 | { 32 | $checks = array( 33 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 34 | array('index' => 1, 'dateString' => '20010301T000000', 'message' => '2nd event, CET: '), 35 | array('index' => 2, 'dateString' => '20020301T000000', 'message' => '3rd event, CET: '), 36 | ); 37 | $this->assertVEVENT( 38 | 'Europe/Berlin', 39 | array( 40 | 'DTSTART;VALUE=DATE:20000301', 41 | 'DTEND;VALUE=DATE:20000302', 42 | 'RRULE:FREQ=YEARLY;WKST=SU;COUNT=3', 43 | ), 44 | 3, 45 | $checks 46 | ); 47 | } 48 | 49 | public function testMonthlyFullDayTimeZoneBerlin() 50 | { 51 | $checks = array( 52 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 53 | array('index' => 1, 'dateString' => '20000401T000000', 'message' => '2nd event, CEST: '), 54 | array('index' => 2, 'dateString' => '20000501T000000', 'message' => '3rd event, CEST: '), 55 | ); 56 | $this->assertVEVENT( 57 | 'Europe/Berlin', 58 | array( 59 | 'DTSTART;VALUE=DATE:20000301', 60 | 'DTEND;VALUE=DATE:20000302', 61 | 'RRULE:FREQ=MONTHLY;BYMONTHDAY=1;WKST=SU;COUNT=3', 62 | ), 63 | 3, 64 | $checks 65 | ); 66 | } 67 | 68 | public function testMonthlyFullDayTimeZoneBerlinSummerTime() 69 | { 70 | $checks = array( 71 | array('index' => 0, 'dateString' => '20180701', 'message' => '1st event, CEST: '), 72 | array('index' => 1, 'dateString' => '20180801T000000', 'message' => '2nd event, CEST: '), 73 | array('index' => 2, 'dateString' => '20180901T000000', 'message' => '3rd event, CEST: '), 74 | ); 75 | $this->assertVEVENT( 76 | 'Europe/Berlin', 77 | array( 78 | 'DTSTART;VALUE=DATE:20180701', 79 | 'DTEND;VALUE=DATE:20180702', 80 | 'RRULE:FREQ=MONTHLY;WKST=SU;COUNT=3', 81 | ), 82 | 3, 83 | $checks 84 | ); 85 | } 86 | 87 | public function testMonthlyFullDayTimeZoneBerlinFromFile() 88 | { 89 | $checks = array( 90 | array('index' => 0, 'dateString' => '20180701', 'message' => '1st event, CEST: '), 91 | array('index' => 1, 'dateString' => '20180801T000000', 'message' => '2nd event, CEST: '), 92 | array('index' => 2, 'dateString' => '20180901T000000', 'message' => '3rd event, CEST: '), 93 | ); 94 | $this->assertEventFile( 95 | 'Europe/Berlin', 96 | './tests/ical/ical-monthly.ics', 97 | 25, 98 | $checks 99 | ); 100 | } 101 | 102 | public function testIssue196FromFile() 103 | { 104 | $checks = array( 105 | array('index' => 0, 'dateString' => '20191105T190000', 'timezone' => 'Europe/Berlin', 'message' => '1st event, CEST: '), 106 | array('index' => 1, 'dateString' => '20191106T190000', 'timezone' => 'Europe/Berlin', 'message' => '2nd event, CEST: '), 107 | array('index' => 2, 'dateString' => '20191107T190000', 'timezone' => 'Europe/Berlin', 'message' => '3rd event, CEST: '), 108 | array('index' => 3, 'dateString' => '20191108T190000', 'timezone' => 'Europe/Berlin', 'message' => '4th event, CEST: '), 109 | array('index' => 4, 'dateString' => '20191109T170000', 'timezone' => 'Europe/Berlin', 'message' => '5th event, CEST: '), 110 | array('index' => 5, 'dateString' => '20191110T180000', 'timezone' => 'Europe/Berlin', 'message' => '6th event, CEST: '), 111 | ); 112 | $this->assertEventFile( 113 | 'UTC', 114 | './tests/ical/issue-196.ics', 115 | 7, 116 | $checks 117 | ); 118 | } 119 | 120 | public function testWeeklyFullDayTimeZoneBerlin() 121 | { 122 | $checks = array( 123 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 124 | array('index' => 1, 'dateString' => '20000308T000000', 'message' => '2nd event, CET: '), 125 | array('index' => 2, 'dateString' => '20000315T000000', 'message' => '3rd event, CET: '), 126 | array('index' => 3, 'dateString' => '20000322T000000', 'message' => '4th event, CET: '), 127 | array('index' => 4, 'dateString' => '20000329T000000', 'message' => '5th event, CEST: '), 128 | array('index' => 5, 'dateString' => '20000405T000000', 'message' => '6th event, CEST: '), 129 | ); 130 | $this->assertVEVENT( 131 | 'Europe/Berlin', 132 | array( 133 | 'DTSTART;VALUE=DATE:20000301', 134 | 'DTEND;VALUE=DATE:20000302', 135 | 'RRULE:FREQ=WEEKLY;WKST=SU;COUNT=6', 136 | ), 137 | 6, 138 | $checks 139 | ); 140 | } 141 | 142 | public function testDailyFullDayTimeZoneBerlin() 143 | { 144 | $checks = array( 145 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 146 | array('index' => 1, 'dateString' => '20000302T000000', 'message' => '2nd event, CET: '), 147 | array('index' => 30, 'dateString' => '20000331T000000', 'message' => '31st event, CEST: '), 148 | ); 149 | $this->assertVEVENT( 150 | 'Europe/Berlin', 151 | array( 152 | 'DTSTART;VALUE=DATE:20000301', 153 | 'DTEND;VALUE=DATE:20000302', 154 | 'RRULE:FREQ=DAILY;WKST=SU;COUNT=31', 155 | ), 156 | 31, 157 | $checks 158 | ); 159 | } 160 | 161 | public function testWeeklyFullDayTimeZoneBerlinLocal() 162 | { 163 | $checks = array( 164 | array('index' => 0, 'dateString' => '20000301T000000', 'message' => '1st event, CET: '), 165 | array('index' => 1, 'dateString' => '20000308T000000', 'message' => '2nd event, CET: '), 166 | array('index' => 2, 'dateString' => '20000315T000000', 'message' => '3rd event, CET: '), 167 | array('index' => 3, 'dateString' => '20000322T000000', 'message' => '4th event, CET: '), 168 | array('index' => 4, 'dateString' => '20000329T000000', 'message' => '5th event, CEST: '), 169 | array('index' => 5, 'dateString' => '20000405T000000', 'message' => '6th event, CEST: '), 170 | ); 171 | $this->assertVEVENT( 172 | 'Europe/Berlin', 173 | array( 174 | 'DTSTART;TZID=Europe/Berlin:20000301T000000', 175 | 'DTEND;TZID=Europe/Berlin:20000302T000000', 176 | 'RRULE:FREQ=WEEKLY;WKST=SU;COUNT=6', 177 | ), 178 | 6, 179 | $checks 180 | ); 181 | } 182 | 183 | public function testRFCDaily10NewYork() 184 | { 185 | $checks = array( 186 | array('index' => 0, 'dateString' => '19970902T090000', 'timezone' => 'America/New_York', 'message' => '1st event, EDT: '), 187 | array('index' => 1, 'dateString' => '19970903T090000', 'timezone' => 'America/New_York', 'message' => '2nd event, EDT: '), 188 | array('index' => 9, 'dateString' => '19970911T090000', 'timezone' => 'America/New_York', 'message' => '10th event, EDT: '), 189 | ); 190 | $this->assertVEVENT( 191 | 'Europe/Berlin', 192 | array( 193 | 'DTSTART;TZID=America/New_York:19970902T090000', 194 | 'RRULE:FREQ=DAILY;COUNT=10', 195 | ), 196 | 10, 197 | $checks 198 | ); 199 | } 200 | 201 | public function testRFCDaily10Berlin() 202 | { 203 | $checks = array( 204 | array('index' => 0, 'dateString' => '19970902T090000', 'timezone' => 'Europe/Berlin', 'message' => '1st event, CEST: '), 205 | array('index' => 1, 'dateString' => '19970903T090000', 'timezone' => 'Europe/Berlin', 'message' => '2nd event, CEST: '), 206 | array('index' => 9, 'dateString' => '19970911T090000', 'timezone' => 'Europe/Berlin', 'message' => '10th event, CEST: '), 207 | ); 208 | $this->assertVEVENT( 209 | 'Europe/Berlin', 210 | array( 211 | 'DTSTART;TZID=Europe/Berlin:19970902T090000', 212 | 'RRULE:FREQ=DAILY;COUNT=10', 213 | ), 214 | 10, 215 | $checks 216 | ); 217 | } 218 | 219 | public function testStartDateIsExdateUsingUntil() 220 | { 221 | $checks = array( 222 | array('index' => 0, 'dateString' => '20190918T095000', 'timezone' => 'Europe/London', 'message' => '1st event: '), 223 | array('index' => 1, 'dateString' => '20191002T095000', 'timezone' => 'Europe/London', 'message' => '2nd event: '), 224 | array('index' => 2, 'dateString' => '20191016T095000', 'timezone' => 'Europe/London', 'message' => '3rd event: '), 225 | ); 226 | $this->assertVEVENT( 227 | 'Europe/London', 228 | array( 229 | 'DTSTART;TZID=Europe/London:20190911T095000', 230 | 'RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20191027T235959Z;BYDAY=WE', 231 | 'EXDATE;TZID=Europe/London:20191023T095000', 232 | 'EXDATE;TZID=Europe/London:20191009T095000', 233 | 'EXDATE;TZID=Europe/London:20190925T095000', 234 | 'EXDATE;TZID=Europe/London:20190911T095000', 235 | ), 236 | 3, 237 | $checks 238 | ); 239 | } 240 | 241 | public function testStartDateIsExdateUsingCount() 242 | { 243 | $checks = array( 244 | array('index' => 0, 'dateString' => '20190918T095000', 'timezone' => 'Europe/London', 'message' => '1st event: '), 245 | array('index' => 1, 'dateString' => '20191002T095000', 'timezone' => 'Europe/London', 'message' => '2nd event: '), 246 | array('index' => 2, 'dateString' => '20191016T095000', 'timezone' => 'Europe/London', 'message' => '3rd event: '), 247 | ); 248 | $this->assertVEVENT( 249 | 'Europe/London', 250 | array( 251 | 'DTSTART;TZID=Europe/London:20190911T095000', 252 | 'RRULE:FREQ=WEEKLY;WKST=SU;COUNT=7;BYDAY=WE', 253 | 'EXDATE;TZID=Europe/London:20191023T095000', 254 | 'EXDATE;TZID=Europe/London:20191009T095000', 255 | 'EXDATE;TZID=Europe/London:20190925T095000', 256 | 'EXDATE;TZID=Europe/London:20190911T095000', 257 | ), 258 | 3, 259 | $checks 260 | ); 261 | } 262 | 263 | public function testCountWithExdate() 264 | { 265 | $checks = array( 266 | array('index' => 0, 'dateString' => '20200323T050000', 'timezone' => 'Europe/Paris', 'message' => '1st event: '), 267 | array('index' => 1, 'dateString' => '20200324T050000', 'timezone' => 'Europe/Paris', 'message' => '2nd event: '), 268 | array('index' => 2, 'dateString' => '20200327T050000', 'timezone' => 'Europe/Paris', 'message' => '3rd event: '), 269 | ); 270 | $this->assertVEVENT( 271 | 'Europe/London', 272 | array( 273 | 'DTSTART;TZID=Europe/Paris:20200323T050000', 274 | 'DTEND;TZID=Europe/Paris:20200323T070000', 275 | 'RRULE:FREQ=DAILY;COUNT=5', 276 | 'EXDATE;TZID=Europe/Paris:20200326T050000', 277 | 'EXDATE;TZID=Europe/Paris:20200325T050000', 278 | 'DTSTAMP:20200318T141057Z', 279 | ), 280 | 3, 281 | $checks 282 | ); 283 | } 284 | 285 | public function testRFCDaily10BerlinFromNewYork() 286 | { 287 | $checks = array( 288 | array('index' => 0, 'dateString' => '19970902T090000', 'timezone' => 'Europe/Berlin', 'message' => '1st event, CEST: '), 289 | array('index' => 1, 'dateString' => '19970903T090000', 'timezone' => 'Europe/Berlin', 'message' => '2nd event, CEST: '), 290 | array('index' => 9, 'dateString' => '19970911T090000', 'timezone' => 'Europe/Berlin', 'message' => '10th event, CEST: '), 291 | ); 292 | $this->assertVEVENT( 293 | 'America/New_York', 294 | array( 295 | 'DTSTART;TZID=Europe/Berlin:19970902T090000', 296 | 'RRULE:FREQ=DAILY;COUNT=10', 297 | ), 298 | 10, 299 | $checks 300 | ); 301 | } 302 | 303 | public function testExdatesInDifferentTimeZone() 304 | { 305 | $checks = array( 306 | array('index' => 0, 'dateString' => '20170503T190000', 'message' => '1st event: '), 307 | array('index' => 1, 'dateString' => '20170510T190000', 'message' => '2nd event: '), 308 | array('index' => 9, 'dateString' => '20170712T190000', 'message' => '10th event: '), 309 | array('index' => 19, 'dateString' => '20171004T190000', 'message' => '20th event: '), 310 | ); 311 | $this->assertVEVENT( 312 | 'America/Chicago', 313 | array( 314 | 'DTSTART;TZID=America/Chicago:20170503T190000', 315 | 'RRULE:FREQ=WEEKLY;BYDAY=WE;WKST=SU;UNTIL=20180101', 316 | 'EXDATE:20170601T000000Z', 317 | 'EXDATE:20170803T000000Z', 318 | 'EXDATE:20170824T000000Z', 319 | 'EXDATE:20171026T000000Z', 320 | 'EXDATE:20171102T000000Z', 321 | 'EXDATE:20171123T010000Z', 322 | 'EXDATE:20171221T010000Z', 323 | ), 324 | 28, 325 | $checks 326 | ); 327 | } 328 | 329 | public function testYearlyWithBySetPos() 330 | { 331 | $checks = array( 332 | array('index' => 0, 'dateString' => '19970306T090000', 'message' => '1st occurrence: '), 333 | array('index' => 1, 'dateString' => '19970313T090000', 'message' => '2nd occurrence: '), 334 | array('index' => 2, 'dateString' => '19970325T090000', 'message' => '3rd occurrence: '), 335 | array('index' => 3, 'dateString' => '19980305T090000', 'message' => '4th occurrence: '), 336 | array('index' => 4, 'dateString' => '19980312T090000', 'message' => '5th occurrence: '), 337 | array('index' => 5, 'dateString' => '19980326T090000', 'message' => '6th occurrence: '), 338 | array('index' => 9, 'dateString' => '20000307T090000', 'message' => '10th occurrence: '), 339 | ); 340 | $this->assertVEVENT( 341 | 'America/New_York', 342 | array( 343 | 'DTSTART;TZID=America/New_York:19970306T090000', 344 | 'RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=3;BYDAY=TU,TH;BYSETPOS=2,4,-2', 345 | ), 346 | 10, 347 | $checks 348 | ); 349 | } 350 | 351 | public function testDailyWithByMonthDay() 352 | { 353 | $checks = array( 354 | array('index' => 0, 'dateString' => '20000206T120000', 'message' => '1st event: '), 355 | array('index' => 1, 'dateString' => '20000211T120000', 'message' => '2nd event: '), 356 | array('index' => 2, 'dateString' => '20000216T120000', 'message' => '3rd event: '), 357 | array('index' => 4, 'dateString' => '20000226T120000', 'message' => '5th event, transition from February to March: '), 358 | array('index' => 5, 'dateString' => '20000301T120000', 'message' => '6th event, transition to March from February: '), 359 | array('index' => 11, 'dateString' => '20000331T120000', 'message' => '12th event, transition from March to April: '), 360 | array('index' => 12, 'dateString' => '20000401T120000', 'message' => '13th event, transition to April from March: '), 361 | ); 362 | $this->assertVEVENT( 363 | 'Europe/Berlin', 364 | array( 365 | 'DTSTART:20000206T120000', 366 | 'DTEND:20000206T130000', 367 | 'RRULE:FREQ=DAILY;BYMONTHDAY=1,6,11,16,21,26,31;COUNT=16', 368 | ), 369 | 16, 370 | $checks 371 | ); 372 | } 373 | 374 | public function testYearlyWithByMonthDay() 375 | { 376 | $checks = array( 377 | array('index' => 0, 'dateString' => '20001214T120000', 'message' => '1st event: '), 378 | array('index' => 1, 'dateString' => '20001221T120000', 'message' => '2nd event: '), 379 | array('index' => 2, 'dateString' => '20010107T120000', 'message' => '3rd event: '), 380 | array('index' => 3, 'dateString' => '20010114T120000', 'message' => '4th event: '), 381 | array('index' => 6, 'dateString' => '20010214T120000', 'message' => '7th event: '), 382 | ); 383 | $this->assertVEVENT( 384 | 'Europe/Berlin', 385 | array( 386 | 'DTSTART:20001214T120000', 387 | 'DTEND:20001214T130000', 388 | 'RRULE:FREQ=YEARLY;BYMONTHDAY=7,14,21;COUNT=8', 389 | ), 390 | 8, 391 | $checks 392 | ); 393 | } 394 | 395 | public function testYearlyWithByMonthDayAndByDay() 396 | { 397 | $checks = array( 398 | array('index' => 0, 'dateString' => '20001214T120000', 'message' => '1st event: '), 399 | array('index' => 1, 'dateString' => '20001221T120000', 'message' => '2nd event: '), 400 | array('index' => 2, 'dateString' => '20010607T120000', 'message' => '3rd event: '), 401 | array('index' => 3, 'dateString' => '20010614T120000', 'message' => '4th event: '), 402 | array('index' => 6, 'dateString' => '20020214T120000', 'message' => '7th event: '), 403 | ); 404 | $this->assertVEVENT( 405 | 'Europe/Berlin', 406 | array( 407 | 'DTSTART:20001214T120000', 408 | 'DTEND:20001214T130000', 409 | 'RRULE:FREQ=YEARLY;BYMONTHDAY=7,14,21;BYDAY=TH;COUNT=8', 410 | ), 411 | 8, 412 | $checks 413 | ); 414 | } 415 | 416 | public function testYearlyWithByMonthAndByMonthDay() 417 | { 418 | $checks = array( 419 | array('index' => 0, 'dateString' => '20001214T120000', 'message' => '1st event: '), 420 | array('index' => 1, 'dateString' => '20001221T120000', 'message' => '2nd event: '), 421 | array('index' => 2, 'dateString' => '20010607T120000', 'message' => '3rd event: '), 422 | array('index' => 3, 'dateString' => '20010614T120000', 'message' => '4th event: '), 423 | array('index' => 6, 'dateString' => '20011214T120000', 'message' => '7th event: '), 424 | ); 425 | $this->assertVEVENT( 426 | 'Europe/Berlin', 427 | array( 428 | 'DTSTART:20001214T120000', 429 | 'DTEND:20001214T130000', 430 | 'RRULE:FREQ=YEARLY;BYMONTH=12,6;BYMONTHDAY=7,14,21;COUNT=8', 431 | ), 432 | 8, 433 | $checks 434 | ); 435 | } 436 | 437 | public function testCountIsOne() 438 | { 439 | $checks = array( 440 | array('index' => 0, 'dateString' => '20211201T090000', 'message' => '1st and only expected event: '), 441 | ); 442 | $this->assertVEVENT( 443 | 'UTC', 444 | array( 445 | 'DTSTART:20211201T090000', 446 | 'DTEND:20211201T100000', 447 | 'RRULE:FREQ=DAILY;COUNT=1', 448 | ), 449 | 1, 450 | $checks 451 | ); 452 | } 453 | 454 | public function test5thByDayOfMonth() 455 | { 456 | $checks = array( 457 | array('index' => 0, 'dateString' => '20200103T090000', 'message' => '1st event: '), 458 | array('index' => 1, 'dateString' => '20200129T090000', 'message' => '2nd event: '), 459 | array('index' => 2, 'dateString' => '20200429T090000', 'message' => '3rd event: '), 460 | array('index' => 3, 'dateString' => '20200501T090000', 'message' => '4th event: '), 461 | array('index' => 4, 'dateString' => '20200703T090000', 'message' => '5th event: '), 462 | array('index' => 5, 'dateString' => '20200729T090000', 'message' => '6th event: '), 463 | array('index' => 6, 'dateString' => '20200930T090000', 'message' => '7th event: '), 464 | array('index' => 7, 'dateString' => '20201002T090000', 'message' => '8th event: '), 465 | array('index' => 8, 'dateString' => '20201230T090000', 'message' => '9th event: '), 466 | array('index' => 9, 'dateString' => '20210101T090000', 'message' => '10th and last event: '), 467 | ); 468 | $this->assertVEVENT( 469 | 'UTC', 470 | array( 471 | 'DTSTART:20200103T090000', 472 | 'DTEND:20200103T100000', 473 | 'RRULE:FREQ=MONTHLY;BYDAY=5WE,-5FR;UNTIL=20210102T090000', 474 | ), 475 | 10, 476 | $checks 477 | ); 478 | } 479 | 480 | public function assertVEVENT($defaultTimeZone, $veventParts, $count, $checks) 481 | { 482 | $options = $this->getOptions($defaultTimeZone); 483 | 484 | $testIcal = implode(PHP_EOL, $this->getIcalHeader()); 485 | $testIcal .= PHP_EOL; 486 | $testIcal .= implode(PHP_EOL, $this->formatIcalEvent($veventParts)); 487 | $testIcal .= PHP_EOL; 488 | $testIcal .= implode(PHP_EOL, $this->getIcalFooter()); 489 | 490 | $ical = new ICal(false, $options); 491 | $ical->initString($testIcal); 492 | 493 | $events = $ical->events(); 494 | 495 | $this->assertCount($count, $events); 496 | 497 | foreach ($checks as $check) { 498 | $this->assertEvent($events[$check['index']], $check['dateString'], $check['message'], isset($check['timezone']) ? $check['timezone'] : $defaultTimeZone); 499 | } 500 | } 501 | 502 | public function assertEventFile($defaultTimeZone, $file, $count, $checks) 503 | { 504 | $options = $this->getOptions($defaultTimeZone); 505 | 506 | $ical = new ICal($file, $options); 507 | 508 | $events = $ical->events(); 509 | 510 | $this->assertCount($count, $events); 511 | 512 | $events = $ical->sortEventsWithOrder($events); 513 | 514 | foreach ($checks as $check) { 515 | $this->assertEvent($events[$check['index']], $check['dateString'], $check['message'], isset($check['timezone']) ? $check['timezone'] : $defaultTimeZone); 516 | } 517 | } 518 | 519 | public function assertEvent($event, $expectedDateString, $message, $timeZone = null) 520 | { 521 | if (!is_null($timeZone)) { 522 | date_default_timezone_set($timeZone); 523 | } 524 | 525 | $expectedTimeStamp = strtotime($expectedDateString); 526 | 527 | $this->assertSame($expectedTimeStamp, $event->dtstart_array[2], $message . 'timestamp mismatch (expected ' . $expectedDateString . ' vs actual ' . $event->dtstart . ')'); 528 | $this->assertSame($expectedDateString, $event->dtstart, $message . 'dtstart mismatch (timestamp is okay)'); 529 | } 530 | 531 | public function getOptions($defaultTimeZone) 532 | { 533 | $options = array( 534 | 'defaultSpan' => 2, // Default value 535 | 'defaultTimeZone' => $defaultTimeZone, // Default value: UTC 536 | 'defaultWeekStart' => 'MO', // Default value 537 | 'disableCharacterReplacement' => false, // Default value 538 | 'filterDaysAfter' => null, // Default value 539 | 'filterDaysBefore' => null, // Default value 540 | 'httpUserAgent' => null, // Default value 541 | 'skipRecurrence' => false, // Default value 542 | ); 543 | 544 | return $options; 545 | } 546 | 547 | public function formatIcalEvent($veventParts) 548 | { 549 | return array_merge( 550 | array( 551 | 'BEGIN:VEVENT', 552 | 'CREATED:' . gmdate('Ymd\THis\Z'), 553 | 'UID:M2CD-1-1-5FB000FB-BBE4-4F3F-9E7E-217F1FF97209', 554 | ), 555 | $veventParts, 556 | array( 557 | 'SUMMARY:test', 558 | 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z', filemtime(__FILE__)), 559 | 'END:VEVENT', 560 | ) 561 | ); 562 | } 563 | 564 | public function getIcalHeader() 565 | { 566 | return array( 567 | 'BEGIN:VCALENDAR', 568 | 'VERSION:2.0', 569 | 'PRODID:-//Google Inc//Google Calendar 70.9054//EN', 570 | 'X-WR-CALNAME:Private', 571 | 'X-APPLE-CALENDAR-COLOR:#FF2968', 572 | 'X-WR-CALDESC:', 573 | ); 574 | } 575 | 576 | public function getIcalFooter() 577 | { 578 | return array('END:VCALENDAR'); 579 | } 580 | } 581 | -------------------------------------------------------------------------------- /tests/Rfc5545RecurrenceTest.php: -------------------------------------------------------------------------------- 1 | originalTimeZone = date_default_timezone_get(); 62 | } 63 | 64 | /** 65 | * @after 66 | */ 67 | public function tearDownFixtures() 68 | { 69 | date_default_timezone_set($this->originalTimeZone); 70 | } 71 | 72 | // Page 123, Test 1 :: Daily, 10 Occurrences 73 | public function test_page123_test1() 74 | { 75 | $checks = array( 76 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 77 | array('index' => 1, 'dateString' => '19970903T090000', 'message' => '2nd occurrence: '), 78 | array('index' => 2, 'dateString' => '19970904T090000', 'message' => '3rd occurrence: '), 79 | ); 80 | $this->assertVEVENT( 81 | 'America/New_York', 82 | array( 83 | 'DTSTART;TZID=America/New_York:19970902T090000', 84 | 'RRULE:FREQ=DAILY;COUNT=10', 85 | ), 86 | 10, 87 | $checks 88 | ); 89 | } 90 | 91 | // Page 123, Test 2 :: Daily, until December 24th 92 | public function test_page123_test2() 93 | { 94 | $checks = array( 95 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 96 | array('index' => 1, 'dateString' => '19970903T090000', 'message' => '2nd occurrence: '), 97 | array('index' => 2, 'dateString' => '19970904T090000', 'message' => '3rd occurrence: '), 98 | ); 99 | $this->assertVEVENT( 100 | 'America/New_York', 101 | array( 102 | 'DTSTART;TZID=America/New_York:19970902T090000', 103 | 'RRULE:FREQ=DAILY;UNTIL=19971224T000000Z', 104 | ), 105 | 113, 106 | $checks 107 | ); 108 | } 109 | 110 | // Page 123, Test 3 :: Daily, until December 24th, with trailing semicolon 111 | public function test_page123_test3() 112 | { 113 | $checks = array( 114 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 115 | array('index' => 1, 'dateString' => '19970903T090000', 'message' => '2nd occurrence: '), 116 | array('index' => 2, 'dateString' => '19970904T090000', 'message' => '3rd occurrence: '), 117 | ); 118 | $this->assertVEVENT( 119 | 'America/New_York', 120 | array( 121 | 'DTSTART;TZID=America/New_York:19970902T090000', 122 | 'RRULE:FREQ=DAILY;UNTIL=19971224T000000Z;', 123 | ), 124 | 113, 125 | $checks 126 | ); 127 | } 128 | 129 | // Page 124, Test 1 :: Daily, every other day, Forever 130 | // 131 | // UNTIL rule does not exist in original example 132 | public function test_page124_test1() 133 | { 134 | $checks = array( 135 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 136 | array('index' => 1, 'dateString' => '19970904T090000', 'message' => '2nd occurrence: '), 137 | array('index' => 2, 'dateString' => '19970906T090000', 'message' => '3rd occurrence: '), 138 | ); 139 | $this->assertVEVENT( 140 | 'America/New_York', 141 | array( 142 | 'DTSTART;TZID=America/New_York:19970902T090000', 143 | 'RRULE:FREQ=DAILY;INTERVAL=2;UNTIL=19971201Z', 144 | ), 145 | 45, 146 | $checks 147 | ); 148 | } 149 | 150 | // Page 124, Test 2 :: Daily, 10-day intervals, 5 occurrences 151 | public function test_page124_test2() 152 | { 153 | $checks = array( 154 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 155 | array('index' => 1, 'dateString' => '19970912T090000', 'message' => '2nd occurrence: '), 156 | array('index' => 2, 'dateString' => '19970922T090000', 'message' => '3rd occurrence: '), 157 | ); 158 | $this->assertVEVENT( 159 | 'America/New_York', 160 | array( 161 | 'DTSTART;TZID=America/New_York:19970902T090000', 162 | 'RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5', 163 | ), 164 | 5, 165 | $checks 166 | ); 167 | } 168 | 169 | // Page 124, Test 3a :: Every January day, for 3 years (Variant A) 170 | public function test_page124_test3a() 171 | { 172 | $checks = array( 173 | array('index' => 0, 'dateString' => '19980101T090000', 'message' => '1st occurrence: '), 174 | array('index' => 1, 'dateString' => '19980102T090000', 'message' => '2nd occurrence: '), 175 | array('index' => 2, 'dateString' => '19980103T090000', 'message' => '3rd occurrence: '), 176 | ); 177 | $this->assertVEVENT( 178 | 'America/New_York', 179 | array( 180 | 'DTSTART;TZID=America/New_York:19980101T090000', 181 | 'RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA', 182 | ), 183 | 93, 184 | $checks 185 | ); 186 | } 187 | 188 | /* Requires support for BYMONTH under DAILY [No ticket] 189 | * 190 | // Page 124, Test 3b :: Every January day, for 3 years (Variant B) 191 | public function test_page124_test3b() 192 | { 193 | $checks = array( 194 | array('index' => 0, 'dateString' => '19980101T090000', 'message' => '1st occurrence: '), 195 | array('index' => 1, 'dateString' => '19980102T090000', 'message' => '2nd occurrence: '), 196 | array('index' => 2, 'dateString' => '19980103T090000', 'message' => '3rd occurrence: '), 197 | ); 198 | $this->assertVEVENT( 199 | 'America/New_York', 200 | array( 201 | 'DTSTART;TZID=America/New_York:19980101T090000', 202 | 'RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1', 203 | ), 204 | 93, 205 | $checks 206 | ); 207 | } 208 | */ 209 | 210 | // Page 124, Test 4 :: Weekly, 10 occurrences 211 | public function test_page124_test4() 212 | { 213 | $checks = array( 214 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 215 | array('index' => 1, 'dateString' => '19970909T090000', 'message' => '2nd occurrence: '), 216 | array('index' => 2, 'dateString' => '19970916T090000', 'message' => '3rd occurrence: '), 217 | ); 218 | $this->assertVEVENT( 219 | 'America/New_York', 220 | array( 221 | 'DTSTART;TZID=America/New_York:19970902T090000', 222 | 'RRULE:FREQ=WEEKLY;COUNT=10', 223 | ), 224 | 10, 225 | $checks 226 | ); 227 | } 228 | 229 | // Page 125, Test 1 :: Weekly, until December 24th 230 | public function test_page125_test1() 231 | { 232 | $checks = array( 233 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 234 | array('index' => 1, 'dateString' => '19970909T090000', 'message' => '2nd occurrence: '), 235 | array('index' => 2, 'dateString' => '19970916T090000', 'message' => '3rd occurrence: '), 236 | array('index' => 16, 'dateString' => '19971223T090000', 'message' => 'last occurrence: '), 237 | ); 238 | $this->assertVEVENT( 239 | 'America/New_York', 240 | array( 241 | 'DTSTART;TZID=America/New_York:19970902T090000', 242 | 'RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z', 243 | ), 244 | 17, 245 | $checks 246 | ); 247 | } 248 | 249 | // Page 125, Test 2 :: Every other week, forever 250 | // 251 | // UNTIL rule does not exist in original example 252 | public function test_page125_test2() 253 | { 254 | $checks = array( 255 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 256 | array('index' => 1, 'dateString' => '19970916T090000', 'message' => '2nd occurrence: '), 257 | array('index' => 2, 'dateString' => '19970930T090000', 'message' => '3rd occurrence: '), 258 | array('index' => 3, 'dateString' => '19971014T090000', 'message' => '4th occurrence: '), 259 | array('index' => 4, 'dateString' => '19971028T090000', 'message' => '5th occurrence: '), 260 | array('index' => 5, 'dateString' => '19971111T090000', 'message' => '6th occurrence: '), 261 | array('index' => 6, 'dateString' => '19971125T090000', 'message' => '7th occurrence: '), 262 | ); 263 | $this->assertVEVENT( 264 | 'America/New_York', 265 | array( 266 | 'DTSTART;TZID=America/New_York:19970902T090000', 267 | 'RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU;UNTIL=19971201Z', 268 | ), 269 | 7, 270 | $checks 271 | ); 272 | } 273 | 274 | // Page 125, Test 3a :: Tuesday & Thursday every week, for five weeks (Variant A) 275 | public function test_page125_test3a() 276 | { 277 | $checks = array( 278 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 279 | array('index' => 1, 'dateString' => '19970904T090000', 'message' => '2nd occurrence: '), 280 | array('index' => 2, 'dateString' => '19970909T090000', 'message' => '3rd occurrence: '), 281 | array('index' => 9, 'dateString' => '19971002T090000', 'message' => 'final occurrence: '), 282 | ); 283 | $this->assertVEVENT( 284 | 'America/New_York', 285 | array( 286 | 'DTSTART;TZID=America/New_York:19970902T090000', 287 | 'RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH', 288 | ), 289 | 10, 290 | $checks 291 | ); 292 | } 293 | 294 | // Page 125, Test 3b :: Tuesday & Thursday every week, for five weeks (Variant B) 295 | public function test_page125_test3b() 296 | { 297 | $checks = array( 298 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 299 | array('index' => 1, 'dateString' => '19970904T090000', 'message' => '2nd occurrence: '), 300 | array('index' => 2, 'dateString' => '19970909T090000', 'message' => '3rd occurrence: '), 301 | array('index' => 9, 'dateString' => '19971002T090000', 'message' => 'final occurrence: '), 302 | ); 303 | $this->assertVEVENT( 304 | 'America/New_York', 305 | array( 306 | 'DTSTART;TZID=America/New_York:19970902T090000', 307 | 'RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH', 308 | ), 309 | 10, 310 | $checks 311 | ); 312 | } 313 | 314 | // Page 125, Test 4 :: Monday, Wednesday & Friday of every other week until December 24th 315 | public function test_page125_test4() 316 | { 317 | $checks = array( 318 | array('index' => 0, 'dateString' => '19970901T090000', 'message' => '1st occurrence: '), 319 | array('index' => 1, 'dateString' => '19970903T090000', 'message' => '2nd occurrence: '), 320 | array('index' => 2, 'dateString' => '19970905T090000', 'message' => '3rd occurrence: '), 321 | array('index' => 24, 'dateString' => '19971222T090000', 'message' => 'final occurrence: '), 322 | ); 323 | $this->assertVEVENT( 324 | 'America/New_York', 325 | array( 326 | 'DTSTART;TZID=America/New_York:19970901T090000', 327 | 'RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR', 328 | ), 329 | 25, 330 | $checks 331 | ); 332 | } 333 | 334 | // Page 126, Test 1 :: Tuesday & Thursday, every other week, for 8 occurrences 335 | public function test_page126_test1() 336 | { 337 | $checks = array( 338 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 339 | array('index' => 1, 'dateString' => '19970904T090000', 'message' => '2nd occurrence: '), 340 | array('index' => 2, 'dateString' => '19970916T090000', 'message' => '3rd occurrence: '), 341 | ); 342 | $this->assertVEVENT( 343 | 'America/New_York', 344 | array( 345 | 'DTSTART;TZID=America/New_York:19970902T090000', 346 | 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH', 347 | ), 348 | 8, 349 | $checks 350 | ); 351 | } 352 | 353 | // Page 126, Test 2 :: First Friday of the Month, for 10 occurrences 354 | public function test_page126_test2() 355 | { 356 | $checks = array( 357 | array('index' => 0, 'dateString' => '19970905T090000', 'message' => '1st occurrence: '), 358 | array('index' => 1, 'dateString' => '19971003T090000', 'message' => '2nd occurrence: '), 359 | array('index' => 2, 'dateString' => '19971107T090000', 'message' => '3rd occurrence: '), 360 | ); 361 | $this->assertVEVENT( 362 | 'America/New_York', 363 | array( 364 | 'DTSTART;TZID=America/New_York:19970905T090000', 365 | 'RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR', 366 | ), 367 | 10, 368 | $checks 369 | ); 370 | } 371 | 372 | // Page 126, Test 3 :: First Friday of the Month, until 24th December 373 | public function test_page126_test3() 374 | { 375 | $checks = array( 376 | array('index' => 0, 'dateString' => '19970905T090000', 'message' => '1st occurrence: '), 377 | array('index' => 1, 'dateString' => '19971003T090000', 'message' => '2nd occurrence: '), 378 | array('index' => 2, 'dateString' => '19971107T090000', 'message' => '3rd occurrence: '), 379 | ); 380 | $this->assertVEVENT( 381 | 'America/New_York', 382 | array( 383 | 'DTSTART;TZID=America/New_York:19970905T090000', 384 | 'RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR', 385 | ), 386 | 4, 387 | $checks 388 | ); 389 | } 390 | 391 | // Page 126, Test 4 :: First and last Sunday, every other Month, for 10 occurrences 392 | public function test_page126_test4() 393 | { 394 | $checks = array( 395 | array('index' => 0, 'dateString' => '19970907T090000', 'message' => '1st occurrence: '), 396 | array('index' => 1, 'dateString' => '19970928T090000', 'message' => '2nd occurrence: '), 397 | array('index' => 2, 'dateString' => '19971102T090000', 'message' => '3rd occurrence: '), 398 | array('index' => 3, 'dateString' => '19971130T090000', 'message' => '4th occurrence: '), 399 | array('index' => 4, 'dateString' => '19980104T090000', 'message' => '5th occurrence: '), 400 | array('index' => 5, 'dateString' => '19980125T090000', 'message' => '6th occurrence: '), 401 | ); 402 | $this->assertVEVENT( 403 | 'America/New_York', 404 | array( 405 | 'DTSTART;TZID=America/New_York:19970907T090000', 406 | 'RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU', 407 | ), 408 | 10, 409 | $checks 410 | ); 411 | } 412 | 413 | // Page 126, Test 5 :: Second-to-last Monday of the Month, for six months 414 | public function test_page126_test5() 415 | { 416 | $checks = array( 417 | array('index' => 0, 'dateString' => '19970922T090000', 'message' => '1st occurrence: '), 418 | array('index' => 1, 'dateString' => '19971020T090000', 'message' => '2nd occurrence: '), 419 | array('index' => 2, 'dateString' => '19971117T090000', 'message' => '3rd occurrence: '), 420 | ); 421 | $this->assertVEVENT( 422 | 'America/New_York', 423 | array( 424 | 'DTSTART;TZID=America/New_York:19970922T090000', 425 | 'RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO', 426 | ), 427 | 6, 428 | $checks 429 | ); 430 | } 431 | 432 | // Page 127, Test 1 :: Third-to-last day of the month, forever 433 | // 434 | // UNTIL rule does not exist in original example. 435 | public function test_page127_test1() 436 | { 437 | $checks = array( 438 | array('index' => 0, 'dateString' => '19970928T090000', 'message' => '1st occurrence: '), 439 | array('index' => 1, 'dateString' => '19971029T090000', 'message' => '2nd occurrence: '), 440 | array('index' => 2, 'dateString' => '19971128T090000', 'message' => '3rd occurrence: '), 441 | array('index' => 3, 'dateString' => '19971229T090000', 'message' => '4th occurrence: '), 442 | array('index' => 4, 'dateString' => '19980129T090000', 'message' => '5th occurrence: '), 443 | array('index' => 5, 'dateString' => '19980226T090000', 'message' => '6th occurrence: '), 444 | ); 445 | $this->assertVEVENT( 446 | 'America/New_York', 447 | array( 448 | 'DTSTART;TZID=America/New_York:19970928T090000', 449 | 'RRULE:FREQ=MONTHLY;BYMONTHDAY=-3;UNTIL=19980401', 450 | ), 451 | 7, 452 | $checks 453 | ); 454 | } 455 | 456 | // Page 127, Test 2 :: 2nd and 15th of each Month, for 10 occurrences 457 | public function test_page127_test2() 458 | { 459 | $checks = array( 460 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 461 | array('index' => 1, 'dateString' => '19970915T090000', 'message' => '2nd occurrence: '), 462 | array('index' => 2, 'dateString' => '19971002T090000', 'message' => '3rd occurrence: '), 463 | array('index' => 3, 'dateString' => '19971015T090000', 'message' => '4th occurrence: '), 464 | ); 465 | $this->assertVEVENT( 466 | 'America/New_York', 467 | array( 468 | 'DTSTART;TZID=America/New_York:19970902T090000', 469 | 'RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15', 470 | ), 471 | 10, 472 | $checks 473 | ); 474 | } 475 | 476 | // Page 127, Test 3 :: First and last day of the month, for 10 occurrences 477 | public function test_page127_test3() 478 | { 479 | $checks = array( 480 | array('index' => 0, 'dateString' => '19970930T090000', 'message' => '1st occurrence: '), 481 | array('index' => 1, 'dateString' => '19971001T090000', 'message' => '2nd occurrence: '), 482 | array('index' => 2, 'dateString' => '19971031T090000', 'message' => '3rd occurrence: '), 483 | array('index' => 3, 'dateString' => '19971101T090000', 'message' => '4th occurrence: '), 484 | array('index' => 4, 'dateString' => '19971130T090000', 'message' => '5th occurrence: '), 485 | ); 486 | $this->assertVEVENT( 487 | 'America/New_York', 488 | array( 489 | 'DTSTART;TZID=America/New_York:19970930T090000', 490 | 'RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1', 491 | ), 492 | 10, 493 | $checks 494 | ); 495 | } 496 | 497 | // Page 127, Test 4 :: 10th through 15th, every 18 months, for 10 occurrences 498 | public function test_page127_test4() 499 | { 500 | $checks = array( 501 | array('index' => 0, 'dateString' => '19970910T090000', 'message' => '1st occurrence: '), 502 | array('index' => 1, 'dateString' => '19970911T090000', 'message' => '2nd occurrence: '), 503 | array('index' => 2, 'dateString' => '19970912T090000', 'message' => '3rd occurrence: '), 504 | array('index' => 6, 'dateString' => '19990310T090000', 'message' => '7th occurrence: '), 505 | ); 506 | $this->assertVEVENT( 507 | 'America/New_York', 508 | array( 509 | 'DTSTART;TZID=America/New_York:19970910T090000', 510 | 'RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15', 511 | ), 512 | 10, 513 | $checks 514 | ); 515 | } 516 | 517 | // Page 127, Test 5 :: Every Tuesday, every other Month, forever 518 | // 519 | // UNTIL rule does not exist in original example. 520 | public function test_page127_test5() 521 | { 522 | $checks = array( 523 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 524 | array('index' => 1, 'dateString' => '19970909T090000', 'message' => '2nd occurrence: '), 525 | array('index' => 2, 'dateString' => '19970916T090000', 'message' => '3rd occurrence: '), 526 | ); 527 | $this->assertVEVENT( 528 | 'America/New_York', 529 | array( 530 | 'DTSTART;TZID=America/New_York:19970902T090000', 531 | 'RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU;UNTIL=19980101', 532 | ), 533 | 9, 534 | $checks 535 | ); 536 | } 537 | 538 | // Page 128, Test 1 :: June & July of each Year, for 10 occurrences 539 | public function test_page128_test1() 540 | { 541 | $checks = array( 542 | array('index' => 0, 'dateString' => '19970610T090000', 'message' => '1st occurrence: '), 543 | array('index' => 1, 'dateString' => '19970710T090000', 'message' => '2nd occurrence: '), 544 | array('index' => 2, 'dateString' => '19980610T090000', 'message' => '3rd occurrence: '), 545 | ); 546 | $this->assertVEVENT( 547 | 'America/New_York', 548 | array( 549 | 'DTSTART;TZID=America/New_York:19970610T090000', 550 | 'RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7', 551 | ), 552 | 10, 553 | $checks 554 | ); 555 | } 556 | 557 | // Page 128, Test 2 :: January, February, & March, every other Year, for 10 occurrences 558 | public function test_page128_test2() 559 | { 560 | $checks = array( 561 | array('index' => 0, 'dateString' => '19970310T090000', 'message' => '1st occurrence: '), 562 | array('index' => 1, 'dateString' => '19990110T090000', 'message' => '2nd occurrence: '), 563 | array('index' => 2, 'dateString' => '19990210T090000', 'message' => '3rd occurrence: '), 564 | ); 565 | $this->assertVEVENT( 566 | 'America/New_York', 567 | array( 568 | 'DTSTART;TZID=America/New_York:19970310T090000', 569 | 'RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3', 570 | ), 571 | 10, 572 | $checks 573 | ); 574 | } 575 | 576 | // Page 128, Test 3 :: Every third Year on the 1st, 100th, & 200th day for 10 occurrences 577 | public function test_page128_test3() 578 | { 579 | $checks = array( 580 | array('index' => 0, 'dateString' => '19970101T090000', 'message' => '1st occurrence: '), 581 | array('index' => 1, 'dateString' => '19970410T090000', 'message' => '2nd occurrence: '), 582 | array('index' => 2, 'dateString' => '19970719T090000', 'message' => '3rd occurrence: '), 583 | ); 584 | $this->assertVEVENT( 585 | 'America/New_York', 586 | array( 587 | 'DTSTART;TZID=America/New_York:19970101T090000', 588 | 'RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200', 589 | ), 590 | 10, 591 | $checks 592 | ); 593 | } 594 | 595 | // Page 128, Test 4 :: 20th Monday of a Year, forever 596 | // 597 | // COUNT rule does not exist in original example. 598 | public function test_page128_test4() 599 | { 600 | $checks = array( 601 | array('index' => 0, 'dateString' => '19970519T090000', 'message' => '1st occurrence: '), 602 | array('index' => 1, 'dateString' => '19980518T090000', 'message' => '2nd occurrence: '), 603 | array('index' => 2, 'dateString' => '19990517T090000', 'message' => '3rd occurrence: '), 604 | ); 605 | $this->assertVEVENT( 606 | 'America/New_York', 607 | array( 608 | 'DTSTART;TZID=America/New_York:19970519T090000', 609 | 'RRULE:FREQ=YEARLY;BYDAY=20MO;COUNT=4', 610 | ), 611 | 4, 612 | $checks 613 | ); 614 | } 615 | 616 | // Page 129, Test 1 :: Monday of Week 20, where the default start of the week is Monday, forever 617 | // 618 | // COUNT rule does not exist in original example. 619 | public function test_page129_test1() 620 | { 621 | $checks = array( 622 | array('index' => 0, 'dateString' => '19970512T090000', 'message' => '1st occurrence: '), 623 | array('index' => 1, 'dateString' => '19980511T090000', 'message' => '2nd occurrence: '), 624 | array('index' => 2, 'dateString' => '19990517T090000', 'message' => '3rd occurrence: '), 625 | ); 626 | $this->assertVEVENT( 627 | 'America/New_York', 628 | array( 629 | 'DTSTART;TZID=America/New_York:19970512T090000', 630 | 'RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO;COUNT=4', 631 | ), 632 | 4, 633 | $checks 634 | ); 635 | } 636 | 637 | // Page 129, Test 2 :: Every Thursday in March, forever 638 | // 639 | // UNTIL rule does not exist in original example. 640 | public function test_page129_test2() 641 | { 642 | $checks = array( 643 | array('index' => 0, 'dateString' => '19970313T090000', 'message' => '1st occurrence: '), 644 | array('index' => 1, 'dateString' => '19970320T090000', 'message' => '2nd occurrence: '), 645 | array('index' => 2, 'dateString' => '19970327T090000', 'message' => '3rd occurrence: '), 646 | ); 647 | $this->assertVEVENT( 648 | 'America/New_York', 649 | array( 650 | 'DTSTART;TZID=America/New_York:19970313T090000', 651 | 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH;UNTIL=19990401Z', 652 | ), 653 | 11, 654 | $checks 655 | ); 656 | } 657 | 658 | // Page 129, Test 3 :: Every Thursday in June, July, & August, forever 659 | // 660 | // UNTIL rule does not exist in original example. 661 | public function test_page129_test3() 662 | { 663 | $checks = array( 664 | array('index' => 0, 'dateString' => '19970605T090000', 'message' => '1st occurrence: '), 665 | array('index' => 1, 'dateString' => '19970612T090000', 'message' => '2nd occurrence: '), 666 | array('index' => 2, 'dateString' => '19970619T090000', 'message' => '3rd occurrence: '), 667 | ); 668 | $this->assertVEVENT( 669 | 'America/New_York', 670 | array( 671 | 'DTSTART;TZID=America/New_York:19970605T090000', 672 | 'RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8;UNTIL=19970901Z', 673 | ), 674 | 13, 675 | $checks 676 | ); 677 | } 678 | 679 | /* Requires support for BYMONTHDAY and BYDAY in the same MONTHLY RRULE [No ticket] 680 | * 681 | // Page 129, Test 4 :: Every Friday 13th, forever 682 | // 683 | // COUNT rule does not exist in original example. 684 | public function test_page129_test4() 685 | { 686 | $checks = array( 687 | array('index' => 0, 'dateString' => '19980213T090000', 'message' => '1st occurrence: '), 688 | array('index' => 1, 'dateString' => '19980313T090000', 'message' => '2nd occurrence: '), 689 | array('index' => 2, 'dateString' => '19981113T090000', 'message' => '3rd occurrence: '), 690 | array('index' => 3, 'dateString' => '19990813T090000', 'message' => '4th occurrence: '), 691 | array('index' => 4, 'dateString' => '20001013T090000', 'message' => '5th occurrence: '), 692 | ); 693 | $this->assertVEVENT( 694 | 'America/New_York', 695 | array( 696 | 'DTSTART;TZID=America/New_York:19970902T090000', 697 | 'EXDATE;TZID=America/New_York:19970902T090000', 698 | 'RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13;COUNT=5', 699 | ), 700 | 5, 701 | $checks 702 | ); 703 | } 704 | */ 705 | 706 | // Page 130, Test 1 :: The first Saturday that follows the first Sunday of the month, forever: 707 | // 708 | // COUNT rule does not exist in original example. 709 | public function test_page130_test1() 710 | { 711 | $checks = array( 712 | array('index' => 0, 'dateString' => '19970913T090000', 'message' => '1st occurrence: '), 713 | array('index' => 1, 'dateString' => '19971011T090000', 'message' => '2nd occurrence: '), 714 | array('index' => 2, 'dateString' => '19971108T090000', 'message' => '3rd occurrence: '), 715 | ); 716 | $this->assertVEVENT( 717 | 'America/New_York', 718 | array( 719 | 'DTSTART;TZID=America/New_York:19970913T090000', 720 | 'RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13;COUNT=7', 721 | ), 722 | 7, 723 | $checks 724 | ); 725 | } 726 | 727 | // Page 130, Test 2 :: The first Tuesday after a Monday in November, every 4 Years (U.S. Presidential Election Day), forever 728 | // 729 | // COUNT rule does not exist in original example. 730 | public function test_page130_test2() 731 | { 732 | $checks = array( 733 | array('index' => 0, 'dateString' => '19961105T090000', 'message' => '1st occurrence: '), 734 | array('index' => 1, 'dateString' => '20001107T090000', 'message' => '2nd occurrence: '), 735 | array('index' => 2, 'dateString' => '20041102T090000', 'message' => '3rd occurrence: '), 736 | ); 737 | $this->assertVEVENT( 738 | 'America/New_York', 739 | array( 740 | 'DTSTART;TZID=America/New_York:19961105T090000', 741 | 'RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8;COUNT=4', 742 | ), 743 | 4, 744 | $checks 745 | ); 746 | } 747 | 748 | // Page 130, Test 3 :: Third instance of either a Tuesday, Wednesday, or Thursday of a Month, for 3 months. 749 | public function test_page130_test3() 750 | { 751 | $checks = array( 752 | array('index' => 0, 'dateString' => '19970904T090000', 'message' => '1st occurrence: '), 753 | array('index' => 1, 'dateString' => '19971007T090000', 'message' => '2nd occurrence: '), 754 | array('index' => 2, 'dateString' => '19971106T090000', 'message' => '3rd occurrence: '), 755 | ); 756 | $this->assertVEVENT( 757 | 'America/New_York', 758 | array( 759 | 'DTSTART;TZID=America/New_York:19970904T090000', 760 | 'RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3', 761 | ), 762 | 3, 763 | $checks 764 | ); 765 | } 766 | 767 | // Page 130, Test 4 :: Second-to-last weekday of the month, indefinitely 768 | // 769 | // UNTIL rule does not exist in original example. 770 | public function test_page130_test4() 771 | { 772 | $checks = array( 773 | array('index' => 0, 'dateString' => '19970929T090000', 'message' => '1st occurrence: '), 774 | array('index' => 1, 'dateString' => '19971030T090000', 'message' => '2nd occurrence: '), 775 | array('index' => 2, 'dateString' => '19971127T090000', 'message' => '3rd occurrence: '), 776 | array('index' => 3, 'dateString' => '19971230T090000', 'message' => '4th occurrence: '), 777 | ); 778 | $this->assertVEVENT( 779 | 'America/New_York', 780 | array( 781 | 'DTSTART;TZID=America/New_York:19970929T090000', 782 | 'RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2;UNTIL=19980101', 783 | ), 784 | 4, 785 | $checks 786 | ); 787 | } 788 | 789 | /* Requires support of HOURLY frequency [#101] 790 | * 791 | // Page 131, Test 1 :: Every 3 hours from 09:00 to 17:00 on a specific day 792 | public function test_page131_test1() 793 | { 794 | $checks = array( 795 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 796 | array('index' => 1, 'dateString' => '19970902T120000', 'message' => '2nd occurrence: '), 797 | array('index' => 2, 'dateString' => '19970902T150000', 'message' => '3rd occurrence: '), 798 | ); 799 | $this->assertVEVENT( 800 | 'America/New_York', 801 | array( 802 | 'DTSTART;TZID=America/New_York:19970902T090000', 803 | 'FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z', 804 | ), 805 | 3, 806 | $checks 807 | ); 808 | } 809 | */ 810 | 811 | /* Requires support of MINUTELY frequency [#101] 812 | * 813 | // Page 131, Test 2 :: Every 15 minutes for 6 occurrences 814 | public function test_page131_test2() 815 | { 816 | $checks = array( 817 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 818 | array('index' => 1, 'dateString' => '19970902T091500', 'message' => '2nd occurrence: '), 819 | array('index' => 2, 'dateString' => '19970902T093000', 'message' => '3rd occurrence: '), 820 | array('index' => 3, 'dateString' => '19970902T094500', 'message' => '4th occurrence: '), 821 | array('index' => 4, 'dateString' => '19970902T100000', 'message' => '5th occurrence: '), 822 | array('index' => 5, 'dateString' => '19970902T101500', 'message' => '6th occurrence: '), 823 | ); 824 | $this->assertVEVENT( 825 | 'America/New_York', 826 | array( 827 | 'DTSTART;TZID=America/New_York:19970902T090000', 828 | 'RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6', 829 | ), 830 | 6, 831 | $checks 832 | ); 833 | } 834 | */ 835 | 836 | /* Requires support of MINUTELY frequency [#101] 837 | * 838 | // Page 131, Test 3 :: Every hour and a half for 4 occurrences 839 | public function test_page131_test3() 840 | { 841 | $checks = array( 842 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence: '), 843 | array('index' => 1, 'dateString' => '19970902T103000', 'message' => '2nd occurrence: '), 844 | array('index' => 2, 'dateString' => '19970902T120000', 'message' => '3rd occurrence: '), 845 | array('index' => 3, 'dateString' => '19970902T133000', 'message' => '4th occurrence: '), 846 | ); 847 | $this->assertVEVENT( 848 | 'America/New_York', 849 | array( 850 | 'DTSTART;TZID=America/New_York:19970902T090000', 851 | 'RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4', 852 | ), 853 | 4, 854 | $checks 855 | ); 856 | } 857 | */ 858 | 859 | /* Requires support of BYHOUR and BYMINUTE under DAILY [#11] 860 | * 861 | // Page 131, Test 4a :: Every 20 minutes from 9:00 to 16:40 every day, using DAILY 862 | // 863 | // UNTIL rule does not exist in original example 864 | public function test_page131_test4a() 865 | { 866 | $checks = array( 867 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence, Day 1: '), 868 | array('index' => 1, 'dateString' => '19970902T092000', 'message' => '2nd occurrence, Day 1: '), 869 | array('index' => 2, 'dateString' => '19970902T094000', 'message' => '3rd occurrence, Day 1: '), 870 | array('index' => 3, 'dateString' => '19970902T100000', 'message' => '4th occurrence, Day 1: '), 871 | array('index' => 20, 'dateString' => '19970902T164000', 'message' => 'Last occurrence, Day 1: '), 872 | array('index' => 21, 'dateString' => '19970903T090000', 'message' => '1st occurrence, Day 2: '), 873 | array('index' => 41, 'dateString' => '19970903T164000', 'message' => 'Last occurrence, Day 2: '), 874 | ); 875 | $this->assertVEVENT( 876 | 'America/New_York', 877 | array( 878 | 'DTSTART;TZID=America/New_York:19970902T090000', 879 | 'RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40;UNTIL=19970904T000000Z', 880 | ), 881 | 42, 882 | $checks 883 | ); 884 | } 885 | */ 886 | 887 | /* Requires support of MINUTELY frequency [#101] 888 | * 889 | // Page 131, Test 4b :: Every 20 minutes from 9:00 to 16:40 every day, using MINUTELY 890 | // 891 | // UNTIL rule does not exist in original example 892 | public function test_page131_test4b() 893 | { 894 | $checks = array( 895 | array('index' => 0, 'dateString' => '19970902T090000', 'message' => '1st occurrence, Day 1: '), 896 | array('index' => 1, 'dateString' => '19970902T092000', 'message' => '2nd occurrence, Day 1: '), 897 | array('index' => 2, 'dateString' => '19970902T094000', 'message' => '3rd occurrence, Day 1: '), 898 | array('index' => 3, 'dateString' => '19970902T100000', 'message' => '4th occurrence, Day 1: '), 899 | array('index' => 20, 'dateString' => '19970902T164000', 'message' => 'Last occurrence, Day 1: '), 900 | array('index' => 21, 'dateString' => '19970903T090000', 'message' => '1st occurrence, Day 2: '), 901 | array('index' => 41, 'dateString' => '19970903T164000', 'message' => 'Last occurrence, Day 2: '), 902 | ); 903 | $this->assertVEVENT( 904 | 'America/New_York', 905 | array( 906 | 'DTSTART;TZID=America/New_York:19970902T090000', 907 | 'RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16;UNTIL=19970904T000000Z', 908 | ), 909 | 42, 910 | $checks 911 | ); 912 | } 913 | */ 914 | 915 | // Page 131, Test 5a :: Changing the passed WKST rule, before... 916 | public function test_page131_test5a() 917 | { 918 | $checks = array( 919 | array('index' => 0, 'dateString' => '19970805T090000', 'message' => '1st occurrence: '), 920 | array('index' => 1, 'dateString' => '19970810T090000', 'message' => '2nd occurrence: '), 921 | array('index' => 2, 'dateString' => '19970819T090000', 'message' => '3rd occurrence: '), 922 | array('index' => 3, 'dateString' => '19970824T090000', 'message' => '4th occurrence: '), 923 | ); 924 | $this->assertVEVENT( 925 | 'America/New_York', 926 | array( 927 | 'DTSTART;TZID=America/New_York:19970805T090000', 928 | 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO', 929 | ), 930 | 4, 931 | $checks 932 | ); 933 | } 934 | 935 | // Page 131, Test 5b :: ...and after 936 | public function test_page131_test5b() 937 | { 938 | $checks = array( 939 | array('index' => 0, 'dateString' => '19970805T090000', 'message' => '1st occurrence: '), 940 | array('index' => 1, 'dateString' => '19970817T090000', 'message' => '2nd occurrence: '), 941 | array('index' => 2, 'dateString' => '19970819T090000', 'message' => '3rd occurrence: '), 942 | array('index' => 3, 'dateString' => '19970831T090000', 'message' => '4th occurrence: '), 943 | ); 944 | $this->assertVEVENT( 945 | 'America/New_York', 946 | array( 947 | 'DTSTART;TZID=America/New_York:19970805T090000', 948 | 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU', 949 | ), 950 | 4, 951 | $checks 952 | ); 953 | } 954 | 955 | // Page 132, Test 1 :: Automatically ignoring an invalid date (30 February) 956 | public function test_page132_test1() 957 | { 958 | $checks = array( 959 | array('index' => 0, 'dateString' => '20070115T090000', 'message' => '1st occurrence: '), 960 | array('index' => 1, 'dateString' => '20070130T090000', 'message' => '2nd occurrence: '), 961 | array('index' => 2, 'dateString' => '20070215T090000', 'message' => '3rd occurrence: '), 962 | array('index' => 3, 'dateString' => '20070315T090000', 'message' => '4th occurrence: '), 963 | array('index' => 4, 'dateString' => '20070330T090000', 'message' => '5th occurrence: '), 964 | ); 965 | $this->assertVEVENT( 966 | 'America/New_York', 967 | array( 968 | 'DTSTART;TZID=America/New_York:20070115T090000', 969 | 'RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5', 970 | ), 971 | 5, 972 | $checks 973 | ); 974 | } 975 | 976 | public function assertVEVENT($defaultTimeZone, $veventParts, $count, $checks) 977 | { 978 | $options = $this->getOptions($defaultTimeZone); 979 | 980 | $testIcal = implode(PHP_EOL, $this->getIcalHeader()); 981 | $testIcal .= PHP_EOL; 982 | $testIcal .= implode(PHP_EOL, $this->formatIcalEvent($veventParts)); 983 | $testIcal .= PHP_EOL; 984 | $testIcal .= implode(PHP_EOL, $this->getIcalFooter()); 985 | 986 | $ical = new ICal(false, $options); 987 | $ical->initString($testIcal); 988 | 989 | $events = $ical->events(); 990 | 991 | $this->assertCount($count, $events); 992 | 993 | foreach ($checks as $check) { 994 | $this->assertEvent($events[$check['index']], $check['dateString'], $check['message'], isset($check['timezone']) ? $check['timezone'] : $defaultTimeZone); 995 | } 996 | } 997 | 998 | public function assertEvent($event, $expectedDateString, $message, $timeZone = null) 999 | { 1000 | if (!is_null($timeZone)) { 1001 | date_default_timezone_set($timeZone); 1002 | } 1003 | 1004 | $expectedTimeStamp = strtotime($expectedDateString); 1005 | 1006 | $this->assertSame($expectedTimeStamp, $event->dtstart_array[2], $message . 'timestamp mismatch (expected ' . $expectedDateString . ' vs actual ' . $event->dtstart . ')'); 1007 | $this->assertSame($expectedDateString, $event->dtstart, $message . 'dtstart mismatch (timestamp is okay)'); 1008 | } 1009 | 1010 | public function getOptions($defaultTimeZone) 1011 | { 1012 | $options = array( 1013 | 'defaultSpan' => 2, // Default value: 2 1014 | 'defaultTimeZone' => $defaultTimeZone, // Default value: UTC 1015 | 'defaultWeekStart' => 'MO', // Default value 1016 | 'disableCharacterReplacement' => false, // Default value 1017 | 'filterDaysAfter' => null, // Default value 1018 | 'filterDaysBefore' => null, // Default value 1019 | 'httpUserAgent' => null, // Default value 1020 | 'skipRecurrence' => false, // Default value 1021 | ); 1022 | 1023 | return $options; 1024 | } 1025 | 1026 | public function formatIcalEvent($veventParts) 1027 | { 1028 | return array_merge( 1029 | array( 1030 | 'BEGIN:VEVENT', 1031 | 'CREATED:' . gmdate('Ymd\THis\Z'), 1032 | 'UID:RFC5545-examples-test', 1033 | ), 1034 | $veventParts, 1035 | array( 1036 | 'SUMMARY:test', 1037 | 'LAST-MODIFIED:' . gmdate('Ymd\THis\Z', filemtime(__FILE__)), 1038 | 'END:VEVENT', 1039 | ) 1040 | ); 1041 | } 1042 | 1043 | public function getIcalHeader() 1044 | { 1045 | return array( 1046 | 'BEGIN:VCALENDAR', 1047 | 'VERSION:2.0', 1048 | 'PRODID:-//Google Inc//Google Calendar 70.9054//EN', 1049 | 'X-WR-CALNAME:Private', 1050 | 'X-APPLE-CALENDAR-COLOR:#FF2968', 1051 | 'X-WR-CALDESC:', 1052 | ); 1053 | } 1054 | 1055 | public function getIcalFooter() 1056 | { 1057 | return array('END:VCALENDAR'); 1058 | } 1059 | } 1060 | -------------------------------------------------------------------------------- /tests/SingleEventsTest.php: -------------------------------------------------------------------------------- 1 | originalTimeZone = date_default_timezone_get(); 20 | } 21 | 22 | /** 23 | * @after 24 | */ 25 | public function tearDownFixtures() 26 | { 27 | date_default_timezone_set($this->originalTimeZone); 28 | } 29 | 30 | public function testFullDayTimeZoneBerlin() 31 | { 32 | $checks = array( 33 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 34 | ); 35 | $this->assertVEVENT( 36 | 'Europe/Berlin', 37 | 'DTSTART;VALUE=DATE:20000301', 38 | 'DTEND;VALUE=DATE:20000302', 39 | 1, 40 | $checks 41 | ); 42 | } 43 | 44 | public function testSeveralFullDaysTimeZoneBerlin() 45 | { 46 | $checks = array( 47 | array('index' => 0, 'dateString' => '20000301', 'message' => '1st event, CET: '), 48 | ); 49 | $this->assertVEVENT( 50 | 'Europe/Berlin', 51 | 'DTSTART;VALUE=DATE:20000301', 52 | 'DTEND;VALUE=DATE:20000304', 53 | 1, 54 | $checks 55 | ); 56 | } 57 | 58 | public function testEventTimeZoneUTC() 59 | { 60 | $checks = array( 61 | array('index' => 0, 'dateString' => '20180626T070000Z', 'message' => '1st event, UTC: '), 62 | ); 63 | $this->assertVEVENT( 64 | 'Europe/Berlin', 65 | 'DTSTART:20180626T070000Z', 66 | 'DTEND:20180626T110000Z', 67 | 1, 68 | $checks 69 | ); 70 | } 71 | 72 | public function testEventTimeZoneBerlin() 73 | { 74 | $checks = array( 75 | array('index' => 0, 'dateString' => '20180626T070000', 'message' => '1st event, CEST: '), 76 | ); 77 | $this->assertVEVENT( 78 | 'Europe/Berlin', 79 | 'DTSTART:20180626T070000', 80 | 'DTEND:20180626T110000', 81 | 1, 82 | $checks 83 | ); 84 | } 85 | 86 | public function assertVEVENT($defaultTimeZone, $dtstart, $dtend, $count, $checks) 87 | { 88 | $options = $this->getOptions($defaultTimeZone); 89 | 90 | $testIcal = implode(PHP_EOL, $this->getIcalHeader()); 91 | $testIcal .= PHP_EOL; 92 | $testIcal .= implode(PHP_EOL, $this->formatIcalEvent($dtstart, $dtend)); 93 | $testIcal .= PHP_EOL; 94 | $testIcal .= implode(PHP_EOL, $this->getIcalTimeZones()); 95 | $testIcal .= PHP_EOL; 96 | $testIcal .= implode(PHP_EOL, $this->getIcalFooter()); 97 | 98 | date_default_timezone_set('UTC'); 99 | 100 | $ical = new ICal(false, $options); 101 | $ical->initString($testIcal); 102 | 103 | $events = $ical->events(); 104 | 105 | $this->assertCount($count, $events); 106 | 107 | foreach ($checks as $check) { 108 | $this->assertEvent( 109 | $events[$check['index']], 110 | $check['dateString'], 111 | $check['message'], 112 | isset($check['timezone']) ? $check['timezone'] : $defaultTimeZone 113 | ); 114 | } 115 | } 116 | 117 | public function getOptions($defaultTimeZone) 118 | { 119 | $options = array( 120 | 'defaultSpan' => 2, // Default value 121 | 'defaultTimeZone' => $defaultTimeZone, // Default value: UTC 122 | 'defaultWeekStart' => 'MO', // Default value 123 | 'disableCharacterReplacement' => false, // Default value 124 | 'filterDaysAfter' => null, // Default value 125 | 'filterDaysBefore' => null, // Default value 126 | 'httpUserAgent' => null, // Default value 127 | 'skipRecurrence' => false, // Default value 128 | ); 129 | 130 | return $options; 131 | } 132 | 133 | public function getIcalHeader() 134 | { 135 | return array( 136 | 'BEGIN:VCALENDAR', 137 | 'VERSION:2.0', 138 | 'PRODID:-//Google Inc//Google Calendar 70.9054//EN', 139 | 'X-WR-CALNAME:Private', 140 | 'X-APPLE-CALENDAR-COLOR:#FF2968', 141 | 'X-WR-CALDESC:', 142 | ); 143 | } 144 | 145 | public function formatIcalEvent($dtstart, $dtend) 146 | { 147 | return array( 148 | 'BEGIN:VEVENT', 149 | 'CREATED:20090213T195947Z', 150 | 'UID:M2CD-1-1-5FB000FB-BBE4-4F3F-9E7E-217F1FF97209', 151 | $dtstart, 152 | $dtend, 153 | 'SUMMARY:test', 154 | 'DESCRIPTION;LANGUAGE=en-gb:This is a short description\nwith a new line. Some "special" \'s', 155 | ' igns\' may be interesting\, too.', 156 | '  And a non-breaking space.', 157 | 'LAST-MODIFIED:20110429T222101Z', 158 | 'DTSTAMP:20170630T105724Z', 159 | 'SEQUENCE:0', 160 | 'END:VEVENT', 161 | ); 162 | } 163 | 164 | public function getIcalTimeZones() 165 | { 166 | return array( 167 | 'BEGIN:VTIMEZONE', 168 | 'TZID:Europe/Berlin', 169 | 'X-LIC-LOCATION:Europe/Berlin', 170 | 'BEGIN:STANDARD', 171 | 'DTSTART:18930401T000000', 172 | 'RDATE:18930401T000000', 173 | 'TZNAME:CEST', 174 | 'TZOFFSETFROM:+005328', 175 | 'TZOFFSETTO:+0100', 176 | 'END:STANDARD', 177 | 'BEGIN:DAYLIGHT', 178 | 'DTSTART:19160430T230000', 179 | 'RDATE:19160430T230000', 180 | 'RDATE:19400401T020000', 181 | 'RDATE:19430329T020000', 182 | 'RDATE:19460414T020000', 183 | 'RDATE:19470406T030000', 184 | 'RDATE:19480418T020000', 185 | 'RDATE:19490410T020000', 186 | 'RDATE:19800406T020000', 187 | 'TZNAME:CEST', 188 | 'TZOFFSETFROM:+0100', 189 | 'TZOFFSETTO:+0200', 190 | 'END:DAYLIGHT', 191 | 'BEGIN:STANDARD', 192 | 'DTSTART:19161001T010000', 193 | 'RDATE:19161001T010000', 194 | 'RDATE:19421102T030000', 195 | 'RDATE:19431004T030000', 196 | 'RDATE:19441002T030000', 197 | 'RDATE:19451118T030000', 198 | 'RDATE:19461007T030000', 199 | 'TZNAME:CET', 200 | 'TZOFFSETFROM:+0200', 201 | 'TZOFFSETTO:+0100', 202 | 'END:STANDARD', 203 | 'BEGIN:DAYLIGHT', 204 | 'DTSTART:19170416T020000', 205 | 'RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYMONTH=4;BYDAY=3MO', 206 | 'TZNAME:CEST', 207 | 'TZOFFSETFROM:+0100', 208 | 'TZOFFSETTO:+0200', 209 | 'END:DAYLIGHT', 210 | 'BEGIN:STANDARD', 211 | 'DTSTART:19170917T030000', 212 | 'RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYMONTH=9;BYDAY=3MO', 213 | 'TZNAME:CET', 214 | 'TZOFFSETFROM:+0200', 215 | 'TZOFFSETTO:+0100', 216 | 'END:STANDARD', 217 | 'BEGIN:DAYLIGHT', 218 | 'DTSTART:19440403T020000', 219 | 'RRULE:FREQ=YEARLY;UNTIL=19450402T010000Z;BYMONTH=4;BYDAY=1MO', 220 | 'TZNAME:CEST', 221 | 'TZOFFSETFROM:+0100', 222 | 'TZOFFSETTO:+0200', 223 | 'END:DAYLIGHT', 224 | 'BEGIN:DAYLIGHT', 225 | 'DTSTART:19450524T020000', 226 | 'RDATE:19450524T020000', 227 | 'RDATE:19470511T030000', 228 | 'TZNAME:CEMT', 229 | 'TZOFFSETFROM:+0200', 230 | 'TZOFFSETTO:+0300', 231 | 'END:DAYLIGHT', 232 | 'BEGIN:DAYLIGHT', 233 | 'DTSTART:19450924T030000', 234 | 'RDATE:19450924T030000', 235 | 'RDATE:19470629T030000', 236 | 'TZNAME:CEST', 237 | 'TZOFFSETFROM:+0300', 238 | 'TZOFFSETTO:+0200', 239 | 'END:DAYLIGHT', 240 | 'BEGIN:STANDARD', 241 | 'DTSTART:19460101T000000', 242 | 'RDATE:19460101T000000', 243 | 'RDATE:19800101T000000', 244 | 'TZNAME:CEST', 245 | 'TZOFFSETFROM:+0100', 246 | 'TZOFFSETTO:+0100', 247 | 'END:STANDARD', 248 | 'BEGIN:STANDARD', 249 | 'DTSTART:19471005T030000', 250 | 'RRULE:FREQ=YEARLY;UNTIL=19491002T010000Z;BYMONTH=10;BYDAY=1SU', 251 | 'TZNAME:CET', 252 | 'TZOFFSETFROM:+0200', 253 | 'TZOFFSETTO:+0100', 254 | 'END:STANDARD', 255 | 'BEGIN:STANDARD', 256 | 'DTSTART:19800928T030000', 257 | 'RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU', 258 | 'TZNAME:CET', 259 | 'TZOFFSETFROM:+0200', 260 | 'TZOFFSETTO:+0100', 261 | 'END:STANDARD', 262 | 'BEGIN:DAYLIGHT', 263 | 'DTSTART:19810329T020000', 264 | 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 265 | 'TZNAME:CEST', 266 | 'TZOFFSETFROM:+0100', 267 | 'TZOFFSETTO:+0200', 268 | 'END:DAYLIGHT', 269 | 'BEGIN:STANDARD', 270 | 'DTSTART:19961027T030000', 271 | 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 272 | 'TZNAME:CET', 273 | 'TZOFFSETFROM:+0200', 274 | 'TZOFFSETTO:+0100', 275 | 'END:STANDARD', 276 | 'END:VTIMEZONE', 277 | 'BEGIN:VTIMEZONE', 278 | 'TZID:Europe/Paris', 279 | 'X-LIC-LOCATION:Europe/Paris', 280 | 'BEGIN:STANDARD', 281 | 'DTSTART:18910315T000100', 282 | 'RDATE:18910315T000100', 283 | 'TZNAME:PMT', 284 | 'TZOFFSETFROM:+000921', 285 | 'TZOFFSETTO:+000921', 286 | 'END:STANDARD', 287 | 'BEGIN:STANDARD', 288 | 'DTSTART:19110311T000100', 289 | 'RDATE:19110311T000100', 290 | 'TZNAME:WEST', 291 | 'TZOFFSETFROM:+000921', 292 | 'TZOFFSETTO:+0000', 293 | 'END:STANDARD', 294 | 'BEGIN:DAYLIGHT', 295 | 'DTSTART:19160614T230000', 296 | 'RDATE:19160614T230000', 297 | 'RDATE:19170324T230000', 298 | 'RDATE:19180309T230000', 299 | 'RDATE:19190301T230000', 300 | 'RDATE:19200214T230000', 301 | 'RDATE:19210314T230000', 302 | 'RDATE:19220325T230000', 303 | 'RDATE:19230526T230000', 304 | 'RDATE:19240329T230000', 305 | 'RDATE:19250404T230000', 306 | 'RDATE:19260417T230000', 307 | 'RDATE:19270409T230000', 308 | 'RDATE:19280414T230000', 309 | 'RDATE:19290420T230000', 310 | 'RDATE:19300412T230000', 311 | 'RDATE:19310418T230000', 312 | 'RDATE:19320402T230000', 313 | 'RDATE:19330325T230000', 314 | 'RDATE:19340407T230000', 315 | 'RDATE:19350330T230000', 316 | 'RDATE:19360418T230000', 317 | 'RDATE:19370403T230000', 318 | 'RDATE:19380326T230000', 319 | 'RDATE:19390415T230000', 320 | 'RDATE:19400225T020000', 321 | 'TZNAME:WEST', 322 | 'TZOFFSETFROM:+0000', 323 | 'TZOFFSETTO:+0100', 324 | 'END:DAYLIGHT', 325 | 'BEGIN:STANDARD', 326 | 'DTSTART:19161002T000000', 327 | 'RRULE:FREQ=YEARLY;UNTIL=19191005T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6,', 328 | ' 7,8;BYDAY=MO', 329 | 'TZNAME:WET', 330 | 'TZOFFSETFROM:+0100', 331 | 'TZOFFSETTO:+0000', 332 | 'END:STANDARD', 333 | 'BEGIN:STANDARD', 334 | 'DTSTART:19201024T000000', 335 | 'RDATE:19201024T000000', 336 | 'RDATE:19211026T000000', 337 | 'RDATE:19391119T000000', 338 | 'TZNAME:WET', 339 | 'TZOFFSETFROM:+0100', 340 | 'TZOFFSETTO:+0000', 341 | 'END:STANDARD', 342 | 'BEGIN:STANDARD', 343 | 'DTSTART:19221008T000000', 344 | 'RRULE:FREQ=YEARLY;UNTIL=19381001T230000Z;BYMONTH=10;BYMONTHDAY=2,3,4,5,6,', 345 | ' 7,8;BYDAY=SU', 346 | 'TZNAME:WET', 347 | 'TZOFFSETFROM:+0100', 348 | 'TZOFFSETTO:+0000', 349 | 'END:STANDARD', 350 | 'BEGIN:STANDARD', 351 | 'DTSTART:19400614T230000', 352 | 'RDATE:19400614T230000', 353 | 'TZNAME:CEST', 354 | 'TZOFFSETFROM:+0100', 355 | 'TZOFFSETTO:+0200', 356 | 'END:STANDARD', 357 | 'BEGIN:STANDARD', 358 | 'DTSTART:19421102T030000', 359 | 'RDATE:19421102T030000', 360 | 'RDATE:19431004T030000', 361 | 'RDATE:19760926T010000', 362 | 'RDATE:19770925T030000', 363 | 'RDATE:19781001T030000', 364 | 'TZNAME:CET', 365 | 'TZOFFSETFROM:+0200', 366 | 'TZOFFSETTO:+0100', 367 | 'END:STANDARD', 368 | 'BEGIN:DAYLIGHT', 369 | 'DTSTART:19430329T020000', 370 | 'RDATE:19430329T020000', 371 | 'RDATE:19440403T020000', 372 | 'RDATE:19760328T010000', 373 | 'TZNAME:CEST', 374 | 'TZOFFSETFROM:+0100', 375 | 'TZOFFSETTO:+0200', 376 | 'END:DAYLIGHT', 377 | 'BEGIN:STANDARD', 378 | 'DTSTART:19440825T000000', 379 | 'RDATE:19440825T000000', 380 | 'TZNAME:WEST', 381 | 'TZOFFSETFROM:+0200', 382 | 'TZOFFSETTO:+0200', 383 | 'END:STANDARD', 384 | 'BEGIN:DAYLIGHT', 385 | 'DTSTART:19441008T010000', 386 | 'RDATE:19441008T010000', 387 | 'TZNAME:WEST', 388 | 'TZOFFSETFROM:+0200', 389 | 'TZOFFSETTO:+0100', 390 | 'END:DAYLIGHT', 391 | 'BEGIN:DAYLIGHT', 392 | 'DTSTART:19450402T020000', 393 | 'RDATE:19450402T020000', 394 | 'TZNAME:WEMT', 395 | 'TZOFFSETFROM:+0100', 396 | 'TZOFFSETTO:+0200', 397 | 'END:DAYLIGHT', 398 | 'BEGIN:STANDARD', 399 | 'DTSTART:19450916T030000', 400 | 'RDATE:19450916T030000', 401 | 'TZNAME:CEST', 402 | 'TZOFFSETFROM:+0200', 403 | 'TZOFFSETTO:+0100', 404 | 'END:STANDARD', 405 | 'BEGIN:STANDARD', 406 | 'DTSTART:19770101T000000', 407 | 'RDATE:19770101T000000', 408 | 'TZNAME:CEST', 409 | 'TZOFFSETFROM:+0100', 410 | 'TZOFFSETTO:+0100', 411 | 'END:STANDARD', 412 | 'BEGIN:DAYLIGHT', 413 | 'DTSTART:19770403T020000', 414 | 'RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYMONTH=4;BYDAY=1SU', 415 | 'TZNAME:CEST', 416 | 'TZOFFSETFROM:+0100', 417 | 'TZOFFSETTO:+0200', 418 | 'END:DAYLIGHT', 419 | 'BEGIN:STANDARD', 420 | 'DTSTART:19790930T030000', 421 | 'RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYMONTH=9;BYDAY=-1SU', 422 | 'TZNAME:CET', 423 | 'TZOFFSETFROM:+0200', 424 | 'TZOFFSETTO:+0100', 425 | 'END:STANDARD', 426 | 'BEGIN:DAYLIGHT', 427 | 'DTSTART:19810329T020000', 428 | 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 429 | 'TZNAME:CEST', 430 | 'TZOFFSETFROM:+0100', 431 | 'TZOFFSETTO:+0200', 432 | 'END:DAYLIGHT', 433 | 'BEGIN:STANDARD', 434 | 'DTSTART:19961027T030000', 435 | 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 436 | 'TZNAME:CET', 437 | 'TZOFFSETFROM:+0200', 438 | 'TZOFFSETTO:+0100', 439 | 'END:STANDARD', 440 | 'END:VTIMEZONE', 441 | 'BEGIN:VTIMEZONE', 442 | 'TZID:US-Eastern', 443 | 'LAST-MODIFIED:19870101T000000Z', 444 | 'TZURL:http://zones.stds_r_us.net/tz/US-Eastern', 445 | 'BEGIN:STANDARD', 446 | 'DTSTART:19671029T020000', 447 | 'RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10', 448 | 'TZOFFSETFROM:-0400', 449 | 'TZOFFSETTO:-0500', 450 | 'TZNAME:EST', 451 | 'END:STANDARD', 452 | 'BEGIN:DAYLIGHT', 453 | 'DTSTART:19870405T020000', 454 | 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4', 455 | 'TZOFFSETFROM:-0500', 456 | 'TZOFFSETTO:-0400', 457 | 'TZNAME:EDT', 458 | 'END:DAYLIGHT', 459 | 'END:VTIMEZONE', 460 | ); 461 | } 462 | 463 | public function getIcalFooter() 464 | { 465 | return array('END:VCALENDAR'); 466 | } 467 | 468 | public function assertEvent($event, $expectedDateString, $message, $timezone = null) 469 | { 470 | if ($timezone !== null) { 471 | date_default_timezone_set($timezone); 472 | } 473 | 474 | $expectedTimeStamp = strtotime($expectedDateString); 475 | 476 | $this->assertSame( 477 | $expectedTimeStamp, 478 | $event->dtstart_array[2], 479 | $message . 'timestamp mismatch (expected ' . $expectedDateString . ' vs actual ' . $event->dtstart . ')' 480 | ); 481 | $this->assertSame( 482 | $expectedDateString, 483 | $event->dtstart, 484 | $message . 'dtstart mismatch (timestamp is okay)' 485 | ); 486 | } 487 | 488 | public function assertEventFile($defaultTimeZone, $file, $count, $checks) 489 | { 490 | $options = $this->getOptions($defaultTimeZone); 491 | 492 | date_default_timezone_set('UTC'); 493 | 494 | $ical = new ICal($file, $options); 495 | 496 | $events = $ical->events(); 497 | 498 | $this->assertCount($count, $events); 499 | 500 | foreach ($checks as $check) { 501 | $this->assertEvent( 502 | $events[$check['index']], 503 | $check['dateString'], 504 | $check['message'], 505 | isset($check['timezone']) ? $check['timezone'] : $defaultTimeZone 506 | ); 507 | } 508 | } 509 | } 510 | -------------------------------------------------------------------------------- /tests/ical/ical-monthly.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | X-WR-CALNAME:Private 5 | X-APPLE-CALENDAR-COLOR:#FF2968 6 | X-WR-CALDESC: 7 | BEGIN:VEVENT 8 | CREATED:20090213T195947Z 9 | UID:M2CD-1-1-5FB000FB-BBE4-4F3F-9E7E-217F1FF97208 10 | RRULE:FREQ=MONTHLY;BYMONTHDAY=1;WKST=SU;COUNT=25 11 | DTSTART;VALUE=DATE:20180701 12 | DTEND;VALUE=DATE:20180702 13 | SUMMARY:Monthly 14 | LAST-MODIFIED:20110429T222101Z 15 | DTSTAMP:20170630T105724Z 16 | SEQUENCE:0 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/ical/issue-196.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN 3 | VERSION:2.0 4 | X-WR-CALNAME:Test-Calendar 5 | X-WR-TIMEZONE:Europe/Berlin 6 | BEGIN:VTIMEZONE 7 | TZID:Europe/Berlin 8 | BEGIN:DAYLIGHT 9 | TZOFFSETFROM:+0100 10 | TZOFFSETTO:+0200 11 | TZNAME:CEST 12 | DTSTART:19700329T020000 13 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 14 | END:DAYLIGHT 15 | BEGIN:STANDARD 16 | TZOFFSETFROM:+0200 17 | TZOFFSETTO:+0100 18 | TZNAME:CET 19 | DTSTART:19701025T030000 20 | RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 21 | END:STANDARD 22 | END:VTIMEZONE 23 | BEGIN:VEVENT 24 | CREATED:20180101T152047Z 25 | LAST-MODIFIED:20181202T202056Z 26 | DTSTAMP:20181202T202056Z 27 | UID:529b1ea3-8de8-484d-b878-c20c7fb72bf5 28 | SUMMARY:test 29 | RRULE:FREQ=DAILY;UNTIL=20191111T180000Z 30 | DTSTART;TZID=Europe/Berlin:20191105T190000 31 | DTEND;TZID=Europe/Berlin:20191105T220000 32 | TRANSP:OPAQUE 33 | SEQUENCE:24 34 | X-MOZ-GENERATION:37 35 | END:VEVENT 36 | BEGIN:VEVENT 37 | CREATED:20181202T202042Z 38 | LAST-MODIFIED:20181202T202053Z 39 | DTSTAMP:20181202T202053Z 40 | UID:529b1ea3-8de8-484d-b878-c20c7fb72bf5 41 | SUMMARY:test 42 | RECURRENCE-ID;TZID=Europe/Berlin:20191109T190000 43 | DTSTART;TZID=Europe/Berlin:20191109T170000 44 | DTEND;TZID=Europe/Berlin:20191109T220000 45 | TRANSP:OPAQUE 46 | SEQUENCE:25 47 | X-MOZ-GENERATION:37 48 | DURATION:PT0S 49 | END:VEVENT 50 | BEGIN:VEVENT 51 | CREATED:20181202T202053Z 52 | LAST-MODIFIED:20181202T202056Z 53 | DTSTAMP:20181202T202056Z 54 | UID:529b1ea3-8de8-484d-b878-c20c7fb72bf5 55 | SUMMARY:test 56 | RECURRENCE-ID;TZID=Europe/Berlin:20191110T190000 57 | DTSTART;TZID=Europe/Berlin:20191110T180000 58 | DTEND;TZID=Europe/Berlin:20191110T220000 59 | TRANSP:OPAQUE 60 | SEQUENCE:25 61 | X-MOZ-GENERATION:37 62 | DURATION:PT0S 63 | END:VEVENT 64 | END:VCALENDAR 65 | --------------------------------------------------------------------------------