├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── hacs.yaml │ ├── lint.yaml │ └── runtests.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.md ├── UpgradeTo4.0AndLater.md ├── custom_components ├── __init__.py └── ics_calendar │ ├── __init__.py │ ├── calendar.py │ ├── calendardata.py │ ├── config_flow.py │ ├── const.py │ ├── filter.py │ ├── getparser.py │ ├── icalendarparser.py │ ├── manifest.json │ ├── parserevent.py │ ├── parsers │ ├── __init__.py │ ├── parser_ics.py │ └── parser_rie.py │ ├── strings.json │ ├── translations │ ├── de.json │ ├── en.json │ ├── es.json │ ├── fr.json │ └── pt-br.json │ └── utility.py ├── formatstyle.sh ├── hacs.json ├── icons ├── icon.png └── icon@2x.png ├── info.md ├── pyproject.toml ├── test.sh └── tests ├── __init__.py ├── allday.ics ├── allday.ics.expected.json ├── conftest.py ├── issue125.ics ├── issue17.ics ├── issue17.ics.expected.json ├── issue22.ics ├── issue22.ics.expected.json ├── issue34.ics ├── issue36.ics ├── issue36.ics.expected.json ├── issue43-14.ics.expected.json ├── issue43.ics ├── issue43.ics.expected.json ├── issue45.ics ├── issue45.ics.expected.json ├── issue48.ics ├── issue48.ics.expected.json ├── issue5.ics ├── issue6.ics ├── issue8.ics ├── issue92.ics ├── issue92.ics.expected.json ├── negative_offset.ics ├── negative_offset.ics.expected.json ├── negative_offset_all_day.ics ├── negative_offset_all_day.ics.expected.json ├── positive_offset.ics ├── positive_offset.ics.expected.json ├── positive_offset_all_day.ics ├── positive_offset_all_day.ics.expected.json ├── test_calendar.py ├── test_calendardata.py ├── test_config_flow.py ├── test_filter.py ├── test_icalendarparser.py ├── test_parsers.py └── test_utility.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Home Assistant Setup** 11 | Please indicate your version of HA and how it is installed. 12 | 13 | Version: 14 | 15 | Installation Type (put an X between the square brackets for your HA): 16 | [] Home Assistant OS 17 | [] Home Assistant Supervised 18 | [] Home Assistant Container 19 | [] Home Assistant Core 20 | 21 | Hardware platform: 22 | [] ARM 23 | [] x86-64 24 | 25 | Are you running in a container environment like Docker or Kubernetes? 26 | [] Yes 27 | [] No 28 | 29 | If running in a container, how is your image built? 30 | [] Official HA container image 31 | [] Official HA container image with customizations 32 | [] Custom built container image 33 | 34 | **Describe the bug** 35 | A clear and concise description of the bug 36 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | day: "monday" 13 | reviewers: 14 | - "franc6" 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Fixes # 2 | 3 | Description of change: 4 | 5 | ## Formatting, testing, and code coverage 6 | Please note your pull request won't be accepted if you haven't properly formatted your source code, and ensured the unit tests are appropriate. Please note if you are not running on Windows, you can either run the scripts via a bash installation (like git-bash). 7 | 8 | - [] formatstyle.sh reports no errors 9 | - [] All unit tests pass (test.sh) 10 | - [] Code coverage has not decreased (test.sh) 11 | -------------------------------------------------------------------------------- /.github/workflows/hacs.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest and run HACS action 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | schedule: 8 | - cron: '0 0 * * *' 9 | 10 | jobs: 11 | validate: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: Checkout 15 | uses: "actions/checkout@v4" 16 | - name: Validate with hassfest 17 | uses: "home-assistant/actions/hassfest@master" 18 | - name: HACS Action 19 | uses: "hacs/action@main" 20 | with: 21 | category: integration 22 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: [releases] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Install uv 15 | uses: astral-sh/setup-uv@v3 16 | with: 17 | version: "0.4.20" 18 | 19 | - name: Setup Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version-file: "pyproject.toml" 23 | 24 | - name: Install dependencies 25 | run: | 26 | uv sync --prerelease=allow --dev --extra tests 27 | 28 | - name: Run isort --check 29 | run: | 30 | uv run --prerelease=allow isort --check custom_components/ics_calendar tests 31 | 32 | - name: Run black --check 33 | run: | 34 | uv run --prerelease=allow black --check custom_components/ics_calendar tests 35 | 36 | - name: Run flake8 37 | run: | 38 | uv run --prerelease=allow flake8 39 | 40 | - name: Run pydocstyle 41 | run: | 42 | uv run --prerelease=allow pydocstyle -v custom_components/ics_calendar tests 43 | 44 | - name: Run pylint 45 | run: | 46 | uv run --prerelease=allow pylint custom_components/ics_calendar 47 | -------------------------------------------------------------------------------- /.github/workflows/runtests.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [releases] 6 | pull_request: 7 | branches: [releases] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Set timezone 15 | run: | 16 | sudo timedatectl set-timezone America/New_York 17 | timedatectl 18 | 19 | - uses: actions/checkout@v4 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | with: 24 | version: "0.4.20" 25 | 26 | - name: Setup Python 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version-file: "pyproject.toml" 30 | 31 | - name: Install dependencies 32 | run: | 33 | uv sync --prerelease=allow --extra tests 34 | 35 | - name: Run pytest 36 | run: | 37 | PYTHONDONTWRITEBYTECODE=1 uv run --prerelease=allow pytest tests/ 38 | 39 | - name: Upload coverage to Codecov 40 | uses: codecov/codecov-action@v4 41 | with: 42 | fail_ci_if_error: true 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # Spyder project settings 96 | .spyderproject 97 | .spyproject 98 | 99 | # Rope project settings 100 | .ropeproject 101 | 102 | # mkdocs documentation 103 | /site 104 | 105 | # mypy 106 | .mypy_cache/ 107 | 108 | uv.lock -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 5.1.4 2025/04/XX 2 | - Added Spanish translation, thanks @cvc90! 3 | - Added ability to use offsets in URL templates (fixed #239) 4 | - Fixed #237 5 | 6 | ## 5.1.3 2025/02/13 7 | - Updated recurring_ical_events to latest for bug fixes and to prevent 2.5.0 from being used. Big thanks to @ccMatrix, @cookie050, and the many others who reported the problem and determined the correct fix! 8 | - Updated dependencies for tests. 9 | - Issue #191 should also be fixed, thanks to @niccokunzmann for the updates in recurring_ical_events. 10 | 11 | ## 5.1.2 2025/01/30 12 | - Updated httpx_auth version for HA 2025.2.0b0 and later, thanks, @TheZoker and @gieljnssns! 13 | 14 | ## 5.1.1 2025/01/20 15 | - Added Brazilian Portuguese translation, thanks @opastorello! 16 | - Fixed #116. webcal:// prefix will be silently replaced with https:// 17 | - Fixed #133 (and #169 and #211). Configuration should now detect if the URL was already encoded, and if so, avoid encoding it again. 18 | 19 | ## 5.1.0 2024/12/18 20 | - Fixed #125 You can now configure a default summary for events that don't have a summary in the calendar. 21 | - Fixed #200 22 | - Fixed #205 23 | 24 | ## !!! WARNING !!! 25 | This may introduce a regression on issue #117. If that was a problem for you, please re-open the issue if it recurs with this release. 26 | 27 | ## 5.0.6-beta2 2024/12/13 28 | - Second BETA fix for issue 200 29 | - BETA fix for issue 200?? 30 | 31 | ## !!! WARNING !!! 32 | Please do not use this beta release at this time, unless franc6 has asked you to, things might break badly. 33 | 34 | ## 5.0.6-beta1 2024/12/13 35 | - BETA fix for issue 200?? 36 | 37 | ## !!! WARNING !!! 38 | Please do not use this beta release at this time, unless franc6 has asked you to, things might break badly. 39 | 40 | ## 5.0.5 2024/12/12 41 | - Fixed #166/#139/#183 Thanks to everyone who contributed questions and comments! 42 | - Possibly fixed #194. Updated dependencies based on comments for #194 Thanks @gaetanars, @tardich, and @wrichter for the research! 43 | - Partial fix for #125 -- events with no summary will for now receive a summary of "No title". A future release should make this configurable. Thanks @jonasglass and @maxhamilius! 44 | - More unit tests 45 | 46 | ## 5.0.5-beta1 2024/12/11 47 | - Fixed #166 48 | - Possibly fixed #194. Updated dependencies based on comments for #194 Thanks @gaetanars, @tardich, and @wrichter for the research! 49 | - More unit tests 50 | 51 | ## !!! WARNING !!! 52 | Please do not use this beta release at this time, unless franc6 has asked you to, 53 | things might break badly. 54 | 55 | ## 5.0.4 2024/10/10 56 | - Fixed #170 57 | - Fixed #172 58 | - Added more unit tests 59 | - Updated dependencies in manifest.json 60 | - Updated dependencies to include some this project does not directly use, to see if it fixes #174. 61 | 62 | ## 5.0.3 2025/09/15 63 | - Fixed #165 64 | - Fixed #166 65 | - Updated dependencies in manifest.json 66 | 67 | ## 5.0.2 2025/09/11 68 | - Fixed #149 again 69 | - Fixed #158 70 | - Added translations for German (thanks, @mbenecke) and French (thanks, @odouville). 71 | 72 | ### Translator notes 73 | Sorry everyone! I added two new strings for this release, which breaks the lovely translations for that. They're just error messages, so if you're all perfect, then you won't notice. :) 74 | 75 | ## 5.0.1 2024/09/11 76 | - Fixed #149 Thanks, @jpbede! 77 | - Added German Translation by @mbenecke 78 | - Fixed #148 79 | - Fixed #151 Thanks to everyone who reported details on this one! 80 | 81 | ### Breaking Change 82 | If you installed 5.0.0, you'll probably need to re-enable the YAML configuration, remove all entities from ICS Calendar (note: this might mean going to Settings | Devices & entities | Entities, and filtering for "calendar.") and then restart HA. You can then disable your YAML configuration again. 83 | 84 | ## 5.0.0 2024/09/10 85 | - Fixed #117 Made sure the global download lock blocks and ensures min_update_time. (@agroszer) 86 | - Add UI configuration support 87 | - Fixed #89 88 | - Fixed #126 89 | - Fixed #140 90 | - Fixed #144 91 | 92 | ### IMPORTANT 93 | Do **NOT** update to this version from version 3.2.0 or older! Update to versoin 4.0.0 and follow the instructions at [UpgradeTo4.0AndLater.md](https://github.com/franc6/ics_calendar/blob/releases/UpgradeTo4 **first**! 94 | 95 | UI configuration is now supported, and configuration via YAML is now deprecated. After installing this update, and after restarting Home Assistant, please remove your existing YAML configuration for ICS calendar. **Your existing configuration has been imported!** Failure to remove the entries doesn't hurt anything, but will cause additional log entries about it. 96 | 97 | In a future release, YAML configuration support will be removed entirely, so please be sure to update before that happens, or you will lose your existing configuration. 98 | 99 | ### HELP WANTED 100 | Since there are now some UI components, it'd be nice to have them in more than just English, and you probably don't want me doing the translations. Please open PRs with translation files if you know how. Thanks! 101 | 102 | ## 4.2.0 2024/01/15 103 | - Add timeout feature. Thanks to @iamjackg! 104 | - Fixed #117 105 | 106 | ## 4.1.0 2023/11/07 107 | - Add feature for #107 108 | 109 | ## 4.0.1 2023/10/24 110 | - Attempt to give an error message for missing configuration 111 | 112 | ## 4.0.0 2023/10/17 113 | - Change to be a component, since HA doesn't seem likely to allow UI configuration of the calendar component. :( 114 | ### Breaking Change 115 | You must update your YAML configuration with this update. This integration is no longer a platform for the calendar component. Instead, it's a component on its own that provides calendars. Please see [UpgradeTo4.0AndLater.md](https://github.com/franc6/ics_calendar/blob/releases/UpgradeTo4.0AndLater.md) for more information on upgrading. Please read that carefully before upgrading to this version! 116 | 117 | ## 3.2.0 2023/09/19 118 | - Added new option, accept_header to allow setting an Accept header for misconfigured servers. 119 | - Updated dependencies 120 | 121 | ## 3.1.8 2023/08/23 122 | - Fixed #90 123 | - Fixed #92 124 | 125 | ### Breaking Change 126 | - Updated to require HA 2023.6. This release requires python 3.11, which is also required by HA 2023.6 and later, so that's the new minimum. 127 | 128 | ## 3.1.7 2023/02/07 129 | - Fixed #76/#85 130 | - Fixed #77; you can now use {year} and {month} in your URLs to get the current 4 digit year and 2 digit month. 131 | - Fixed #78; you can now specify an offset in hours if your calendar entries have the wrong time zone. 132 | 133 | ## 3.1.6 2022/12/12 134 | - Handle UTF-8 BOM in calendar data. 135 | - Properly decode UTF-16 calendar data. 136 | 137 | ## 3.1.5 2022/11/01 138 | - Handle gzip-encoded responses for servers that incorrectly return gzip'd data. Thanks to @omnigrok for finding the problem and suggesting a fix! 139 | 140 | ## 3.1.4 2022/10/17 141 | - Fixed a problem with using authentication with multiple calendars. Thanks to @Romkabouter for finding the problem and suggesting a fix! 142 | 143 | ## 3.1.3 2022/10/04 144 | - Refactored code for comparing events when getting current event. This is now consistent between parsers. Please report problems if short-duration events don't trigger your automations! 145 | - Fixed #64 146 | - Updated dependencies 147 | 148 | ## 3.1.2 2022/08/16 149 | - Fixed filter bug for events without a description (thanks to jberrenberg for @identifying and fixing it) 150 | - Please note ICS Calendar is now in the default store for HACS! You don't need to do anything. 151 | 152 | ## 3.1.1 2022/08/02 153 | ### This release is to correct 3.1.0, which should have required HA 2022.7! 154 | - Added new exclude/include filters (See README.md) 155 | - Added new user_agent option 156 | ### Breaking Changes! 157 | - As previously noted, "includeAllDay" was deprecated with v2.9.0. This version removes support for it. Please use "include_all_day" instead! 158 | - Python 3.10 is now required! 159 | - HA 2022.7 is now required! 160 | 161 | ## 3.1.0 2022/07/22 162 | - Added new exclude/include filters (See README.md) 163 | - Added new user_agent option 164 | ### Breaking Changes! 165 | - As previously noted, "includeAllDay" was deprecated with v2.9.0. This version removes support for it. Please use "include_all_day" instead! 166 | - Python 3.10 is now required! 167 | - HA 2022.7 is now required! 168 | 169 | ## 3.0.3 2022/07/19 170 | - Fixed #57 171 | - Updated ics dependency to 0.7.2 172 | - Updated icalendar dependency to 4.1.0 173 | 174 | ## 3.0.2 2022/06/30 175 | - Updated dependencies 176 | 177 | ## 3.0.1 2022/06/28 178 | - Fixed #54 which I believe was introduced by PR 71887 of home-assisstant/core. This fix reduces some of the aggressive caching, since the HA calendar code now behaves differently. Given the changes, caching the result of async_get_events is undesirable. 179 | - This may also fix #56 180 | - Applied fix from PR 72922 for home-assistant/core to reduce memory copies 181 | 182 | ## 3.0.0 2022/05/09 183 | - Refactored to use new CalendarEvent and CalendarEntity classes from HA 184 | 185 | ## 2.9.0 2022/04/19 186 | - Significant refactoring to change how data is cached and when; should resolve #38 187 | - Added new option, download_interval to set the time between downloading the calendar. Set to a multiple of 15. 188 | - Renamed includeAllDay to be include_all_day to better match other options. 189 | The old name will continue to work until version 3.1.0. 190 | - This release includes some aggressive data caching, and will consume more memory. However, CPU usage should be signficantly reduced, especially if download_interval is set to a value of 60 or more. A future release will attempt to reduce the memory usage. 191 | 192 | ## 2.8.2 2022/04/04 193 | - Breaking change! Requires Home Assistant 2022.4 or later 194 | - Fixed bug with error messages 195 | - Fixed #48 196 | 197 | ## 2.8.1 2022/03/31 198 | - Breaking change! Requires Home Assistant 2022.4 or later 199 | - Fixed code to work with upcoming 2022.4 releases 200 | - Refactored internal code for better test coverage 201 | 202 | ## 2.8.0 2022/03/31 203 | WITHDRAWN -- See 2.8.1 instead. 204 | 205 | ## 2.7.0 2022/03/29 206 | - Removed icalevents parser 207 | - Corrected manifest.json and hacs.json 208 | 209 | ## 2.6.1 2022/03/04 210 | - Added additional output to some error conditions 211 | 212 | ## 2.6.0 2022/03/01 213 | - Fixed some problems with rie parser 214 | - Added new "days" option so the next event will be shown better (see issues #43 & #44, and PR #33) 215 | - Added more unit tests 216 | 217 | ## 2.5.0 2022/01/03 218 | - Added new parser, "rie" and made it the default. 219 | 220 | ## 2.1.2 2022/01/03 221 | - Fixed ics parser 222 | 223 | ## 2.1.1 2022/01/03 224 | - Fixed deprecated device_state_attributes 225 | - Updated internal code to remove duplicated methods 226 | - Updated unit tests and infrastructure 227 | - Updated minimum HA version to 2021.4.0 228 | 229 | ## 2.1.0 2021/10/19 230 | - Added code to cache calls to async_get_events, which might reduce CPU usage 231 | - Added code to cache calls to download the calendar data 232 | - Note: the above items are not configurable yet 233 | - Fixed resource leak 234 | 235 | ## 2.0.0 2021/08/11 236 | - This is a breaking change, even for users of the 2.0 beta releases. Uninstall and re-install this platform, do not update! 237 | - Calendar platform name has changed; use "ics_calendar" instead of "ics" now 238 | - You can now switch which parser is used on a per-calendar basis; see the parser option in README.md 239 | 240 | ## 2.0.0-beta12 2021/08/11 (NOT FOR GENERAL USE) 241 | - Internal fixes 242 | 243 | ## 2.0.0-beta11 2021/08/11 (NOT FOR GENERAL USE) 244 | - Changed platform name to "ics_calendar" to match directory name 245 | 246 | ## 2.0.0-beta10 2021/08/10 (NOT FOR GENERAL USE) 247 | - Updated code to allow switching parsers based on which calendar, and eliminating the "use the beta" or "use the non-beta" replies 248 | - Added some unit tests 249 | - Fixed #34 250 | - Fixed #35 251 | 252 | ## 2.0.0-beta9 2021/06/18 253 | - Fixed #30, thanks to dannytrigo. I believe this one affected only the beta 254 | 255 | ## 2.0.0-beta8 2021/04/12 256 | - Added version key to manifest.json which is now required by Home Assitant 257 | 258 | ## 1.0.7 2021/04/12 259 | - Added version key to manifest.json which is now required by Home Assitant 260 | 261 | ## 2.0.0-beta7 2020/10/20 262 | - Fixed #20 263 | 264 | ## 1.0.6 2020/10/20 265 | - Improved error handling, based on fixing #20 for the beta version 266 | - Updated imports for arrow (see issue #16) 267 | 268 | ## 2.0.0-beta6 2020/09/11 269 | - Added support for HTTP Basic Auth and Digest Auth (see issue #13) 270 | 271 | ## 1.0.5 2020/09/11 272 | - Added support for HTTP Basic Auth and Digest Auth (see issue #13) 273 | 274 | ## 2.0.0-beta5 2020/09/09 275 | - Fixed issue #15 276 | - Merged PR #14 277 | - Fixed issue #11 278 | 279 | ## 1.0.4 2020/09/09 280 | - Fixed issue #15 281 | - Merged PR #14 282 | - Documented includeAllDay 283 | 284 | ## 1.0.3.1 2020/06/05 285 | - Fixed #11 286 | - Fixed I/O to be async properly 287 | - Updated all dates to be local timezone if there's no timezone instead of UTC. 288 | 289 | ## 1.0.2 2020/02/04 290 | Fixed issue #7 291 | 292 | ## 1.0.1 2020/01/16 293 | Added work-around for issue #5 294 | 295 | ## 1.0.0 2019/08/15 296 | Initial release 297 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ics_calendar 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) 3 | [![ics_calendar](https://img.shields.io/github/v/release/franc6/ics_calendar.svg?1)](https://github.com/franc6/ics_calendar) 4 | [![Coverage](https://codecov.io/gh/franc6/ics_calendar/branch/releases/graph/badge.svg)](https://app.codecov.io/gh/franc6/ics_calendar/branch/releases) 5 | ![Maintained:yes](https://img.shields.io/maintenance/yes/2025.svg) 6 | [![License](https://img.shields.io/github/license/franc6/ics_calendar.svg)](LICENSE) 7 | 8 | Provides a component for ICS (icalendar) calendars for Home Assistant 9 | 10 | > **NOTE**: This component is intended for use with simple hosting of ICS files. If your server supports CalDAV, please use the caldav calendar platform instead. This one might work, but probably not well. 11 | 12 | > **NOTE**: Because https://www.home-assistant.io/integrations/remote_calendar now exists, this project will eventually close. I may or may not add some of the features that project is missing, but maybe not. This will stick around for at least a couple more releases, since I know some people will be unable to use the Remote Calendar integration due to its current limitations. If anyone thinks it's worth keeping this project going even after Remote Calendar gets close to feature parity, I'm willing to transfer ownership. I just have no desire to work on a project that's duplicating something else that exists. 13 | 14 | ## Installation 15 | You can install this through [HACS](https://github.com/custom-components/hacs). 16 | 17 | ### Warning when installing in HA in a container 18 | A number of users have reported problems when running HA in a container. If you see any of these messages in your log, 19 | ``` 20 | - Setup failed for custom integration 'ics_calendar': Requirements for ics_calendar not found 21 | - ModuleNotFoundError: No module named 'ics' 22 | - ModuleNotFoundError: No module named 'recurring-icalendar-events' 23 | - ImportError: Exception importing custom_components.ics_calendar.calendar 24 | - AttributeError: module 'icalendar' has no attribute 'InvalidCalendar' 25 | ``` 26 | 27 | Then your problem stems from a dependency installation issue. This problem is supposed to be resolved in HA 2025.1, but it's possible that you still have trouble, because of one or more of the following: 28 | 29 | 1. you are running in an unsupported container environment 30 | 2. you have made custom modifications to the container 31 | 3. you have made custom modifications to the container in order to resolve an earlier dependency problem, for this integration or another 32 | 33 | If you encounter a dependency installation problem, please see https://github.com/home-assistant/core/issues/127966 and https://github.com/home-assistant/core/pull/125808 which explain the problem in more detail, and the fix that was applied. If those do not help you resolve the dependency problem, please note that the author will be unable to help. Please do not open an issue on GitHub for this problem. It's not a bug in ics_calendar, and it cannot be resolved by changing ics_calendar. You can find the full list of runtime dependencies and versions in the [manifest.json](custom_components/ics_calendar/manifest.json) in the "requirements" value. 34 | 35 | ### Manual installation 36 | 37 | The following procedure should work, but is **strongly** discouraged. 38 | 39 | 1. Using the tool of choice open the directory (folder) for your HA configuration (where you find configuration.yaml). 40 | 2. If you do not have a custom_components directory (folder) there, you need to create it. 41 | 3. In the custom_components directory (folder) create a new folder called ics_calendar. 42 | 4. Download all the files from the custom_components/ics_calendar/ directory (folder) in this repository. 43 | 5. Place the files you downloaded in the new directory (folder) you created. 44 | 6. Restart Home Assistant 45 | 46 | Using your HA configuration directory (folder) as a starting point you should now also have this: 47 | ``` 48 | custom_components/ics_calendar/__init__.py 49 | custom_components/ics_calendar/calendar.py 50 | custom_components/ics_calendar/calendardata.py 51 | custom_components/ics_calendar/config_flow.py 52 | custom_components/ics_calendar/const.py 53 | custom_components/ics_calendar/filter.py 54 | custom_components/ics_calendar/getparser.py 55 | custom_components/ics_calendar/icalendarparser.py 56 | custom_components/ics_calendar/manifest.json 57 | custom_components/ics_calendar/parsers/__init__.py 58 | custom_components/ics_calendar/parsers/parser_ics.py 59 | custom_components/ics_calendar/parsers/parser_rie.py 60 | custom_components/ics_calendar/strings.json 61 | custom_components/ics_calendar/translations/en.json 62 | custom_components/ics_calendar/utility.py 63 | ``` 64 | 65 | ## Authentication 66 | This component supports HTTP Basic Auth and HTTP Digest Auth. It does not support more advanced authentication methods. 67 | 68 | ## Configuration 69 | Configuration is done via UI now. Go to https://my.home-assistant.io/redirect/integrations/ and click "Add Integration" to add ICS Calendar. You'll want to do this for each calendar for this integration. 70 | 71 | Please note that if you previously used configuration.yaml, you must remove those entries after updating to a version that supports UI configuration. 72 | 73 | ## Example configuration.yaml 74 | ```yaml 75 | ics_calendar: 76 | calendars: 77 | - name: "Name of calendar" 78 | url: "https://url.to/calendar.ics" 79 | - name: "Name of another calendar" 80 | url: "https://url.to/other_calendar.ics" 81 | include_all_day: True 82 | - name: "Name of a calendar that requires authentication" 83 | url: "https://url.to/auth/calendar.ics" 84 | include_all_day: True 85 | username: True 86 | password: !secret auth_calendar 87 | ``` 88 | 89 | ## Configuration options 90 | Key | Type | Required | Description 91 | -- | -- | -- | -- 92 | `calendars` | `list` | `True` | The list of remote calendars to check 93 | 94 | ### Configuration options for `calendar` list 95 | Key | Type | Required | Description 96 | -- | -- | -- | -- 97 | `name` | `string` | `True` | A name for the calendar 98 | `url` | `string` | `True` | The URL of the calendar (https and file URI schemes are supported) 99 | `accept_header` | `string` | An accept header for servers that are misconfigured, default is not set 100 | `connection_timeout` | `float` | `None` | Sets a timeout in seconds for the connection to download the calendar. Use this if you have frequent connection issues with a calendar 101 | `days` | `positive integer` | `False` | The number of days to look ahead (only affects the attributes of the calendar entity), default is 1 102 | `download_interval` | `positive integer` | `False` | The time between downloading new calendar data, in minutes, default is 15 103 | `exclude` | `string` | `False` | Allows for filtering of events, see below 104 | `include` | `string` | `False` | Allows for filtering of events, see below 105 | `include_all_day` | `boolean` | `False` | Set to True if all day events should be included 106 | `offset_hours` | `int` | `False` | A number of hours (positive or negative) to offset times by, see below 107 | `parser` | `string` | `False` | 'rie' or 'ics', defaults to 'rie' if not present 108 | `prefix` | `string` | `False` | Specify a string to prefix every event summary with, see below 109 | `username` | `string` | `False` | If the calendar requires authentication, this specifies the user name 110 | `password` | `string` | `False` | If the calendar requires authentication, this specifies the password 111 | `user_agent` | `string` | `False` | Allows setting the User-agent header. Only specify this if your server rejects the normal python user-agent string. You must set the entire and exact user agent string here. 112 | 113 | #### Download Interval 114 | The download interval should be a multiple of 15 at this time. This is so downloads coincide with Home Assistant's update interval for the calendar entities. Setting a value smaller than 15 will increase both CPU and memory usage. Higher values will reduce CPU usage. The default of 15 is to keep the same behavior with regards to downloads as in the past. 115 | 116 | Home Assistant does two types of queries. One is the 15 minute calendar entity update, the other is a query every time the `calendar.list_events` service is called. This interval limit applies to both. 117 | On top of that there can be globally only one download in progress for all calendars. This might be an issue with lots of calendars and slow server response. 118 | 119 | #### Offset Hours 120 | This feature is to aid with calendars that present incorrect times. If your calendar has an incorrect time, e.g. it lists your local time, but indicates that it's the time in UTC, this can be used to correct for your local time. This affects all events, except all day events. All day events do not include time information, and so the offset will not be applied. Use a positive number to add hours to the time, and a negative number to subtract hours from the time. 121 | 122 | #### Prefix 123 | This feature prefixes each summary with the given string. You might want to have some whitespace between the prefix and original event summary. You must include whatever whitespace you want in your configuration, so be sure to quote the string. E.g.: 124 | 125 | ```yaml 126 | ics_calendar: 127 | calendars: 128 | - name: "Name of calendar" 129 | url: "https://url.to/calendar.ics" 130 | prefix: 'ICS Prefix ' 131 | ``` 132 | 133 | ## Parsers 134 | ics_calendar uses one of two parsers for generating events from calendars. These parsers are written and maintained by third parties, not by me. Each comes with its own sets of problems. 135 | 136 | Version 1.x used "ics" which does not handle recurring events, and has a few other problems (see issues #6, #8, and #18). The "ics" parser is also very strict, and will frequently give parsing errors for files which do not conform to RFC 5545. Some of the most popular calendaring programs produce files that do not conform to the RFC. The "ics" parser also tends to require more memory and processing power. Several users have noted that it's unusuable for HA systems running on Raspberry pi computers. 137 | 138 | The Version 2.0.0 betas used "icalevents" which is a little more forgiving, but has a few problems with recurring event rules. All-day events which repeat until a specific date and time are a particular issue (all-day events which repeat until a specific date are just fine). 139 | 140 | In Version 2.5 and later, a new parser, "rie" is the default. Like "icalevents", it's based on the "icalendar" library. This parser appears to fix both issues #8 and #36, which are problematic for "icalevents". 141 | 142 | Starting with version 2.7, "icalevents" is no longer available. If you have specified icalevents as the parser, please change it to rie or ics. 143 | 144 | As a general rule, I recommend sticking with the "rie" parser, which is the default. If you see parsing errors, you can try switching to "ics" for the calendar with the parsing errors. 145 | 146 | ## Filters 147 | The new exclude and include options allow for filtering events in the calendar. This is a string representation of an array of strings or regular expressions. They are used as follows: 148 | 149 | The exclude filters are applied first, searching in the summary and description only. If an event is excluded by the exclude filters, the include filters will be checked to determine if the event should be included anyway. 150 | 151 | Regular expressions can be used, by surrounding the string with slashes (/). You can also specify case insensitivity, multi-line match, and dotall matches by appending i, m, or s (respectively) after the ending slash. If you don't understand what that means, you probably just want to stick with plain string matching. For example, if you specify "['/^test/i']" as your exclude filter, then any event whose summary or description starts with "test", "Test", "TEST", etc. will be excluded. 152 | 153 | For plain string matching, the string will be searched for in a case insensitive manner. For example, if you specify "['test']" for your exclude filter, any event whose summary or description contains "test", "Test", "TEST", etc. will be excluded. Since this is not a whole-word match, this means if the summary or description contains "testing" or "stesting", the event will be excluded. 154 | 155 | You can also include multiple entries for exclude or include. 156 | 157 | > **NOTE**: If you want to include only events that include a specific string, you **must** use an exclude filter that excludes everything in addition to your include filter. E.g. "['/.*/']" 158 | 159 | ### Examples 160 | ```yaml 161 | ics_calendar: 162 | calendars: 163 | - name: "Name of calendar" 164 | url: "https://url.to/calendar.ics" 165 | exclude: "['test', '/^regex$/']" 166 | include: "['keepme']" 167 | ``` 168 | 169 | This example will exclude any event whose summary or description includes "test" in a case insensitive manner, or if the summary or description is "regex". However, if the summary or description includes "keepme" (case insensitive), the event will be included anyway. 170 | 171 | ## URL Templates 172 | If your ICS url requires specifying the current year and/or month, you can now use templates to specify the current year and month. E.g. if you set your url to: 173 | ```yaml 174 | url: "https://www.a-url?year={year}&month={month}" 175 | ``` 176 | 177 | The "{year}" part will be replaced with the current 4 digit year, and the "{month}" part will be replaced with the current 2 digit month. So in February 2023, the URL will be "https://www.a-url?year=2023&month=02", in November 2024, the URL will be "https://www.a-url?year=2024&month=11". 178 | 179 | You can also specify positive and negative offsets for the year and month templates. E.g. if you set your url to: 180 | ```yaml 181 | url: "https://www.a-url?year={year+1}&month={month-3}" 182 | ``` 183 | 184 | The "{year+1}" part will be replaced with the next 4 digit year, and the "{month-3}" part will be replaced with the 2 digit month of three months ago. So in February 2023, the URL will be "https://www.a-url?year=2023&month=11", in November 2024, the URL will be "https://www.a-url?year=2025&month=8". 185 | 186 | ## Development environment setup 187 | 188 | You should have uv installed. Then run the following commands: 189 | 190 | ```shell 191 | $ uv sync --dev --extra tests 192 | $ source ./.venv/bin/activate # for CSH shells 193 | $ . ./.venv/bin/activate # for most other shells 194 | ``` 195 | 196 | [![Buy me some pizza](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/qpunYPZx5) 197 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # Updates for v5.X 2 | 3 | ## Unit tests 4 | - [X] Add unit tests for UI configuration methods (5.0.5) 5 | - [ ] Add unit tests for setup entry points in ics_calendar/__init__.py 6 | 7 | ## General 8 | - [X] Figure out how to get entries named something other than "ICS Calendar" when looking at separate entries in Setup (5.0.4) 9 | - [ ] Determine if #193 is a problem ics_calendar can resolve or not (CAP-Team, probably not, JayWll, open question) 10 | - [ ] Fix #216 11 | 12 | ## UI Config 13 | - [X] Revamp UI config, especially for URLs (see #133, #116, #169, #211,) 14 | 15 | ## HTTP Changes 16 | - [X] Fix #166; use homeassistant.helpers.httpx_client.get_async_client along with httpx_auth to handle authentication (5.0.5) 17 | 18 | # Updates for v6.0.0 19 | 20 | ## Remove YAML config support 21 | - [ ] Remove methods and unit tests 22 | 23 | ## UI Config 24 | - [ ] Break UI config into config_flow and options_flow 25 | - [ ] Allow reconfigure of options 26 | - Name & unique ID should be kept, everything else can be reconfigured 27 | - Users that want to change the name can use HA's entity configuration to do that 28 | - #222, and others 29 | 30 | ## HTTP Changes 31 | - [ ] Add support for more advanced authentication mechanisms 32 | -------------------------------------------------------------------------------- /UpgradeTo4.0AndLater.md: -------------------------------------------------------------------------------- 1 | This file covers upgrading from ics_calendar version 3.x or earlier to version 4.0.x. Please read it carefully and make the necessary configuration changes, or you may experience problems. If you are installing version 4.0.x and have not used ics_calendar before, you don't need to read this document. Just follow the configuration instructions in [README.md](https://github.com/franc6/ics_calendar/blob/releases/README.md). 2 | 3 | Version 4.0.0 includes some breaking changes that allow for a future version that is configurable via the UI, and supports unique_id. This future version will fix issues 88 and 89. They are not fixed in 4.0.0. 4 | 5 | Your existing configuration from version 3.2.0 or earlier will **not** work with version 4.0.0 and later. 6 | 7 | The basic change is to move the ics_calendar configuration to a top-level item, instead of being a platform entry under calendar. See the examples below. 8 | 9 | ## Steps to upgrade 10 | 1. Install new version of ics_calendar via HACS or manually. **Do NOT restart Home Assistant yet!** 11 | 2. Update YAML configuration, using the examples below for reference. 12 | 3. Restart Home Assistant. 13 | 14 | ## Examples 15 | 16 | ### Example in configuration.yaml with only ics_calendar calendars 17 | 18 | Current configuration.yaml 19 | 20 | ```yaml 21 | calendar: 22 | - platform: ics_calendar 23 | calendars: 24 | - name: "Name of calendar" 25 | url: "https://url.to/calendar.ics" 26 | - name: "Name of another calendar" 27 | url: "https://url.to/other_calendar.ics" 28 | include_all_day: True 29 | - name: "Name of a calendar that requires authentication" 30 | url: "https://url.to/auth/calendar.ics" 31 | include_all_day: True 32 | username: True 33 | password: !secret auth_calendar 34 | ``` 35 | 36 | New configuration.yaml 37 | 38 | ```yaml 39 | ics_calendar: 40 | calendars: 41 | - name: "Name of calendar" 42 | url: "https://url.to/calendar.ics" 43 | - name: "Name of another calendar" 44 | url: "https://url.to/other_calendar.ics" 45 | include_all_day: True 46 | - name: "Name of a calendar that requires authentication" 47 | url: "https://url.to/auth/calendar.ics" 48 | include_all_day: True 49 | username: True 50 | password: !secret auth_calendar 51 | ``` 52 | 53 | ### Example in configuration.yaml with caldav and ics_calendar calendars: 54 | 55 | Current configuration.yaml 56 | 57 | ```yaml 58 | calendar: 59 | - platform: caldav 60 | username: user 61 | password: !secret caldav_password 62 | url: https://example.com/.well-known/caldav 63 | - platform: ics_calendar 64 | calendars: 65 | - name: "Name of calendar" 66 | url: "https://url.to/calendar.ics" 67 | - name: "Name of another calendar" 68 | url: "https://url.to/other_calendar.ics" 69 | include_all_day: True 70 | - name: "Name of a calendar that requires authentication" 71 | url: "https://url.to/auth/calendar.ics" 72 | include_all_day: True 73 | username: True 74 | password: !secret auth_calendar 75 | ``` 76 | 77 | New configuration.yaml 78 | 79 | ```yaml 80 | calendar: 81 | - platform: caldav 82 | username: user 83 | password: !secret caldav_password 84 | url: https://example.com/.well-known/caldav 85 | 86 | ics_calendar: 87 | calendars: 88 | - name: "Name of calendar" 89 | url: "https://url.to/calendar.ics" 90 | - name: "Name of another calendar" 91 | url: "https://url.to/other_calendar.ics" 92 | include_all_day: True 93 | - name: "Name of a calendar that requires authentication" 94 | url: "https://url.to/auth/calendar.ics" 95 | include_all_day: True 96 | username: True 97 | password: !secret auth_calendar 98 | ``` 99 | 100 | ### Example in external yaml file with only ics_calendar calendars: 101 | 102 | Current configuration.yaml 103 | 104 | ```yaml 105 | calendar: !include calendars.yaml 106 | ``` 107 | 108 | Current calendars.yaml 109 | 110 | ```yaml 111 | - platform: ics_calendar 112 | calendars: 113 | - name: "Name of calendar" 114 | url: "https://url.to/calendar.ics" 115 | - name: "Name of another calendar" 116 | url: "https://url.to/other_calendar.ics" 117 | include_all_day: True 118 | - name: "Name of a calendar that requires authentication" 119 | url: "https://url.to/auth/calendar.ics" 120 | include_all_day: True 121 | username: True 122 | password: !secret auth_calendar 123 | ``` 124 | 125 | New configuration.yaml 126 | 127 | ```yaml 128 | ics_calendar: !include calendars.yaml 129 | ``` 130 | 131 | New calendars.yaml 132 | ```yaml 133 | calendars: 134 | - name: "Name of calendar" 135 | url: "https://url.to/calendar.ics" 136 | - name: "Name of another calendar" 137 | url: "https://url.to/other_calendar.ics" 138 | include_all_day: True 139 | - name: "Name of a calendar that requires authentication" 140 | url: "https://url.to/auth/calendar.ics" 141 | include_all_day: True 142 | username: True 143 | password: !secret auth_calendar 144 | ``` 145 | 146 | ### Example in external yaml file, with caldav and ics_calendar calendars: 147 | 148 | Current configuration.yaml 149 | 150 | ```yaml 151 | calendar: !include calendars.yaml 152 | ``` 153 | 154 | Current calendars.yaml 155 | 156 | ```yaml 157 | - platform: caldav 158 | username: user 159 | password: !secret caldav_password 160 | url: https://example.com/.well-known/caldav 161 | - platform: ics_calendar 162 | calendars: 163 | - name: "Name of calendar" 164 | url: "https://url.to/calendar.ics" 165 | - name: "Name of another calendar" 166 | url: "https://url.to/other_calendar.ics" 167 | include_all_day: True 168 | - name: "Name of a calendar that requires authentication" 169 | url: "https://url.to/auth/calendar.ics" 170 | include_all_day: True 171 | username: True 172 | password: !secret auth_calendar 173 | ``` 174 | 175 | New configuration.yaml 176 | 177 | ```yaml 178 | calendar: !include calendars.yaml 179 | ics_calendar: !include ics_calendars.yaml 180 | ``` 181 | 182 | New calendars.yaml 183 | 184 | ```yaml 185 | - platform: caldav 186 | username: user 187 | password: !secret caldav_password 188 | url: https://example.com/.well-known/caldav 189 | ``` 190 | 191 | New ics_calendars.yaml 192 | 193 | ```yaml 194 | calendars: 195 | - name: "Name of calendar" 196 | url: "https://url.to/calendar.ics" 197 | - name: "Name of another calendar" 198 | url: "https://url.to/other_calendar.ics" 199 | include_all_day: True 200 | - name: "Name of a calendar that requires authentication" 201 | url: "https://url.to/auth/calendar.ics" 202 | include_all_day: True 203 | username: True 204 | password: !secret auth_calendar 205 | ``` -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom Components.""" 2 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/__init__.py: -------------------------------------------------------------------------------- 1 | """ics Calendar for Home Assistant.""" 2 | 3 | import logging 4 | 5 | import homeassistant.helpers.config_validation as cv 6 | import voluptuous as vol 7 | from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 8 | from homeassistant.const import ( 9 | CONF_EXCLUDE, 10 | CONF_INCLUDE, 11 | CONF_NAME, 12 | CONF_PASSWORD, 13 | CONF_PREFIX, 14 | CONF_URL, 15 | CONF_USERNAME, 16 | Platform, 17 | ) 18 | from homeassistant.core import HomeAssistant, callback 19 | from homeassistant.helpers import discovery 20 | from homeassistant.helpers.issue_registry import ( 21 | IssueSeverity, 22 | async_create_issue, 23 | ) 24 | from homeassistant.helpers.typing import ConfigType 25 | 26 | from .const import ( 27 | CONF_ACCEPT_HEADER, 28 | CONF_ADV_CONNECT_OPTS, 29 | CONF_CALENDARS, 30 | CONF_CONNECTION_TIMEOUT, 31 | CONF_DAYS, 32 | CONF_DOWNLOAD_INTERVAL, 33 | CONF_INCLUDE_ALL_DAY, 34 | CONF_OFFSET_HOURS, 35 | CONF_PARSER, 36 | CONF_REQUIRES_AUTH, 37 | CONF_SET_TIMEOUT, 38 | CONF_SUMMARY_DEFAULT, 39 | CONF_SUMMARY_DEFAULT_DEFAULT, 40 | CONF_USER_AGENT, 41 | DOMAIN, 42 | ) 43 | 44 | _LOGGER = logging.getLogger(__name__) 45 | PLATFORMS: list[Platform] = [Platform.CALENDAR] 46 | 47 | CONFIG_SCHEMA = vol.Schema( 48 | { 49 | DOMAIN: vol.Schema( 50 | { 51 | # pylint: disable=no-value-for-parameter 52 | vol.Optional(CONF_CALENDARS, default=[]): vol.All( 53 | cv.ensure_list, 54 | vol.Schema( 55 | [ 56 | vol.Schema( 57 | { 58 | vol.Required(CONF_URL): vol.Url(), 59 | vol.Required(CONF_NAME): cv.string, 60 | vol.Optional( 61 | CONF_INCLUDE_ALL_DAY, default=False 62 | ): cv.boolean, 63 | vol.Optional( 64 | CONF_USERNAME, default="" 65 | ): cv.string, 66 | vol.Optional( 67 | CONF_PASSWORD, default="" 68 | ): cv.string, 69 | vol.Optional( 70 | CONF_PARSER, default="rie" 71 | ): cv.string, 72 | vol.Optional( 73 | CONF_PREFIX, default="" 74 | ): cv.string, 75 | vol.Optional( 76 | CONF_DAYS, default=1 77 | ): cv.positive_int, 78 | vol.Optional( 79 | CONF_DOWNLOAD_INTERVAL, default=15 80 | ): cv.positive_int, 81 | vol.Optional( 82 | CONF_USER_AGENT, default="" 83 | ): cv.string, 84 | vol.Optional( 85 | CONF_EXCLUDE, default="" 86 | ): cv.string, 87 | vol.Optional( 88 | CONF_INCLUDE, default="" 89 | ): cv.string, 90 | vol.Optional( 91 | CONF_OFFSET_HOURS, default=0 92 | ): int, 93 | vol.Optional( 94 | CONF_ACCEPT_HEADER, default="" 95 | ): cv.string, 96 | vol.Optional( 97 | CONF_CONNECTION_TIMEOUT, default=300 98 | ): cv.positive_float, 99 | vol.Optional( 100 | CONF_SUMMARY_DEFAULT, 101 | default=CONF_SUMMARY_DEFAULT_DEFAULT, 102 | ): cv.string, 103 | } 104 | ) 105 | ] 106 | ), 107 | ) 108 | } 109 | ) 110 | }, 111 | extra=vol.ALLOW_EXTRA, 112 | ) 113 | 114 | STORAGE_KEY = DOMAIN 115 | STORAGE_VERSION_MAJOR = 1 116 | STORAGE_VERSION_MINOR = 0 117 | 118 | 119 | async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 120 | """Set up calendars.""" 121 | _LOGGER.debug("Setting up ics_calendar component") 122 | hass.data.setdefault(DOMAIN, {}) 123 | 124 | if DOMAIN in config and config[DOMAIN]: 125 | _LOGGER.debug("discovery.load_platform called") 126 | discovery.load_platform( 127 | hass=hass, 128 | component=PLATFORMS[0], 129 | platform=DOMAIN, 130 | discovered=config[DOMAIN], 131 | hass_config=config, 132 | ) 133 | async_create_issue( 134 | hass, 135 | DOMAIN, 136 | "deprecated_yaml_configuration", 137 | is_fixable=False, 138 | issue_domain=DOMAIN, 139 | severity=IssueSeverity.WARNING, 140 | translation_key="YAML_Warning", 141 | ) 142 | _LOGGER.warning( 143 | "YAML configuration of ics_calendar is deprecated and will be " 144 | "removed in ics_calendar v5.0.0. Your configuration items have " 145 | "been imported. Please remove them from your configuration.yaml " 146 | "file." 147 | ) 148 | 149 | config_entry = _async_find_matching_config_entry(hass) 150 | if not config_entry: 151 | if config[DOMAIN].get("calendars"): 152 | for calendar in config[DOMAIN].get("calendars"): 153 | hass.async_create_task( 154 | hass.config_entries.flow.async_init( 155 | DOMAIN, 156 | context={"source": SOURCE_IMPORT}, 157 | data=dict(calendar), 158 | ) 159 | ) 160 | return True 161 | 162 | # update entry with any changes 163 | if config[DOMAIN].get("calendars"): 164 | for calendar in config[DOMAIN].get("calendars"): 165 | hass.config_entries.async_update_entry( 166 | config_entry, data=dict(calendar) 167 | ) 168 | 169 | return True 170 | 171 | 172 | @callback 173 | def _async_find_matching_config_entry(hass): 174 | for entry in hass.config_entries.async_entries(DOMAIN): 175 | if entry.source == SOURCE_IMPORT: 176 | return entry 177 | return None 178 | 179 | 180 | async def async_migrate_entry(hass, entry: ConfigEntry): 181 | """Migrate old config entry.""" 182 | # Don't downgrade entries 183 | if entry.version > STORAGE_VERSION_MAJOR: 184 | return False 185 | 186 | if entry.version == STORAGE_VERSION_MAJOR: 187 | new_data = {**entry.data} 188 | 189 | hass.config_entries.async_update_entry( 190 | entry, 191 | data=new_data, 192 | minor_version=STORAGE_VERSION_MINOR, 193 | version=STORAGE_VERSION_MAJOR, 194 | ) 195 | 196 | return True 197 | 198 | 199 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 200 | """Implement async_setup_entry.""" 201 | full_data: dict = add_missing_defaults(entry) 202 | hass.config_entries.async_update_entry(entry=entry, data=full_data) 203 | 204 | hass.data.setdefault(DOMAIN, {}) 205 | hass.data[DOMAIN][entry.entry_id] = full_data 206 | await hass.config_entries.async_forward_entry_setups(entry, ["calendar"]) 207 | return True 208 | 209 | 210 | def add_missing_defaults( 211 | entry: ConfigEntry, 212 | ) -> dict: 213 | """Initialize missing data.""" 214 | data = { 215 | CONF_NAME: "", 216 | CONF_URL: "", 217 | CONF_ADV_CONNECT_OPTS: False, 218 | CONF_SET_TIMEOUT: False, 219 | CONF_REQUIRES_AUTH: False, 220 | CONF_INCLUDE_ALL_DAY: False, 221 | CONF_REQUIRES_AUTH: False, 222 | CONF_USERNAME: "", 223 | CONF_PASSWORD: "", 224 | CONF_PARSER: "rie", 225 | CONF_PREFIX: "", 226 | CONF_DAYS: 1, 227 | CONF_DOWNLOAD_INTERVAL: 15, 228 | CONF_USER_AGENT: "", 229 | CONF_EXCLUDE: "", 230 | CONF_INCLUDE: "", 231 | CONF_OFFSET_HOURS: 0, 232 | CONF_ACCEPT_HEADER: "", 233 | CONF_CONNECTION_TIMEOUT: 300.0, 234 | CONF_SUMMARY_DEFAULT: CONF_SUMMARY_DEFAULT_DEFAULT, 235 | } 236 | data.update(entry.data) 237 | 238 | if CONF_USERNAME in entry.data or CONF_PASSWORD in entry.data: 239 | data[CONF_REQUIRES_AUTH] = True 240 | if ( 241 | CONF_USER_AGENT in entry.data 242 | or CONF_ACCEPT_HEADER in entry.data 243 | or CONF_CONNECTION_TIMEOUT in entry.data 244 | ): 245 | data[CONF_ADV_CONNECT_OPTS] = True 246 | if CONF_CONNECTION_TIMEOUT in entry.data: 247 | data[CONF_SET_TIMEOUT] = True 248 | 249 | return data 250 | 251 | 252 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 253 | """Unload entry.""" 254 | unload_ok = await hass.config_entries.async_unload_platforms( 255 | entry, PLATFORMS 256 | ) 257 | if unload_ok: 258 | hass.data[DOMAIN].pop(entry.entry_id) 259 | 260 | return unload_ok 261 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/calendar.py: -------------------------------------------------------------------------------- 1 | """Support for ICS Calendar.""" 2 | 3 | import logging 4 | from datetime import datetime, timedelta 5 | from typing import Any, Optional 6 | 7 | # import homeassistant.helpers.config_validation as cv 8 | # import voluptuous as vol 9 | from homeassistant.components.calendar import ( 10 | ENTITY_ID_FORMAT, 11 | CalendarEntity, 12 | CalendarEvent, 13 | extract_offset, 14 | is_offset_reached, 15 | ) 16 | from homeassistant.config_entries import ConfigEntry 17 | from homeassistant.const import ( 18 | CONF_EXCLUDE, 19 | CONF_INCLUDE, 20 | CONF_NAME, 21 | CONF_PASSWORD, 22 | CONF_PREFIX, 23 | CONF_URL, 24 | CONF_USERNAME, 25 | ) 26 | from homeassistant.core import HomeAssistant 27 | from homeassistant.helpers.entity import generate_entity_id 28 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 29 | from homeassistant.helpers.httpx_client import get_async_client 30 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 31 | from homeassistant.util.dt import now as hanow 32 | 33 | from .calendardata import CalendarData 34 | from .const import ( 35 | CONF_ACCEPT_HEADER, 36 | CONF_CALENDARS, 37 | CONF_CONNECTION_TIMEOUT, 38 | CONF_DAYS, 39 | CONF_DOWNLOAD_INTERVAL, 40 | CONF_INCLUDE_ALL_DAY, 41 | CONF_OFFSET_HOURS, 42 | CONF_PARSER, 43 | CONF_SET_TIMEOUT, 44 | CONF_SUMMARY_DEFAULT, 45 | CONF_USER_AGENT, 46 | DOMAIN, 47 | ) 48 | from .filter import Filter 49 | from .getparser import GetParser 50 | from .parserevent import ParserEvent 51 | 52 | _LOGGER = logging.getLogger(__name__) 53 | 54 | 55 | OFFSET = "!!" 56 | 57 | 58 | MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) 59 | 60 | 61 | async def async_setup_entry( 62 | hass: HomeAssistant, 63 | config_entry: ConfigEntry, 64 | async_add_entities: AddEntitiesCallback, 65 | ) -> None: 66 | """Set up the calendar in background.""" 67 | hass.async_create_task( 68 | _async_setup_entry_bg_task(hass, config_entry, async_add_entities) 69 | ) 70 | 71 | 72 | async def _async_setup_entry_bg_task( 73 | hass: HomeAssistant, 74 | config_entry: ConfigEntry, 75 | async_add_entities: AddEntitiesCallback, 76 | ) -> None: 77 | """Set up the calendar.""" 78 | data = hass.data[DOMAIN][config_entry.entry_id] 79 | device_id = f"{data[CONF_NAME]}" 80 | entity = ICSCalendarEntity( 81 | hass, 82 | generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass), 83 | hass.data[DOMAIN][config_entry.entry_id], 84 | config_entry.entry_id, 85 | ) 86 | async_add_entities([entity]) 87 | 88 | 89 | def setup_platform( 90 | hass: HomeAssistant, 91 | config: ConfigType, 92 | add_entities: AddEntitiesCallback, 93 | discovery_info: DiscoveryInfoType | None = None, 94 | ): 95 | """Set up ics_calendar platform. 96 | 97 | :param hass: Home Assistant object 98 | :type hass: HomeAssistant 99 | :param config: Config information for the platform 100 | :type config: ConfigType 101 | :param add_entities: Callback to add entities to HA 102 | :type add_entities: AddEntitiesCallback 103 | :param discovery_info: Config information for the platform 104 | :type discovery_info: DiscoveryInfoType | None, optional 105 | """ 106 | _LOGGER.debug("Setting up ics calendars") 107 | if discovery_info is not None: 108 | _LOGGER.debug( 109 | "setup_platform: ignoring discovery_info, already imported!" 110 | ) 111 | # calendars: list = discovery_info.get(CONF_CALENDARS) 112 | calendars = [] 113 | else: 114 | _LOGGER.debug("setup_platform: discovery_info is None") 115 | calendars: list = config.get(CONF_CALENDARS) 116 | 117 | calendar_devices = [] 118 | for calendar in calendars: 119 | device_data = { 120 | CONF_NAME: calendar.get(CONF_NAME), 121 | CONF_URL: calendar.get(CONF_URL), 122 | CONF_INCLUDE_ALL_DAY: calendar.get(CONF_INCLUDE_ALL_DAY), 123 | CONF_USERNAME: calendar.get(CONF_USERNAME), 124 | CONF_PASSWORD: calendar.get(CONF_PASSWORD), 125 | CONF_PARSER: calendar.get(CONF_PARSER), 126 | CONF_PREFIX: calendar.get(CONF_PREFIX), 127 | CONF_DAYS: calendar.get(CONF_DAYS), 128 | CONF_DOWNLOAD_INTERVAL: calendar.get(CONF_DOWNLOAD_INTERVAL), 129 | CONF_USER_AGENT: calendar.get(CONF_USER_AGENT), 130 | CONF_EXCLUDE: calendar.get(CONF_EXCLUDE), 131 | CONF_INCLUDE: calendar.get(CONF_INCLUDE), 132 | CONF_OFFSET_HOURS: calendar.get(CONF_OFFSET_HOURS), 133 | CONF_ACCEPT_HEADER: calendar.get(CONF_ACCEPT_HEADER), 134 | CONF_CONNECTION_TIMEOUT: calendar.get(CONF_CONNECTION_TIMEOUT), 135 | } 136 | device_id = f"{device_data[CONF_NAME]}" 137 | entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) 138 | calendar_devices.append( 139 | ICSCalendarEntity(hass, entity_id, device_data) 140 | ) 141 | 142 | add_entities(calendar_devices) 143 | 144 | 145 | class ICSCalendarEntity(CalendarEntity): 146 | """A CalendarEntity for an ICS Calendar.""" 147 | 148 | def __init__( 149 | self, 150 | hass: HomeAssistant, 151 | entity_id: str, 152 | device_data, 153 | unique_id: str = None, 154 | ): 155 | """Construct ICSCalendarEntity. 156 | 157 | :param entity_id: Entity id for the calendar 158 | :type entity_id: str 159 | :param device_data: dict describing the calendar 160 | :type device_data: dict 161 | """ 162 | _LOGGER.debug( 163 | "Initializing calendar: %s with URL: %s, uniqueid: %s", 164 | device_data[CONF_NAME], 165 | device_data[CONF_URL], 166 | unique_id, 167 | ) 168 | self.data = ICSCalendarData(hass, device_data) 169 | self.entity_id = entity_id 170 | self._attr_unique_id = f"ICSCalendar.{unique_id}" 171 | self._event = None 172 | self._attr_name = device_data[CONF_NAME] 173 | self._last_call = None 174 | 175 | @property 176 | def event(self) -> Optional[CalendarEvent]: 177 | """Return the current or next upcoming event or None. 178 | 179 | :return: The current event as a dict 180 | :rtype: dict 181 | """ 182 | return self._event 183 | 184 | @property 185 | def should_poll(self) -> bool: 186 | """Indicate if the calendar should be polled. 187 | 188 | If the last call to update or get_api_events was not within the minimum 189 | update time, then async_schedule_update_ha_state(True) is also called. 190 | :return: True 191 | :rtype: boolean 192 | """ 193 | this_call = hanow() 194 | if ( 195 | self._last_call is None 196 | or (this_call - self._last_call) > MIN_TIME_BETWEEN_UPDATES 197 | ): 198 | self._last_call = this_call 199 | self.async_schedule_update_ha_state(True) 200 | return True 201 | 202 | async def async_get_events( 203 | self, hass: HomeAssistant, start_date: datetime, end_date: datetime 204 | ) -> list[CalendarEvent]: 205 | """Get all events in a specific time frame. 206 | 207 | :param hass: Home Assistant object 208 | :type hass: HomeAssistant 209 | :param start_date: The first starting date to consider 210 | :type start_date: datetime 211 | :param end_date: The last starting date to consider 212 | :type end_date: datetime 213 | """ 214 | _LOGGER.debug( 215 | "%s: async_get_events called; calling internal.", self.name 216 | ) 217 | return await self.data.async_get_events(start_date, end_date) 218 | 219 | async def async_update(self): 220 | """Get the current or next event.""" 221 | await self.data.async_update() 222 | self._event: CalendarEvent | None = self.data.event 223 | self._attr_extra_state_attributes = { 224 | "offset_reached": ( 225 | is_offset_reached( 226 | self._event.start_datetime_local, self.data.offset 227 | ) 228 | if self._event 229 | else False 230 | ) 231 | } 232 | 233 | async def async_create_event(self, **kwargs: Any): 234 | """Raise error, this is a read-only calendar.""" 235 | raise NotImplementedError() 236 | 237 | async def async_delete_event( 238 | self, 239 | uid: str, 240 | recurrence_id: str | None = None, 241 | recurrence_range: str | None = None, 242 | ) -> None: 243 | """Raise error, this is a read-only calendar.""" 244 | raise NotImplementedError() 245 | 246 | async def async_update_event( 247 | self, 248 | uid: str, 249 | event: dict[str, Any], 250 | recurrence_id: str | None = None, 251 | recurrence_range: str | None = None, 252 | ) -> None: 253 | """Raise error, this is a read-only calendar.""" 254 | raise NotImplementedError() 255 | 256 | 257 | class ICSCalendarData: # pylint: disable=R0902 258 | """Class to use the calendar ICS client object to get next event.""" 259 | 260 | def __init__(self, hass: HomeAssistant, device_data): 261 | """Set up how we are going to connect to the URL. 262 | 263 | :param device_data Information about the calendar 264 | """ 265 | self.name = device_data[CONF_NAME] 266 | self._days = device_data[CONF_DAYS] 267 | self._offset_hours = device_data[CONF_OFFSET_HOURS] 268 | self.include_all_day = device_data[CONF_INCLUDE_ALL_DAY] 269 | self._summary_prefix: str = device_data[CONF_PREFIX] 270 | self._summary_default: str = device_data[CONF_SUMMARY_DEFAULT] 271 | self.parser = GetParser.get_parser(device_data[CONF_PARSER]) 272 | self.parser.set_filter( 273 | Filter(device_data[CONF_EXCLUDE], device_data[CONF_INCLUDE]) 274 | ) 275 | self.offset = None 276 | self.event = None 277 | self._hass = hass 278 | 279 | self._calendar_data = CalendarData( 280 | get_async_client(hass), 281 | _LOGGER, 282 | { 283 | "name": self.name, 284 | "url": device_data[CONF_URL], 285 | "min_update_time": timedelta( 286 | minutes=device_data[CONF_DOWNLOAD_INTERVAL] 287 | ), 288 | }, 289 | ) 290 | 291 | self._calendar_data.set_headers( 292 | device_data[CONF_USERNAME], 293 | device_data[CONF_PASSWORD], 294 | device_data[CONF_USER_AGENT], 295 | device_data[CONF_ACCEPT_HEADER], 296 | ) 297 | 298 | if device_data.get(CONF_SET_TIMEOUT): 299 | self._calendar_data.set_timeout( 300 | device_data[CONF_CONNECTION_TIMEOUT] 301 | ) 302 | 303 | async def async_get_events( 304 | self, start_date: datetime, end_date: datetime 305 | ) -> list[CalendarEvent]: 306 | """Get all events in a specific time frame. 307 | 308 | :param start_date: The first starting date to consider 309 | :type start_date: datetime 310 | :param end_date: The last starting date to consider 311 | :type end_date: datetime 312 | """ 313 | event_list: list[ParserEvent] = [] 314 | if await self._calendar_data.download_calendar(): 315 | _LOGGER.debug("%s: Setting calendar content", self.name) 316 | await self._hass.async_add_executor_job( 317 | lambda: self.parser.set_content(self._calendar_data.get()) 318 | ) 319 | try: 320 | event_list = self.parser.get_event_list( 321 | start=start_date, 322 | end=end_date, 323 | include_all_day=self.include_all_day, 324 | offset_hours=self._offset_hours, 325 | ) 326 | except: # pylint: disable=W0702 327 | _LOGGER.error( 328 | "async_get_events: %s: Failed to parse ICS!", 329 | self.name, 330 | exc_info=True, 331 | ) 332 | event_list: list[ParserEvent] = [] 333 | 334 | for event in event_list: 335 | event.summary = self._summary_prefix + event.summary 336 | if not event.summary: 337 | event.summary = self._summary_default 338 | # Since we skipped the validation code earlier, invoke it now, 339 | # before passing the object outside this component 340 | event.validate() 341 | 342 | return event_list 343 | 344 | async def async_update(self): 345 | """Get the current or next event.""" 346 | _LOGGER.debug("%s: Update was called", self.name) 347 | parser_event: ParserEvent | None = None 348 | if await self._calendar_data.download_calendar(): 349 | _LOGGER.debug("%s: Setting calendar content", self.name) 350 | await self._hass.async_add_executor_job( 351 | lambda: self.parser.set_content(self._calendar_data.get()) 352 | ) 353 | try: 354 | parser_event: ParserEvent | None = self.parser.get_current_event( 355 | include_all_day=self.include_all_day, 356 | now=hanow(), 357 | days=self._days, 358 | offset_hours=self._offset_hours, 359 | ) 360 | except: # pylint: disable=W0702 361 | _LOGGER.error( 362 | "update: %s: Failed to parse ICS!", self.name, exc_info=True 363 | ) 364 | if parser_event is not None: 365 | _LOGGER.debug( 366 | "%s: got event: %s; start: %s; end: %s; all_day: %s", 367 | self.name, 368 | parser_event.summary, 369 | parser_event.start, 370 | parser_event.end, 371 | parser_event.all_day, 372 | ) 373 | (summary, offset) = extract_offset(parser_event.summary, OFFSET) 374 | parser_event.summary = self._summary_prefix + summary 375 | if not parser_event.summary: 376 | parser_event.summary = self._summary_default 377 | self.offset = offset 378 | # Invoke validation here, since it was skipped when creating the 379 | # ParserEvent 380 | parser_event.validate() 381 | self.event: CalendarEvent = parser_event 382 | return True 383 | 384 | _LOGGER.debug("%s: No event found!", self.name) 385 | return False 386 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/calendardata.py: -------------------------------------------------------------------------------- 1 | """Provide CalendarData class.""" 2 | 3 | import re 4 | from logging import Logger 5 | from math import floor 6 | 7 | import httpx 8 | import httpx_auth 9 | from homeassistant.util.dt import now as hanow 10 | 11 | # from urllib.error import ContentTooShortError, HTTPError, URLError 12 | 13 | 14 | class DigestWithMultiAuth(httpx.DigestAuth, httpx_auth.SupportMultiAuth): 15 | """Describes a DigestAuth authentication.""" 16 | 17 | def __init__(self, username: str, password: str): 18 | """Construct Digest authentication that supports Multi Auth.""" 19 | httpx.DigestAuth.__init__(self, username, password) 20 | 21 | 22 | class CalendarData: # pylint: disable=R0902 23 | """CalendarData class. 24 | 25 | The CalendarData class is used to download and cache calendar data from a 26 | given URL. Use the get method to retrieve the data after constructing your 27 | instance. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | async_client: httpx.AsyncClient, 33 | logger: Logger, 34 | conf: dict, 35 | ): 36 | """Construct CalendarData object. 37 | 38 | :param async_client: An httpx.AsyncClient object for requests 39 | :type httpx.AsyncClient 40 | :param logger: The logger for reporting problems 41 | :type logger: Logger 42 | :param conf: Configuration options 43 | :type conf: dict 44 | """ 45 | self._auth = None 46 | self._calendar_data = None 47 | self._headers = [] 48 | self._last_download = None 49 | self._min_update_time = conf["min_update_time"] 50 | self.logger = logger 51 | self.name = conf["name"] 52 | self.url = conf["url"] 53 | self.connection_timeout = None 54 | self._httpx = async_client 55 | 56 | async def download_calendar(self) -> bool: 57 | """Download the calendar data. 58 | 59 | This only downloads data if self.min_update_time has passed since the 60 | last download. 61 | 62 | returns: True if data was downloaded, otherwise False. 63 | rtype: bool 64 | """ 65 | self.logger.debug("%s: download_calendar start", self.name) 66 | if ( 67 | self._calendar_data is None 68 | or self._last_download is None 69 | or (hanow() - self._last_download) > self._min_update_time 70 | ): 71 | self._calendar_data = None 72 | next_url: str = self._make_url() 73 | self.logger.debug( 74 | "%s: Downloading calendar data from: %s", 75 | self.name, 76 | next_url, 77 | ) 78 | await self._download_data(next_url) 79 | self._last_download = hanow() 80 | self.logger.debug("%s: download_calendar done", self.name) 81 | return self._calendar_data is not None 82 | 83 | self.logger.debug("%s: download_calendar skipped download", self.name) 84 | return False 85 | 86 | def get(self) -> str: 87 | """Get the calendar data that was downloaded. 88 | 89 | :return: The downloaded calendar data. 90 | :rtype: str 91 | """ 92 | return self._calendar_data 93 | 94 | def set_headers( 95 | self, 96 | user_name: str, 97 | password: str, 98 | user_agent: str, 99 | accept_header: str, 100 | ): 101 | """Set a user agent, accept header, and/or user name and password. 102 | 103 | The user name and password will be set into an auth object that 104 | supports both Basic Auth and Digest Auth for httpx. 105 | 106 | If the user_agent parameter is not "", a User-agent header will be 107 | added to the urlopener. 108 | 109 | :param user_name: The user name 110 | :type user_name: str 111 | :param password: The password 112 | :type password: str 113 | :param user_agent: The User Agent string to use or "" 114 | :type user_agent: str 115 | :param accept_header: The accept header string to use or "" 116 | :type accept_header: str 117 | """ 118 | if user_name != "" and password != "": 119 | self._auth = httpx_auth.Basic( 120 | user_name, password 121 | ) + DigestWithMultiAuth(user_name, password) 122 | 123 | if user_agent != "": 124 | self._headers.append(("User-agent", user_agent)) 125 | if accept_header != "": 126 | self._headers.append(("Accept", accept_header)) 127 | 128 | def set_timeout(self, connection_timeout: float): 129 | """Set the connection timeout. 130 | 131 | :param connection_timeout: The timeout value in seconds. 132 | :type connection_timeout: float 133 | """ 134 | self.connection_timeout = connection_timeout 135 | 136 | def _decode_data(self, data): 137 | return data.replace("\0", "") 138 | 139 | async def _download_data(self, url): # noqa: C901 140 | """Download the calendar data.""" 141 | self.logger.debug("%s: _download_data start", self.name) 142 | try: 143 | response = await self._httpx.get( 144 | url, 145 | auth=self._auth, 146 | headers=self._headers, 147 | follow_redirects=True, 148 | timeout=self.connection_timeout, 149 | ) 150 | if response.status_code >= 400: 151 | raise httpx.HTTPStatusError( 152 | "status error", request=None, response=response 153 | ) 154 | self._calendar_data = self._decode_data(response.text) 155 | self.logger.debug("%s: _download_data done", self.name) 156 | except httpx.HTTPStatusError as http_status_error: 157 | self.logger.error( 158 | "%s: Failed to open url(%s): %s", 159 | self.name, 160 | self.url, 161 | http_status_error.response.status_code, 162 | ) 163 | except httpx.TimeoutException: 164 | self.logger.error( 165 | "%s: Timeout opening url: %s", self.name, self.url 166 | ) 167 | except httpx.DecodingError: 168 | self.logger.error( 169 | "%s: Error decoding data from url: %s", self.name, self.url 170 | ) 171 | except httpx.InvalidURL: 172 | self.logger.error("%s: Invalid URL: %s", self.name, self.url) 173 | except httpx.HTTPError: 174 | self.logger.error( 175 | "%s: Error decoding data from url: %s", self.name, self.url 176 | ) 177 | except: # pylint: disable=W0702 178 | self.logger.error( 179 | "%s: Failed to open url!", self.name, exc_info=True 180 | ) 181 | 182 | def _make_url(self): 183 | """Replace templates in url and encode.""" 184 | now = hanow() 185 | year: int = now.year 186 | month: int = now.month 187 | url = self.url 188 | (month, year, url) = self._get_month_year(url, month, year) 189 | return url.replace("{year}", f"{year:04}").replace( 190 | "{month}", f"{month:02}" 191 | ) 192 | 193 | def _get_year_as_months(self, url: str, month: int) -> int: 194 | year_match = re.search("\\{year([-+])([0-9]+)\\}", url) 195 | if year_match: 196 | if year_match.group(1) == "-": 197 | month = month - (int(year_match.group(2)) * 12) 198 | else: 199 | month = month + (int(year_match.group(2)) * 12) 200 | url = url.replace(year_match.group(0), "{year}") 201 | return (month, url) 202 | 203 | def _get_month_year(self, url: str, month: int, year: int) -> int: 204 | (month, url) = self._get_year_as_months(url, month) 205 | print(f"month: {month}\n") 206 | month_match = re.search("\\{month([-+])([0-9]+)\\}", url) 207 | if month_match: 208 | if month_match.group(1) == "-": 209 | month = month - int(month_match.group(2)) 210 | else: 211 | month = month + int(month_match.group(2)) 212 | if month < 1: 213 | year -= floor(abs(month) / 12) + 1 214 | month = month % 12 215 | if month == 0: 216 | month = 12 217 | elif month > 12: 218 | year += abs(floor(month / 12)) 219 | month = month % 12 220 | if month == 0: 221 | month = 12 222 | year -= 1 223 | url = url.replace(month_match.group(0), "{month}") 224 | return (month, year, url) 225 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config Flow for ICS Calendar.""" 2 | 3 | import logging 4 | import re 5 | from typing import Any, Dict, Optional, Self 6 | from urllib.parse import quote 7 | 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 11 | from homeassistant.const import ( 12 | CONF_EXCLUDE, 13 | CONF_INCLUDE, 14 | CONF_NAME, 15 | CONF_PASSWORD, 16 | CONF_PREFIX, 17 | CONF_URL, 18 | CONF_USERNAME, 19 | ) 20 | from homeassistant.helpers.selector import selector 21 | 22 | from . import ( 23 | CONF_ACCEPT_HEADER, 24 | CONF_ADV_CONNECT_OPTS, 25 | CONF_CONNECTION_TIMEOUT, 26 | CONF_DAYS, 27 | CONF_DOWNLOAD_INTERVAL, 28 | CONF_INCLUDE_ALL_DAY, 29 | CONF_OFFSET_HOURS, 30 | CONF_PARSER, 31 | CONF_REQUIRES_AUTH, 32 | CONF_SET_TIMEOUT, 33 | CONF_SUMMARY_DEFAULT, 34 | CONF_USER_AGENT, 35 | ) 36 | from .const import CONF_SUMMARY_DEFAULT_DEFAULT, DOMAIN 37 | 38 | _LOGGER = logging.getLogger(__name__) 39 | 40 | CALENDAR_NAME_SCHEMA = vol.Schema( 41 | { 42 | vol.Required(CONF_NAME): cv.string, 43 | vol.Optional(CONF_DAYS, default=1): cv.positive_int, 44 | vol.Optional(CONF_INCLUDE_ALL_DAY, default=False): cv.boolean, 45 | } 46 | ) 47 | 48 | CALENDAR_OPTS_SCHEMA = vol.Schema( 49 | { 50 | vol.Optional(CONF_EXCLUDE, default=""): cv.string, 51 | vol.Optional(CONF_INCLUDE, default=""): cv.string, 52 | vol.Optional(CONF_PREFIX, default=""): cv.string, 53 | vol.Optional(CONF_DOWNLOAD_INTERVAL, default=15): cv.positive_int, 54 | vol.Optional(CONF_OFFSET_HOURS, default=0): int, 55 | vol.Optional(CONF_PARSER, default="rie"): selector( 56 | {"select": {"options": ["rie", "ics"], "mode": "dropdown"}} 57 | ), 58 | vol.Optional( 59 | CONF_SUMMARY_DEFAULT, default=CONF_SUMMARY_DEFAULT_DEFAULT 60 | ): cv.string, 61 | } 62 | ) 63 | 64 | CONNECT_OPTS_SCHEMA = vol.Schema( 65 | { 66 | vol.Required(CONF_URL): cv.string, 67 | vol.Optional(CONF_REQUIRES_AUTH, default=False): cv.boolean, 68 | vol.Optional(CONF_ADV_CONNECT_OPTS, default=False): cv.boolean, 69 | } 70 | ) 71 | 72 | AUTH_OPTS_SCHEMA = vol.Schema( 73 | { 74 | vol.Optional(CONF_USERNAME, default=""): cv.string, 75 | vol.Optional(CONF_PASSWORD, default=""): cv.string, 76 | } 77 | ) 78 | 79 | ADVANCED_CONNECT_OPTS_SCHEMA = vol.Schema( 80 | { 81 | vol.Optional(CONF_ACCEPT_HEADER, default=""): cv.string, 82 | vol.Optional(CONF_USER_AGENT, default=""): cv.string, 83 | vol.Optional(CONF_SET_TIMEOUT, default=False): cv.boolean, 84 | } 85 | ) 86 | 87 | TIMEOUT_OPTS_SCHEMA = vol.Schema( 88 | {vol.Optional(CONF_CONNECTION_TIMEOUT, default=None): cv.positive_float} 89 | ) 90 | 91 | 92 | def is_array_string(arr_str: str) -> bool: 93 | """Return true if arr_str starts with [ and ends with ].""" 94 | return arr_str.startswith("[") and arr_str.endswith("]") 95 | 96 | 97 | def format_url(url: str) -> str: 98 | """Format a URL using quote() and ensure any templates are not quoted.""" 99 | is_quoted = bool(re.search("%[0-9A-Fa-f][0-9A-Fa-f]", url)) 100 | if not is_quoted: 101 | year_match = re.search("\\{(year([-+][0-9]+)?)\\}", url) 102 | month_match = re.search("\\{(month([-+][0-9]+)?)\\}", url) 103 | has_template: bool = year_match or month_match 104 | url = quote(url, safe=":/?&=") 105 | if has_template: 106 | year_template = year_match.group(1) 107 | month_template = month_match.group(1) 108 | year_template1 = year_template.replace("+", "%2[Bb]") 109 | month_template1 = month_template.replace("+", "%2[Bb]") 110 | url = re.sub( 111 | f"%7[Bb]{year_template1}%7[Dd]", 112 | f"{{{year_template}}}", 113 | url, 114 | ) 115 | url = re.sub( 116 | f"%7[Bb]{month_template1}%7[Dd]", 117 | f"{{{month_template}}}", 118 | url, 119 | ) 120 | if url.startswith("webcal://"): 121 | url = re.sub("^webcal://", "https://", url) 122 | 123 | return url 124 | 125 | 126 | class ICSCalendarConfigFlow(ConfigFlow, domain=DOMAIN): 127 | """Config Flow for ICS Calendar.""" 128 | 129 | VERSION = 1 130 | MINOR_VERSION = 0 131 | 132 | data: Optional[Dict[str, Any]] 133 | 134 | def __init__(self): 135 | """Construct ICSCalendarConfigFlow.""" 136 | self.data = {} 137 | 138 | def is_matching(self, _other_flow: Self) -> bool: 139 | """Match discovery method. 140 | 141 | This method doesn't do anything, because this integration has no 142 | discoverable components. 143 | """ 144 | return False 145 | 146 | async def async_step_reauth(self, user_input=None): 147 | """Re-authenticateon auth error.""" 148 | # self.reauth_entry = self.hass.config_entries.async_get_entry( 149 | # self.context["entry_id"] 150 | # ) 151 | return await self.async_step_reauth_confirm(user_input) 152 | 153 | async def async_step_reauth_confirm( 154 | self, user_input=None 155 | ) -> ConfigFlowResult: 156 | """Dialog to inform user that reauthentication is required.""" 157 | if user_input is None: 158 | return self.async_show_form( 159 | step_id="reauth_confirm", data_schema=vol.Schema({}) 160 | ) 161 | return await self.async_step_user() 162 | 163 | # Don't allow reconfigure for now! 164 | # async def async_step_reconfigure( 165 | # self, user_input: dict[str, Any] | None = None 166 | # ) -> ConfigFlowResult: 167 | # """Reconfigure entry.""" 168 | # return await self.async_step_user(user_input) 169 | 170 | async def async_step_user( 171 | self, user_input: Optional[Dict[str, Any]] = None 172 | ) -> ConfigFlowResult: 173 | """Start of Config Flow.""" 174 | errors = {} 175 | if user_input is not None: 176 | user_input[CONF_NAME] = user_input[CONF_NAME].strip() 177 | if not user_input[CONF_NAME]: 178 | errors[CONF_NAME] = "empty_name" 179 | else: 180 | self._async_abort_entries_match( 181 | {CONF_NAME: user_input[CONF_NAME]} 182 | ) 183 | 184 | if not errors: 185 | self.data = user_input 186 | return await self.async_step_calendar_opts() 187 | 188 | return self.async_show_form( 189 | step_id="user", 190 | data_schema=CALENDAR_NAME_SCHEMA, 191 | errors=errors, 192 | last_step=False, 193 | ) 194 | 195 | async def async_step_calendar_opts( # noqa: R701,C901 196 | self, user_input: Optional[Dict[str, Any]] = None 197 | ): 198 | """Calendar Options step for ConfigFlow.""" 199 | errors = {} 200 | if user_input is not None: 201 | user_input[CONF_EXCLUDE] = user_input[CONF_EXCLUDE].strip() 202 | user_input[CONF_INCLUDE] = user_input[CONF_INCLUDE].strip() 203 | if ( 204 | user_input[CONF_EXCLUDE] 205 | and user_input[CONF_EXCLUDE] == user_input[CONF_INCLUDE] 206 | ): 207 | errors[CONF_EXCLUDE] = "exclude_include_cannot_be_the_same" 208 | else: 209 | if user_input[CONF_EXCLUDE] and not is_array_string( 210 | user_input[CONF_EXCLUDE] 211 | ): 212 | errors[CONF_EXCLUDE] = "exclude_must_be_array" 213 | if user_input[CONF_INCLUDE] and not is_array_string( 214 | user_input[CONF_INCLUDE] 215 | ): 216 | errors[CONF_INCLUDE] = "include_must_be_array" 217 | 218 | if user_input[CONF_DOWNLOAD_INTERVAL] < 15: 219 | _LOGGER.error("download_interval_too_small error") 220 | errors[CONF_DOWNLOAD_INTERVAL] = "download_interval_too_small" 221 | 222 | if not user_input[CONF_SUMMARY_DEFAULT]: 223 | user_input[CONF_SUMMARY_DEFAULT] = CONF_SUMMARY_DEFAULT_DEFAULT 224 | 225 | if not errors: 226 | self.data.update(user_input) 227 | return await self.async_step_connect_opts() 228 | 229 | return self.async_show_form( 230 | step_id="calendar_opts", 231 | data_schema=CALENDAR_OPTS_SCHEMA, 232 | errors=errors, 233 | last_step=False, 234 | ) 235 | 236 | async def async_step_connect_opts( 237 | self, user_input: Optional[Dict[str, Any]] = None 238 | ): 239 | """Connect Options step for ConfigFlow.""" 240 | errors = {} 241 | if user_input is not None: 242 | user_input[CONF_URL] = user_input[CONF_URL].strip() 243 | if not user_input[CONF_URL]: 244 | errors[CONF_URL] = "empty_url" 245 | 246 | if not errors: 247 | user_input[CONF_URL] = format_url(user_input[CONF_URL]) 248 | 249 | self.data.update(user_input) 250 | if user_input.get(CONF_REQUIRES_AUTH, False): 251 | return await self.async_step_auth_opts() 252 | if user_input.get(CONF_ADV_CONNECT_OPTS, False): 253 | return await self.async_step_adv_connect_opts() 254 | return self.async_create_entry( 255 | title=self.data[CONF_NAME], 256 | data=self.data, 257 | ) 258 | 259 | return self.async_show_form( 260 | step_id="connect_opts", 261 | data_schema=CONNECT_OPTS_SCHEMA, 262 | errors=errors, 263 | ) 264 | 265 | async def async_step_auth_opts( 266 | self, user_input: Optional[Dict[str, Any]] = None 267 | ): 268 | """Auth Options step for ConfigFlow.""" 269 | if user_input is not None: 270 | self.data.update(user_input) 271 | if self.data.get(CONF_ADV_CONNECT_OPTS, False): 272 | return await self.async_step_adv_connect_opts() 273 | return self.async_create_entry( 274 | title=self.data[CONF_NAME], 275 | data=self.data, 276 | ) 277 | 278 | return self.async_show_form( 279 | step_id="auth_opts", data_schema=AUTH_OPTS_SCHEMA 280 | ) 281 | 282 | async def async_step_adv_connect_opts( 283 | self, user_input: Optional[Dict[str, Any]] = None 284 | ): 285 | """Advanced Connection Options step for ConfigFlow.""" 286 | errors = {} 287 | if user_input is not None: 288 | 289 | if not errors: 290 | self.data.update(user_input) 291 | if user_input.get(CONF_SET_TIMEOUT, False): 292 | return await self.async_step_timeout_opts() 293 | return self.async_create_entry( 294 | title=self.data[CONF_NAME], 295 | data=self.data, 296 | ) 297 | 298 | return self.async_show_form( 299 | step_id="adv_connect_opts", 300 | data_schema=ADVANCED_CONNECT_OPTS_SCHEMA, 301 | errors=errors, 302 | ) 303 | 304 | async def async_step_timeout_opts( 305 | self, user_input: Optional[Dict[str, Any]] = None 306 | ): 307 | """Timeout Options step for ConfigFlow.""" 308 | errors = {} 309 | if user_input is not None: 310 | 311 | if not errors: 312 | self.data.update(user_input) 313 | return self.async_create_entry( 314 | title=self.data[CONF_NAME], 315 | data=self.data, 316 | ) 317 | 318 | return self.async_show_form( 319 | step_id="timeout_opts", 320 | data_schema=TIMEOUT_OPTS_SCHEMA, 321 | errors=errors, 322 | last_step=True, 323 | ) 324 | 325 | async def async_step_import(self, import_data): 326 | """Import config from configuration.yaml.""" 327 | return self.async_create_entry( 328 | title=import_data[CONF_NAME], 329 | data=import_data, 330 | ) 331 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/const.py: -------------------------------------------------------------------------------- 1 | """Constants for ics_calendar platform.""" 2 | 3 | VERSION = "5.1.3" 4 | DOMAIN = "ics_calendar" 5 | 6 | CONF_DEVICE_ID = "device_id" 7 | CONF_CALENDARS = "calendars" 8 | CONF_DAYS = "days" 9 | CONF_INCLUDE_ALL_DAY = "include_all_day" 10 | CONF_PARSER = "parser" 11 | CONF_DOWNLOAD_INTERVAL = "download_interval" 12 | CONF_USER_AGENT = "user_agent" 13 | CONF_OFFSET_HOURS = "offset_hours" 14 | CONF_ACCEPT_HEADER = "accept_header" 15 | CONF_CONNECTION_TIMEOUT = "connection_timeout" 16 | CONF_SET_TIMEOUT = "set_connection_timeout" 17 | CONF_REQUIRES_AUTH = "requires_auth" 18 | CONF_ADV_CONNECT_OPTS = "advanced_connection_options" 19 | CONF_SUMMARY_DEFAULT = "summary_default" 20 | # It'd be really nifty if this could be a translatable string, but it seems 21 | # that's not supported, unless I want to roll my own interpretation of the 22 | # translate/*.json files. :( 23 | # See also https://github.com/home-assistant/core/issues/125075 24 | CONF_SUMMARY_DEFAULT_DEFAULT = "No title" 25 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/filter.py: -------------------------------------------------------------------------------- 1 | """Provide Filter class.""" 2 | 3 | import re 4 | from ast import literal_eval 5 | from typing import List, Optional, Pattern 6 | 7 | from .parserevent import ParserEvent 8 | 9 | 10 | class Filter: 11 | """Filter class. 12 | 13 | The Filter class is used to filter events according to the exclude and 14 | include rules. 15 | """ 16 | 17 | def __init__(self, exclude: str, include: str): 18 | """Construct Filter class. 19 | 20 | :param exclude: The exclude rules 21 | :type exclude: str 22 | :param include: The include rules 23 | :type include: str 24 | """ 25 | self._exclude = Filter.set_rules(exclude) 26 | self._include = Filter.set_rules(include) 27 | 28 | @staticmethod 29 | def set_rules(rules: str) -> List[Pattern]: 30 | """Set the given rules into an array which is returned. 31 | 32 | :param rules: The rules to set 33 | :type rules: str 34 | :return: An array of regular expressions 35 | :rtype: List[Pattern] 36 | """ 37 | arr = [] 38 | if rules != "": 39 | for rule in literal_eval(rules): 40 | if rule.startswith("/"): 41 | re_flags = re.NOFLAG 42 | [expr, flags] = rule[1:].split("/") 43 | for flag in flags: 44 | match flag: 45 | case "i": 46 | re_flags |= re.IGNORECASE 47 | case "m": 48 | re_flags |= re.MULTILINE 49 | case "s": 50 | re_flags |= re.DOTALL 51 | arr.append(re.compile(expr, re_flags)) 52 | else: 53 | arr.append(re.compile(rule, re.IGNORECASE)) 54 | return arr 55 | 56 | def _is_match( 57 | self, summary: str, description: Optional[str], regexes: List[Pattern] 58 | ) -> bool: 59 | """Indicate if the event matches the given list of regular expressions. 60 | 61 | :param summary: The event summary to examine 62 | :type summary: str 63 | :param description: The event description summary to examine 64 | :type description: Optional[str] 65 | :param regexes: The regular expressions to match against 66 | :type regexes: List[] 67 | :return: True if the event matches the exclude filter 68 | :rtype: bool 69 | """ 70 | for regex in regexes: 71 | if regex.search(summary) or ( 72 | description and regex.search(description) 73 | ): 74 | return True 75 | 76 | return False 77 | 78 | def _is_excluded(self, summary: str, description: Optional[str]) -> bool: 79 | """Indicate if the event should be excluded. 80 | 81 | :param summary: The event summary to examine 82 | :type summary: str 83 | :param description: The event description summary to examine 84 | :type description: Optional[str] 85 | :return: True if the event matches the exclude filter 86 | :rtype: bool 87 | """ 88 | return self._is_match(summary, description, self._exclude) 89 | 90 | def _is_included(self, summary: str, description: Optional[str]) -> bool: 91 | """Indicate if the event should be included. 92 | 93 | :param summary: The event summary to examine 94 | :type summary: str 95 | :param description: The event description summary to examine 96 | :type description: Optional[str] 97 | :return: True if the event matches the include filter 98 | :rtype: bool 99 | """ 100 | return self._is_match(summary, description, self._include) 101 | 102 | def filter(self, summary: str, description: Optional[str]) -> bool: 103 | """Check if the event should be included or not. 104 | 105 | :param summary: The event summary to examine 106 | :type summary: str 107 | :param description: The event description summary to examine 108 | :type description: Optional[str] 109 | :return: true if the event should be included, otherwise false 110 | :rtype: bool 111 | """ 112 | add_event = not self._is_excluded(summary, description) 113 | if not add_event: 114 | add_event = self._is_included(summary, description) 115 | return add_event 116 | 117 | def filter_event(self, event: ParserEvent) -> bool: 118 | """Check if the event should be included or not. 119 | 120 | :param event: The event to examine 121 | :type event: ParserEvent 122 | :return: true if the event should be included, otherwise false 123 | :rtype: bool 124 | """ 125 | return self.filter(event.summary, event.description) 126 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/getparser.py: -------------------------------------------------------------------------------- 1 | """Provide GetParser class.""" 2 | 3 | from .icalendarparser import ICalendarParser 4 | from .parsers.parser_ics import ParserICS 5 | from .parsers.parser_rie import ParserRIE 6 | 7 | 8 | class GetParser: # pylint: disable=R0903 9 | """Provide get_parser to return an instance of ICalendarParser. 10 | 11 | The class provides a static method , get_instace, to get a parser instance. 12 | The non static methods allow this class to act as an "interface" for the 13 | parser classes. 14 | """ 15 | 16 | @staticmethod 17 | def get_parser(parser: str, *args) -> ICalendarParser | None: 18 | """Get an instance of the requested parser.""" 19 | # parser_cls = ICalendarParser.get_class(parser) 20 | # if parser_cls is not None: 21 | # return parser_cls(*args) 22 | if parser == "rie": 23 | return ParserRIE(*args) 24 | if parser == "ics": 25 | return ParserICS(*args) 26 | 27 | return None 28 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/icalendarparser.py: -------------------------------------------------------------------------------- 1 | """Provide ICalendarParser class.""" 2 | 3 | from datetime import datetime 4 | from typing import Optional 5 | 6 | from .filter import Filter 7 | from .parserevent import ParserEvent 8 | 9 | 10 | class ICalendarParser: 11 | """Provide interface for various parser classes.""" 12 | 13 | def set_content(self, content: str): 14 | """Parse content into a calendar object. 15 | 16 | This must be called at least once before get_event_list or 17 | get_current_event. 18 | :param content is the calendar data 19 | :type content str 20 | """ 21 | 22 | def set_filter(self, filt: Filter): 23 | """Set a Filter object to filter events. 24 | 25 | :param filt: The Filter object 26 | :type exclude: Filter 27 | """ 28 | 29 | def get_event_list( 30 | self, 31 | start: datetime, 32 | end: datetime, 33 | include_all_day: bool, 34 | offset_hours: int = 0, 35 | ) -> list[ParserEvent]: 36 | """Get a list of events. 37 | 38 | Gets the events from start to end, including or excluding all day 39 | events. 40 | :param start the earliest start time of events to return 41 | :type start datetime 42 | :param end the latest start time of events to return 43 | :type end datetime 44 | :param include_all_day if true, all day events will be included. 45 | :type include_all_day boolean 46 | :param offset_hours the number of hours to offset the event 47 | :type offset_hours int 48 | :returns a list of events, or an empty list 49 | :rtype list[ParserEvent] 50 | """ 51 | 52 | def get_current_event( 53 | self, 54 | include_all_day: bool, 55 | now: datetime, 56 | days: int, 57 | offset_hours: int = 0, 58 | ) -> Optional[ParserEvent]: 59 | """Get the current or next event. 60 | 61 | Gets the current event, or the next upcoming event with in the 62 | specified number of days, if there is no current event. 63 | :param include_all_day if true, all day events will be included. 64 | :type include_all_day boolean 65 | :param now the current date and time 66 | :type now datetime 67 | :param days the number of days to check for an upcoming event 68 | :type days int 69 | :param offset_hours the number of hours to offset the event 70 | :type offset_hours int 71 | :returns a ParserEvent or None 72 | """ 73 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ics_calendar", 3 | "name": "ics Calendar", 4 | "codeowners": ["@franc6"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/franc6/ics_calendar", 8 | "integration_type": "service", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/franc6/ics_calendar/issues", 11 | "requirements": ["icalendar~=6.1","python-dateutil>=2.9.0.post0","pytz>=2024.1","recurring_ical_events~=3.5,>=3.5.2","ics==0.7.2","arrow","httpx_auth>=0.22.0,<=0.23.1"], 12 | "version": "5.1.3" 13 | } 14 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/parserevent.py: -------------------------------------------------------------------------------- 1 | """Provide ParserEvent class.""" 2 | 3 | import dataclasses 4 | 5 | from homeassistant.components.calendar import CalendarEvent 6 | 7 | 8 | @dataclasses.dataclass 9 | class ParserEvent(CalendarEvent): 10 | """Class to represent CalendarEvent without validation.""" 11 | 12 | def validate(self) -> None: 13 | """Invoke __post_init__ from CalendarEvent.""" 14 | return super().__post_init__() 15 | 16 | def __post_init__(self) -> None: 17 | """Don't do validation steps for this class.""" 18 | # This is necessary to prevent problems when creating events that don't 19 | # have a summary. We'll add a summary after the event is created, not 20 | # before, to reduce code repitition. 21 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/parsers/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide parsers.""" 2 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/parsers/parser_ics.py: -------------------------------------------------------------------------------- 1 | """Support for ics parser.""" 2 | 3 | import re 4 | from datetime import date, datetime, timedelta 5 | from typing import Optional, Union 6 | 7 | from arrow import Arrow, get as arrowget 8 | from ics import Calendar 9 | 10 | from ..filter import Filter 11 | from ..icalendarparser import ICalendarParser 12 | from ..parserevent import ParserEvent 13 | from ..utility import compare_event_dates 14 | 15 | 16 | class ParserICS(ICalendarParser): 17 | """Class to provide parser using ics module.""" 18 | 19 | def __init__(self): 20 | """Construct ParserICS.""" 21 | self._re_method = re.compile("^METHOD:.*$", flags=re.MULTILINE) 22 | self._calendar = None 23 | self._filter = Filter("", "") 24 | 25 | def set_content(self, content: str): 26 | """Parse content into a calendar object. 27 | 28 | This must be called at least once before get_event_list or 29 | get_current_event. 30 | :param content is the calendar data 31 | :type content str 32 | """ 33 | self._calendar = Calendar(re.sub(self._re_method, "", content)) 34 | 35 | def set_filter(self, filt: Filter): 36 | """Set a Filter object to filter events. 37 | 38 | :param filt: The Filter object 39 | :type exclude: Filter 40 | """ 41 | self._filter = filt 42 | 43 | def get_event_list( 44 | self, start, end, include_all_day: bool, offset_hours: int = 0 45 | ) -> list[ParserEvent]: 46 | """Get a list of events. 47 | 48 | Gets the events from start to end, including or excluding all day 49 | events. 50 | :param start the earliest start time of events to return 51 | :type datetime 52 | :param end the latest start time of events to return 53 | :type datetime 54 | :param include_all_day if true, all day events will be included. 55 | :type boolean 56 | :param offset_hours the number of hours to offset the event 57 | :type offset_hours int 58 | :returns a list of events, or an empty list 59 | :rtype list[ParserEvent] 60 | """ 61 | event_list: list[ParserEvent] = [] 62 | 63 | if self._calendar is not None: 64 | # ics 0.8 takes datetime not Arrow objects 65 | # ar_start = start 66 | # ar_end = end 67 | ar_start = arrowget(start - timedelta(hours=offset_hours)) 68 | ar_end = arrowget(end - timedelta(hours=offset_hours)) 69 | 70 | for event in self._calendar.timeline.included(ar_start, ar_end): 71 | if event.all_day and not include_all_day: 72 | continue 73 | summary: str = "" 74 | # ics 0.8 uses 'summary' reliably, older versions use 'name' 75 | # if hasattr(event, "summary"): 76 | # summary = event.summary 77 | # elif hasattr(event, "name"): 78 | summary = event.name 79 | calendar_event: ParserEvent = ParserEvent( 80 | summary=summary, 81 | start=ParserICS.get_date( 82 | event.begin, event.all_day, offset_hours 83 | ), 84 | end=ParserICS.get_date( 85 | event.end, event.all_day, offset_hours 86 | ), 87 | location=event.location, 88 | description=event.description, 89 | ) 90 | if self._filter.filter_event(calendar_event): 91 | event_list.append(calendar_event) 92 | 93 | return event_list 94 | 95 | def get_current_event( # noqa: $701 96 | self, 97 | include_all_day: bool, 98 | now: datetime, 99 | days: int, 100 | offset_hours: int = 0, 101 | ) -> Optional[ParserEvent]: 102 | """Get the current or next event. 103 | 104 | Gets the current event, or the next upcoming event with in the 105 | specified number of days, if there is no current event. 106 | :param include_all_day if true, all day events will be included. 107 | :type boolean 108 | :param now the current date and time 109 | :type datetime 110 | :param days the number of days to check for an upcoming event 111 | :type int 112 | :param offset_hours the number of hours to offset the event 113 | :type int 114 | :returns a ParserEvent or None 115 | """ 116 | if self._calendar is None: 117 | return None 118 | 119 | temp_event = None 120 | now = now - timedelta(offset_hours) 121 | end = now + timedelta(days=days) 122 | for event in self._calendar.timeline.included( 123 | arrowget(now), arrowget(end) 124 | ): 125 | if event.all_day and not include_all_day: 126 | continue 127 | 128 | if not self._filter.filter(event.name, event.description): 129 | continue 130 | 131 | if temp_event is None or compare_event_dates( 132 | now, 133 | temp_event.end, 134 | temp_event.begin, 135 | temp_event.all_day, 136 | event.end, 137 | event.begin, 138 | event.all_day, 139 | ): 140 | temp_event = event 141 | 142 | if temp_event is None: 143 | return None 144 | # if hasattr(event, "summary"): 145 | # summary = temp_event.summary 146 | # elif hasattr(event, "name"): 147 | summary = temp_event.name 148 | return ParserEvent( 149 | summary=summary, 150 | start=ParserICS.get_date( 151 | temp_event.begin, temp_event.all_day, offset_hours 152 | ), 153 | end=ParserICS.get_date( 154 | temp_event.end, temp_event.all_day, offset_hours 155 | ), 156 | location=temp_event.location, 157 | description=temp_event.description, 158 | ) 159 | 160 | @staticmethod 161 | def get_date( 162 | arw: Arrow, is_all_day: bool, offset_hours: int 163 | ) -> Union[datetime, date]: 164 | """Get datetime. 165 | 166 | :param arw The arrow object representing the date. 167 | :type Arrow 168 | :param is_all_day If true, the returned datetime will have the time 169 | component set to 0. 170 | :type: bool 171 | :param offset_hours the number of hours to offset the event 172 | :type int 173 | :returns The datetime. 174 | :rtype datetime 175 | """ 176 | # if isinstance(arw, Arrow): 177 | if is_all_day: 178 | return arw.date() 179 | # else: 180 | # if arw.tzinfo is None or arw.tzinfo.utcoffset(arw) is None 181 | # or is_all_day: 182 | # arw = arw.astimezone() 183 | # if is_all_day: 184 | # return arw.date() 185 | # 186 | arw = arw.shift(hours=offset_hours) 187 | 188 | return_value = arw.datetime 189 | if return_value.tzinfo is None: 190 | return_value = return_value.astimezone() 191 | return return_value 192 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/parsers/parser_rie.py: -------------------------------------------------------------------------------- 1 | """Support for recurring_ical_events parser.""" 2 | 3 | from datetime import date, datetime, timedelta 4 | from typing import Optional, Union 5 | 6 | import recurring_ical_events as rie 7 | from icalendar import Calendar 8 | 9 | from ..filter import Filter 10 | from ..icalendarparser import ICalendarParser 11 | from ..parserevent import ParserEvent 12 | from ..utility import compare_event_dates 13 | 14 | 15 | class ParserRIE(ICalendarParser): 16 | """Provide parser using recurring_ical_events.""" 17 | 18 | def __init__(self): 19 | """Construct ParserRIE.""" 20 | self._calendar = None 21 | self.oneday = timedelta(days=1) 22 | self.oneday2 = timedelta(hours=23, minutes=59, seconds=59) 23 | self._filter = Filter("", "") 24 | 25 | def set_content(self, content: str): 26 | """Parse content into a calendar object. 27 | 28 | This must be called at least once before get_event_list or 29 | get_current_event. 30 | :param content is the calendar data 31 | :type content str 32 | """ 33 | self._calendar = Calendar.from_ical(content) 34 | 35 | def set_filter(self, filt: Filter): 36 | """Set a Filter object to filter events. 37 | 38 | :param filt: The Filter object 39 | :type exclude: Filter 40 | """ 41 | self._filter = filt 42 | 43 | def get_event_list( 44 | self, 45 | start: datetime, 46 | end: datetime, 47 | include_all_day: bool, 48 | offset_hours: int = 0, 49 | ) -> list[ParserEvent]: 50 | """Get a list of events. 51 | 52 | Gets the events from start to end, including or excluding all day 53 | events. 54 | :param start the earliest start time of events to return 55 | :type datetime 56 | :param end the latest start time of events to return 57 | :type datetime 58 | :param include_all_day if true, all day events will be included. 59 | :type boolean 60 | :param offset_hours the number of hours to offset the event 61 | :type offset_hours int 62 | :returns a list of events, or an empty list 63 | :rtype list[ParserEvent] 64 | """ 65 | event_list: list[ParserEvent] = [] 66 | 67 | if self._calendar is not None: 68 | for event in rie.of(self._calendar, skip_bad_series=True).between( 69 | start - timedelta(hours=offset_hours), 70 | end - timedelta(hours=offset_hours), 71 | ): 72 | start, end, all_day = self.is_all_day(event, offset_hours) 73 | 74 | if all_day and not include_all_day: 75 | continue 76 | 77 | calendar_event: ParserEvent = ParserEvent( 78 | summary=event.get("SUMMARY"), 79 | start=start, 80 | end=end, 81 | location=event.get("LOCATION"), 82 | description=event.get("DESCRIPTION"), 83 | ) 84 | if self._filter.filter_event(calendar_event): 85 | event_list.append(calendar_event) 86 | 87 | return event_list 88 | 89 | def get_current_event( # noqa: R701 90 | self, 91 | include_all_day: bool, 92 | now: datetime, 93 | days: int, 94 | offset_hours: int = 0, 95 | ) -> Optional[ParserEvent]: 96 | """Get the current or next event. 97 | 98 | Gets the current event, or the next upcoming event with in the 99 | specified number of days, if there is no current event. 100 | :param include_all_day if true, all day events will be included. 101 | :type boolean 102 | :param now the current date and time 103 | :type datetime 104 | :param days the number of days to check for an upcoming event 105 | :type int 106 | :param offset_hours the number of hours to offset the event 107 | :type offset_hours int 108 | :returns a ParserEvent or None 109 | """ 110 | if self._calendar is None: 111 | return None 112 | 113 | temp_event = None 114 | temp_start: date | datetime = None 115 | temp_end: date | datetime = None 116 | temp_all_day: bool = None 117 | end: datetime = now + timedelta(days=days) 118 | for event in rie.of(self._calendar, skip_bad_series=True).between( 119 | now - timedelta(hours=offset_hours), 120 | end - timedelta(hours=offset_hours), 121 | ): 122 | start, end, all_day = self.is_all_day(event, offset_hours) 123 | 124 | if all_day and not include_all_day: 125 | continue 126 | 127 | if not self._filter.filter( 128 | event.get("SUMMARY"), event.get("DESCRIPTION") 129 | ): 130 | continue 131 | 132 | if temp_start is None or compare_event_dates( 133 | now, temp_end, temp_start, temp_all_day, end, start, all_day 134 | ): 135 | temp_event = event 136 | temp_start = start 137 | temp_end = end 138 | temp_all_day = all_day 139 | 140 | if temp_event is None: 141 | return None 142 | 143 | return ParserEvent( 144 | summary=temp_event.get("SUMMARY"), 145 | start=temp_start, 146 | end=temp_end, 147 | location=temp_event.get("LOCATION"), 148 | description=temp_event.get("DESCRIPTION"), 149 | ) 150 | 151 | @staticmethod 152 | def get_date(date_time) -> Union[datetime, date]: 153 | """Get datetime with timezone information. 154 | 155 | If a date object is passed, it will first have a time component added, 156 | set to 0. 157 | :param date_time The date or datetime object 158 | :type date_time datetime or date 159 | :type: bool 160 | :returns The datetime. 161 | :rtype datetime 162 | """ 163 | # Must use type here, since a datetime is also a date! 164 | if isinstance(date_time, date) and not isinstance(date_time, datetime): 165 | date_time = datetime.combine(date_time, datetime.min.time()) 166 | return date_time.astimezone() 167 | 168 | def is_all_day(self, event, offset_hours: int): 169 | """Determine if the event is an all day event. 170 | 171 | Return all day status and start and end times for the event. 172 | :param event The event to examine 173 | :param offset_hours the number of hours to offset the event 174 | :type offset_hours int 175 | """ 176 | start: datetime | date = ParserRIE.get_date(event.get("DTSTART").dt) 177 | end: datetime | date = ParserRIE.get_date(event.get("DTEND").dt) 178 | all_day = False 179 | diff = event.get("DURATION") 180 | if diff is not None: 181 | diff = diff.dt 182 | else: 183 | diff = end - start 184 | if (start == end or diff in {self.oneday, self.oneday2}) and all( 185 | x == 0 for x in [start.hour, start.minute, start.second] 186 | ): 187 | # if all_day, start and end must be date, not datetime! 188 | start = start.date() 189 | end = end.date() 190 | all_day = True 191 | else: 192 | start = start + timedelta(hours=offset_hours) 193 | end = end + timedelta(hours=offset_hours) 194 | if start.tzinfo is None: 195 | start = start.astimezone() 196 | if end.tzinfo is None: 197 | end = end.astimezone() 198 | 199 | return start, end, all_day 200 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "YAML configuration is deprecated for ICS Calendar", 5 | "description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file." 6 | } 7 | }, 8 | "title": "ICS Calendar", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Name", 14 | "days": "Days", 15 | "include_all_day": "Include all day events?" 16 | }, 17 | "title": "Add Calendar" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "Exclude filter", 22 | "include": "Include filter", 23 | "prefix": "String to prefix all event summaries", 24 | "download_interval": "Download interval (minutes)", 25 | "offset_hours": "Number of hours to offset event times", 26 | "parser": "Parser (rie or ics)", 27 | "summary_default": "Summary if event doesn't have one" 28 | }, 29 | "title": "Calendar Options" 30 | }, 31 | "connect_opts": { 32 | "data": { 33 | "url": "URL of ICS file", 34 | "requires_auth": "Requires authentication?", 35 | "advanced_connection_options": "Set advanced connection options?" 36 | }, 37 | "title": "Connection Options" 38 | }, 39 | "auth_opts": { 40 | "data": { 41 | "username": "Username", 42 | "password": "Password" 43 | }, 44 | "description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.", 45 | "title": "Authentication" 46 | }, 47 | "adv_connect_opts": { 48 | "data": { 49 | "accept_header": "Custom Accept header for broken servers", 50 | "user_agent": "Custom User-agent header", 51 | "set_connection_timeout": "Change connection timeout?" 52 | }, 53 | "title": "Advanced Connection Options" 54 | }, 55 | "timeout_opts": { 56 | "data": { 57 | "connection_timeout": "Connection timeout in seconds" 58 | }, 59 | "title": "Connection Timeout Options" 60 | }, 61 | "reauth_confirm": { 62 | "description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.", 63 | "title": "Authorization Failure for ICS Calendar" 64 | } 65 | }, 66 | "error": { 67 | "empty_name": "The calendar name must not be empty.", 68 | "empty_url": "The url must not be empty.", 69 | "download_interval_too_small": "The download interval must be at least 15.", 70 | "exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same", 71 | "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", 72 | "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." 73 | }, 74 | "abort": { 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /custom_components/ics_calendar/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "YAML-Konfiguration für ICS-Kalender ist veraltet", 5 | "description": "Die YAML-Konfiguration von ics_calendar ist veraltet und wird in ics_calendar v5.0.0 entfernt. Deine Konfigurationselemente wurden importiert. Bitte entferne sie aus deiner configuration.yaml-Datei." 6 | } 7 | }, 8 | "title": "ICS-Kalender", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Name", 14 | "days": "Tage", 15 | "include_all_day": "Ganztägige Ereignisse einbeziehen?" 16 | }, 17 | "title": "Kalender hinzufügen" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "auszuschließende Ereignisse", 22 | "include": "einzuschließende Ereignisse", 23 | "prefix": "String, um allen Zusammenfassungen ein Präfix hinzuzufügen", 24 | "download_interval": "Download-Intervall (Minuten)", 25 | "offset_hours": "Anzahl der Stunden, um Ereigniszeiten zu versetzen", 26 | "parser": "Parser (rie oder ics)" 27 | }, 28 | "title": "Kalender-Optionen" 29 | }, 30 | "connect_opts": { 31 | "data": { 32 | "url": "URL der ICS-Datei", 33 | "requires_auth": "Erfordert Authentifizierung?", 34 | "advanced_connection_options": "Erweiterte Verbindungsoptionen festlegen?" 35 | }, 36 | "title": "Verbindungsoptionen" 37 | }, 38 | "auth_opts": { 39 | "data": { 40 | "username": "Benutzername", 41 | "password": "Passwort" 42 | }, 43 | "description": "Bitte beachte, dass nur HTTP Basic Auth und HTTP Digest Auth unterstützt wird. Authentifizierungsmethoden wie OAuth werden derzeit nicht unterstützt.", 44 | "title": "Authentifizierung" 45 | }, 46 | "adv_connect_opts": { 47 | "data": { 48 | "accept_header": "Eigener Accept-Header für fehlerhafte Server", 49 | "user_agent": "Eigener User-Agent-Header", 50 | "set_connection_timeout": "Verbindungstimeout ändern?" 51 | }, 52 | "title": "Erweiterte Verbindungsoptionen" 53 | }, 54 | "timeout_opts": { 55 | "data": { 56 | "connection_timeout": "Verbindungstimeout in Sekunden" 57 | }, 58 | "title": "Verbindungstimeout-Optionen" 59 | }, 60 | "reauth_confirm": { 61 | "description": "Die Autorisierung für den Kalender ist fehlgeschlagen. Bitte konfiguriere die Kalender-URL und/oder die Authentifizierungseinstellungen neu.", 62 | "title": "Autorisierungsfehler für ICS-Kalender" 63 | } 64 | }, 65 | "error": { 66 | "empty_name": "Der Kalendername darf nicht leer sein.", 67 | "empty_url": "Die URL darf nicht leer sein.", 68 | "download_interval_too_small": "Das Download-Intervall muss mindestens 15 betragen.", 69 | "exclude_include_cannot_be_the_same": "Die Ausschluss- und Einschluss-Strings dürfen nicht identisch sein.", 70 | "exclude_must_be_array": "Die \"auszuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter https://github.com/franc6/ics_calendar/blob/releases/README.md#filters.", 71 | "include_must_be_array": "Die \"einzuschließenden Ereignisse\" müssen ein Array von Zeichenfolgen oder regulären Ausdrücken sein. Weitere Informationen finden Sie unter https://github.com/franc6/ics_calendar/blob/releases/README.md#filters." 72 | }, 73 | "abort": { 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "YAML configuration is deprecated for ICS Calendar", 5 | "description": "YAML configuration of ics_calendar is deprecated and will be removed in ics_calendar v5.0.0. Your configuration items have been imported. Please remove them from your configuration.yaml file." 6 | } 7 | }, 8 | "title": "ICS Calendar", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Name", 14 | "days": "Days", 15 | "include_all_day": "Include all day events?" 16 | }, 17 | "title": "Add Calendar" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "Exclude filter", 22 | "include": "Include filter", 23 | "prefix": "String to prefix all event summaries", 24 | "download_interval": "Download interval (minutes)", 25 | "offset_hours": "Number of hours to offset event times", 26 | "parser": "Parser (rie or ics)", 27 | "summary_default": "Summary if event doesn't have one" 28 | }, 29 | "title": "Calendar Options" 30 | }, 31 | "connect_opts": { 32 | "data": { 33 | "url": "URL of ICS file", 34 | "requires_auth": "Requires authentication?", 35 | "advanced_connection_options": "Set advanced connection options?" 36 | }, 37 | "title": "Connection Options" 38 | }, 39 | "auth_opts": { 40 | "data": { 41 | "username": "Username", 42 | "password": "Password" 43 | }, 44 | "description": "Please note this component supports only HTTP Basic Auth and HTTP Digest Auth. More advanced authentication, like OAuth is not supported at this time.", 45 | "title": "Authentication" 46 | }, 47 | "adv_connect_opts": { 48 | "data": { 49 | "accept_header": "Custom Accept header for broken servers", 50 | "user_agent": "Custom User-agent header", 51 | "set_connection_timeout": "Change connection timeout?" 52 | }, 53 | "title": "Advanced Connection Options" 54 | }, 55 | "timeout_opts": { 56 | "data": { 57 | "connection_timeout": "Connection timeout in seconds" 58 | }, 59 | "title": "Connection Timeout Options" 60 | }, 61 | "reauth_confirm": { 62 | "description": "Authorization failed for calendar. Please re-configured the calendar URL and/or authentication settings.", 63 | "title": "Authorization Failure for ICS Calendar" 64 | } 65 | }, 66 | "error": { 67 | "empty_name": "The calendar name must not be empty.", 68 | "empty_url": "The url must not be empty.", 69 | "download_interval_too_small": "The download interval must be at least 15.", 70 | "exclude_include_cannot_be_the_same": "The exclude and include strings must not be the same", 71 | "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", 72 | "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." 73 | }, 74 | "abort": { 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /custom_components/ics_calendar/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "La configuración YAML está obsoleta para ICS Calendar", 5 | "description": "La configuración YAML de ics_calendar está obsoleta y se eliminará en ics_calendar v5.0.0. Sus elementos de configuración se han importado. Elimínelos de su archivo configuration.yaml." 6 | } 7 | }, 8 | "title": "ICS Calendar", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Nombre", 14 | "days": "Días", 15 | "include_all_day": "¿Incluir eventos de todo el día?" 16 | }, 17 | "title": "Agregar calendario" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "Excluir filtro", 22 | "include": "Incluir filtro", 23 | "prefix": "Cadena que precederá a todos los resúmenes de eventos", 24 | "download_interval": "Intervalo de descarga (minutos)", 25 | "offset_hours": "Número de horas para compensar los tiempos del evento", 26 | "parser": "Parser (rie or ics)", 27 | "summary_default": "Resumen si el evento no tiene uno" 28 | }, 29 | "title": "Opciones de calendario" 30 | }, 31 | "connect_opts": { 32 | "data": { 33 | "url": "URL del archivo ICS", 34 | "requires_auth": "¿Requiere autentificación?", 35 | "advanced_connection_options": "¿Establecer opciones de conexión avanzadas?" 36 | }, 37 | "title": "Opciones de conexión" 38 | }, 39 | "auth_opts": { 40 | "data": { 41 | "username": "Nombre de usuario", 42 | "password": "Contraseña" 43 | }, 44 | "description": "Tenga en cuenta que este componente solo admite la autenticación básica HTTP y la autenticación HTTP Digest. Actualmente, no se admiten autenticaciones más avanzadas, como OAuth.", 45 | "title": "Autentificación" 46 | }, 47 | "adv_connect_opts": { 48 | "data": { 49 | "accept_header": "Encabezado Accept personalizado para servidores rotos", 50 | "user_agent": "Encabezado de agente de usuario personalizado", 51 | "set_connection_timeout": "¿Cambiar el tiempo de espera de la conexión?" 52 | }, 53 | "title": "Opciones avanzadas de conexión" 54 | }, 55 | "timeout_opts": { 56 | "data": { 57 | "connection_timeout": "Tiempo de espera de la conexión en segundos" 58 | }, 59 | "title": "Opciones de tiempo de espera de la conexión" 60 | }, 61 | "reauth_confirm": { 62 | "description": "Error de autorización para el calendario. Vuelva a configurar la URL del calendario y/o los ajustes de autenticación.", 63 | "title": "Fallo de autorización para ICS Calendar" 64 | } 65 | }, 66 | "error": { 67 | "empty_name": "El nombre del calendario no debe estar vacío.", 68 | "empty_url": "La url no debe estar vacía.", 69 | "download_interval_too_small": "El intervalo de descarga debe ser de al menos 15.", 70 | "exclude_include_cannot_be_the_same": "Las cadenas de exclusión e inclusión no deben ser las mismas", 71 | "exclude_must_be_array": "La opción de exclusión debe ser una matriz de cadenas o expresiones regulares. Consulte https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para obtener más información.", 72 | "include_must_be_array": "La opción de inclusión debe ser un array de cadenas o expresiones regulares. Consulte https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para obtener más información." 73 | }, 74 | "abort": { 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "La configuration YAML pour ICS Calendar est obsolète", 5 | "description": "La configuration YAML d'ICS Calendar est obsolète et sera supprimée dans la version 5.0.0 d'ics_calendar. Les éléments de votre configuration ont été importés. Veuillez les supprimer de votre fichier configuration.yaml." 6 | } 7 | }, 8 | "title": "ICS Calendar", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Nom", 14 | "days": "Jours", 15 | "include_all_day": "Inclure les événements à la journée ?" 16 | }, 17 | "title": "Ajouter un calendrier" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "Exclure les événements contenant", 22 | "include": "Inclure les événements contenant", 23 | "prefix": "Préfixer tous les résumés d'événements avec", 24 | "download_interval": "Intervalle de téléchargement (minutes)", 25 | "offset_hours": "Décalage à appliquer aux horaires des événements (heures)", 26 | "parser": "Parseur (rie ou ics)" 27 | }, 28 | "title": "Options du calendrier" 29 | }, 30 | "connect_opts": { 31 | "data": { 32 | "url": "URL du fichier ICS", 33 | "requires_auth": "Authentification requise ?", 34 | "advanced_connection_options": "Définir les options avancées de la connexion ?" 35 | }, 36 | "title": "Options de connexion" 37 | }, 38 | "auth_opts": { 39 | "data": { 40 | "username": "Utilisateur", 41 | "password": "Mot de passe" 42 | }, 43 | "description": "Veuillez noter que cette intégration ne supporte que les modes d'authentification HTTP Basic et HTTP Digest. Les méthodes d'authentification plus avancées, telles que OAuth, ne sont pas supportées actuellement.", 44 | "title": "Authentification" 45 | }, 46 | "adv_connect_opts": { 47 | "data": { 48 | "accept_header": "Entête 'Accept' personnalisée pour les serveurs injoignables", 49 | "user_agent": "Entête 'User-agent' personnalisée", 50 | "set_connection_timeout": "Modifier le délai maximum autorisé pour la connexion ?" 51 | }, 52 | "title": "Options avancées de connexion" 53 | }, 54 | "timeout_opts": { 55 | "data": { 56 | "connection_timeout": "Délai maximum autorisé pour la connexion (secondes)" 57 | }, 58 | "title": "Options de délai de connexion" 59 | }, 60 | "reauth_confirm": { 61 | "description": "L'autorisation a échoué pour le calendrier. Veuillez vérifier l'URL du calendrier et/ou les paramètres d'authentification.", 62 | "title": "Échec d'autorisation pour ICS Calendar" 63 | } 64 | }, 65 | "error": { 66 | "empty_name": "Le nom du calendrier doit être renseigné.", 67 | "empty_url": "L'URL du calendrier doit être renseignée.", 68 | "download_interval_too_small": "L'intervalle de téléchargement ne peut pas être inférieur à 15 minutes.", 69 | "exclude_include_cannot_be_the_same": "Les valeurs d'exclusion et d'inclusion ne peuvent pas être identiques.", 70 | "exclude_must_be_array": "The exclude option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information.", 71 | "include_must_be_array": "The include option must be an array of strings or regular expressions. See https://github.com/franc6/ics_calendar/blob/releases/README.md#filters for more information." 72 | }, 73 | "abort": { 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/translations/pt-br.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": { 3 | "YAML_Warning": { 4 | "title": "A configuração YAML está obsoleta para o ICS Calendar", 5 | "description": "A configuração YAML do ics_calendar está obsoleta e será removida na versão 5.0.0 do ics_calendar. Seus itens de configuração foram importados. Por favor, remova-os do seu arquivo configuration.yaml." 6 | } 7 | }, 8 | "title": "ICS Calendar", 9 | "config": { 10 | "step": { 11 | "user": { 12 | "data": { 13 | "name": "Nome", 14 | "days": "Dias", 15 | "include_all_day": "Incluir eventos de dia inteiro?" 16 | }, 17 | "title": "Adicionar Calendário" 18 | }, 19 | "calendar_opts": { 20 | "data": { 21 | "exclude": "Filtro de exclusão", 22 | "include": "Filtro de inclusão", 23 | "prefix": "Texto para prefixar todos os resumos de eventos", 24 | "download_interval": "Intervalo de download (minutos)", 25 | "offset_hours": "Número de horas para ajustar os horários dos eventos", 26 | "parser": "Parser (rie ou ics)", 27 | "summary_default": "Resumo padrão se o evento não tiver um" 28 | }, 29 | "title": "Opções do Calendário" 30 | }, 31 | "connect_opts": { 32 | "data": { 33 | "url": "URL do arquivo ICS", 34 | "requires_auth": "Requer autenticação?", 35 | "advanced_connection_options": "Definir opções de conexão avançadas?" 36 | }, 37 | "title": "Opções de Conexão" 38 | }, 39 | "auth_opts": { 40 | "data": { 41 | "username": "Usuário", 42 | "password": "Senha" 43 | }, 44 | "description": "Este componente oferece suporte apenas para HTTP Basic Auth e HTTP Digest Auth. Métodos de autenticação mais avançados, como OAuth, ainda não são suportados.", 45 | "title": "Autenticação" 46 | }, 47 | "adv_connect_opts": { 48 | "data": { 49 | "accept_header": "Cabeçalho Accept personalizado para servidores com problemas", 50 | "user_agent": "Cabeçalho User-agent personalizado", 51 | "set_connection_timeout": "Alterar tempo limite de conexão?" 52 | }, 53 | "title": "Opções Avançadas de Conexão" 54 | }, 55 | "timeout_opts": { 56 | "data": { 57 | "connection_timeout": "Tempo limite de conexão em segundos" 58 | }, 59 | "title": "Opções de Tempo Limite de Conexão" 60 | }, 61 | "reauth_confirm": { 62 | "description": "A autorização falhou para o calendário. Por favor, reconfigure a URL do calendário e/ou as configurações de autenticação.", 63 | "title": "Falha de Autorização para o ICS Calendar" 64 | } 65 | }, 66 | "error": { 67 | "empty_name": "O nome do calendário não pode estar vazio.", 68 | "empty_url": "A URL não pode estar vazia.", 69 | "download_interval_too_small": "O intervalo de download deve ser de pelo menos 15.", 70 | "exclude_include_cannot_be_the_same": "As strings de exclusão e inclusão não podem ser as mesmas.", 71 | "exclude_must_be_array": "A opção de exclusão deve ser um array de strings ou expressões regulares. Veja https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para mais informações.", 72 | "include_must_be_array": "A opção de inclusão deve ser um array de strings ou expressões regulares. Veja https://github.com/franc6/ics_calendar/blob/releases/README.md#filters para mais informações." 73 | }, 74 | "abort": { 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /custom_components/ics_calendar/utility.py: -------------------------------------------------------------------------------- 1 | """Utility methods.""" 2 | 3 | from datetime import date, datetime 4 | 5 | 6 | def make_datetime(val): 7 | """Ensure val is a datetime, not a date.""" 8 | if isinstance(val, date) and not isinstance(val, datetime): 9 | return datetime.combine(val, datetime.min.time()).astimezone() 10 | return val 11 | 12 | 13 | def compare_event_dates( # pylint: disable=R0913,R0917 14 | now, end2, start2, all_day2, end, start, all_day 15 | ) -> bool: 16 | """Determine if end2 and start2 are newer than end and start.""" 17 | # Make sure we only compare datetime values, not dates with datetimes. 18 | # Set each date object to a datetime at midnight. 19 | end = make_datetime(end) 20 | end2 = make_datetime(end2) 21 | start = make_datetime(start) 22 | start2 = make_datetime(start2) 23 | 24 | if all_day2 == all_day: 25 | if end2 == end: 26 | return start2 > start 27 | return end2 > end and start2 >= start 28 | 29 | if now.tzinfo is None: 30 | now = now.astimezone() 31 | 32 | event2_current = start2 <= now <= end2 33 | event_current = start <= now <= end 34 | 35 | if event_current and event2_current: 36 | return all_day 37 | 38 | return start2 >= start or end2 >= end 39 | -------------------------------------------------------------------------------- /formatstyle.sh: -------------------------------------------------------------------------------- 1 | # see pyproject.toml for settings 2 | echo "Sorting imports" 3 | isort custom_components/ics_calendar tests 4 | 5 | echo "Formatting files" 6 | black custom_components/ics_calendar tests 7 | 8 | echo "Formatting json files" 9 | for i in custom_components/ics_calendar/*.json custom_components/ics_calendar/translations/*.json 10 | do 11 | echo " $i" 12 | python -m json.tool $i > /dev/null || exit 13 | python -m json.tool $i > $i.new 14 | if diff $i $i.new >/dev/null 2>/dev/null 15 | then 16 | cat $i.new > $i 17 | fi 18 | rm $i.new 19 | done 20 | 21 | echo "flake8 style and complexity checks" 22 | flake8 custom_components/**/*.py || exit 23 | 24 | echo "pydocstyle checks" 25 | pydocstyle -v custom_components/ics_calendar tests || exit 26 | 27 | echo "pylint checks" 28 | pylint custom_components/ics_calendar || exit 29 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "homeassistant": "2024.8.0", 3 | "name": "ICS Calendar (iCalendar)" 4 | } 5 | -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franc6/ics_calendar/9392ab6719f4c77b5af27036d3b4fcd2f73c26fe/icons/icon.png -------------------------------------------------------------------------------- /icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franc6/ics_calendar/9392ab6719f4c77b5af27036d3b4fcd2f73c26fe/icons/icon@2x.png -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # ics_calendar 2 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg)](https://github.com/hacs/integration) [![ics_calendar](https://img.shields.io/github/v/release/franc6/ics_calendar.svg?1)](https://github.com/franc6/ics_calendar) [![Coverage](https://codecov.io/gh/franc6/ics_calendar/branch/releases/graph/badge.svg)](https://app.codecov.io/gh/franc6/ics_calendar/branch/releases) ![Maintained:yes](https://img.shields.io/maintenance/yes/2023.svg) [![License](https://img.shields.io/github/license/franc6/ics_calendar.svg)](LICENSE) 3 | 4 | Provides a component for ICS (icalendar) calendars for Home Assistant 5 | 6 | > **NOTE**: This component is intended for use with simple hosting of ICS files. If your server supports CalDAV, please use the caldav calendar platform instead. This one might work, but probably not well. 7 | 8 | ## Warning when installing in HA in a container 9 | A number of users have reported problems when running HA in a container. If you see any of these messages in your log, 10 | ``` 11 | - Setup failed for custom integration 'ics_calendar': Requirements for ics_calendar not found 12 | - ModuleNotFoundError: No module named 'ics' 13 | - ModuleNotFoundError: No module named 'recurring-icalendar-events' 14 | - ImportError: Exception importing custom_components.ics_calendar.calendar 15 | - AttributeError: module 'icalendar' has no attribute 'InvalidCalendar' 16 | ``` 17 | 18 | Then your problem stems from a dependency installation issue. This problem is supposed to be resolved in HA 2025.1, but it's possible that you still have trouble, because of one or more of the following: 19 | 20 | 1. you are running in an unsupported container environment 21 | 2. you have made custom modifications to the container 22 | 3. you have made custom modifications to the container in order to resolve an earlier dependency problem, for this integration or another 23 | 24 | If you encounter a dependency installation problem, please see https://github.com/home-assistant/core/issues/127966 and https://github.com/home-assistant/core/pull/125808 which explain the problem in more detail, and the fix that was applied. If those do not help you resolve the dependency problem, please note that the author will be unable to help. Please do not open an issue on GitHub for this problem. It's not a bug in ics_calendar, and it cannot be resolved by changing ics_calendar. You can find the full list of runtime dependencies and versions in the [manifest.json](custom_components/ics_calendar/manifest.json) in the "requirements" value. 25 | 26 | 27 | ## Authentication 28 | This component supports HTTP Basic Auth and HTTP Digest Auth. It does not support more advanced authentication methods. 29 | 30 | ## Configuration 31 | Configuration is done via UI now. Go to https://my.home-assistant.io/redirect/integrations/ and click "Add Integration" to add ICS Calendar. You'll want to do this for each calendar for this integration. 32 | 33 | Please note that if you previously used configuration.yaml, you can remove those entries after updating to a version that supports UI configuration. 34 | 35 | ## Example configuration.yaml 36 | ```yaml 37 | ics_calendar: 38 | calendars: 39 | - name: "Name of calendar" 40 | url: "https://url.to/calendar.ics" 41 | - name: "Name of another calendar" 42 | url: "https://url.to/other_calendar.ics" 43 | include_all_day: True 44 | - name: "Name of a calendar that requires authentication" 45 | url: "https://url.to/auth/calendar.ics" 46 | include_all_day: True 47 | username: True 48 | password: !secret auth_calendar 49 | ``` 50 | 51 | ## Configuration options 52 | Key | Type | Required | Description 53 | -- | -- | -- | -- 54 | `calendars` | `list` | `True` | The list of remote calendars to check 55 | 56 | ### Configuration options for `calendar` list 57 | Key | Type | Required | Description 58 | -- | -- | -- | -- 59 | `name` | `string` | `True` | A name for the calendar 60 | `url` | `string` | `True` | The URL of the calendar (https and file URI schemes are supported) 61 | `accept_header` | `string` | An accept header for servers that are misconfigured, default is not set 62 | `connection_timeout` | `float` | `None` | Sets a timeout in seconds for the connection to download the calendar. Use this if you have frequent connection issues with a calendar 63 | `days` | `positive integer` | `False` | The number of days to look ahead (only affects the attributes of the calendar entity), default is 1 64 | `download_interval` | `positive integer` | `False` | The time between downloading new calendar data, in minutes, default is 15 65 | `exclude` | `string` | `False` | Allows for filtering of events, see below 66 | `include` | `string` | `False` | Allows for filtering of events, see below 67 | `include_all_day` | `boolean` | `False` | Set to True if all day events should be included 68 | `offset_hours` | `int` | `False` | A number of hours (positive or negative) to offset times by, see below 69 | `parser` | `string` | `False` | 'rie' or 'ics', defaults to 'rie' if not present 70 | `prefix` | `string` | `False` | Specify a string to prefix every event summary with, see below 71 | `username` | `string` | `False` | If the calendar requires authentication, this specifies the user name 72 | `password` | `string` | `False` | If the calendar requires authentication, this specifies the password 73 | `user_agent` | `string` | `False` | Allows setting the User-agent header. Only specify this if your server rejects the normal python user-agent string. You must set the entire and exact user agent string here. 74 | 75 | #### Download Interval 76 | The download interval should be a multiple of 15 at this time. This is so downloads coincide with Home Assistant's update interval for the calendar entities. Setting a value smaller than 15 will increase both CPU and memory usage. Higher values will reduce CPU usage. The default of 15 is to keep the same behavior with regards to downloads as in the past. 77 | 78 | #### Offset Hours 79 | This feature is to aid with calendars that present incorrect times. If your calendar has an incorrect time, e.g. it lists your local time, but indicates that it's the time in UTC, this can be used to correct for your local time. This affects all events, except all day events. All day events do not include time information, and so the offset will not be applied. Use a positive number to add hours to the time, and a negative number to subtract hours from the time. 80 | 81 | #### Prefix 82 | This feature prefixes each summary with the given string. You might want to have some whitespace between the prefix and original event summary. You must include whatever whitespace you want in your configuration, so be sure to quote the string. E.g.: 83 | 84 | ```yaml 85 | ics_calendar: 86 | calendars: 87 | - name: "Name of calendar" 88 | url: "https://url.to/calendar.ics" 89 | prefix: 'ICS Prefix ' 90 | ``` 91 | 92 | ## Parsers 93 | ics_calendar uses one of two parsers for generating events from calendars. These parsers are written and maintained by third parties, not by me. Each comes with its own sets of problems. 94 | 95 | Version 1.x used "ics" which does not handle recurring events, and has a few other problems (see issues #6, #8, and #18). The "ics" parser is also very strict, and will frequently give parsing errors for files which do not conform to RFC 5545. Some of the most popular calendaring programs produce files that do not conform to the RFC. The "ics" parser also tends to require more memory and processing power. Several users have noted that it's unusuable for HA systems running on Raspberry pi computers. 96 | 97 | The Version 2.0.0 betas used "icalevents" which is a little more forgiving, but has a few problems with recurring event rules. All-day events which repeat until a specific date and time are a particular issue (all-day events which repeat until a specific date are just fine). 98 | 99 | In Version 2.5 and later, a new parser, "rie" is the default. Like "icalevents", it's based on the "icalendar" library. This parser appears to fix both issues #8 and #36, which are problematic for "icalevents". 100 | 101 | Starting with version 2.7, "icalevents" is no longer available. If you have specified icalevents as the parser, please change it to rie or ics. 102 | 103 | As a general rule, I recommend sticking with the "rie" parser, which is the default. If you see parsing errors, you can try switching to "ics" for the calendar with the parsing errors. 104 | 105 | ## Filters 106 | The new exclude and include options allow for filtering events in the calendar. This is a string representation of an array of strings or regular expressions. They are used as follows: 107 | 108 | The exclude filters are applied first, searching in the summary and description only. If an event is excluded by the exclude filters, the include filters will be checked to determine if the event should be included anyway. 109 | 110 | Regular expressions can be used, by surrounding the string with slashes (/). You can also specify case insensitivity, multi-line match, and dotall matches by appending i, m, or s (respectively) after the ending slash. If you don't understand what that means, you probably just want to stick with plain string matching. For example, if you specify "['/^test/i']" as your exclude filter, then any event whose summary or description starts with "test", "Test", "TEST", etc. will be excluded. 111 | 112 | For plain string matching, the string will be searched for in a case insensitive manner. For example, if you specify "['test']" for your exclude filter, any event whose summary or description contains "test", "Test", "TEST", etc. will be excluded. Since this is not a whole-word match, this means if the summary or description contains "testing" or "stesting", the event will be excluded. 113 | 114 | You can also include multiple entries for exclude or include. 115 | 116 | > **NOTE**: If you want to include only events that include a specific string, you **must** use an exclude filter that excludes everything in addition to your include filter. E.g. "['/.*/']" 117 | 118 | ### Examples 119 | ```yaml 120 | ics_calendar: 121 | calendars: 122 | - name: "Name of calendar" 123 | url: "https://url.to/calendar.ics" 124 | exclude: "['test', '/^regex$/']" 125 | include: "['keepme']" 126 | ``` 127 | 128 | This example will exclude any event whose summary or description includes "test" in a case insensitive manner, or if the summary or description is "regex". However, if the summary or description includes "keepme" (case insensitive), the event will be included anyway. 129 | 130 | ## URL Templates 131 | If your ICS url requires specifying the current year and/or month, you can now use templates to specify the current year and month. E.g. if you set your url to: 132 | ```yaml 133 | url: "https://www.a-url?year={year}&month={month}" 134 | ``` 135 | 136 | The "{year}" part will be replaced with the current 4 digit year, and the "{month}" part will be replaced with the current 2 digit month. So in February 2023, the URL will be "https://www.a-url?year=2023&month=02", in November 2024, the URL will be "https://www.a-url?year=2024&month=11". 137 | 138 | You can also specify positive and negative offsets for the year and month templates. E.g. if you set your url to: 139 | ```yaml 140 | url: "https://www.a-url?year={year+1}&month={month-3}" 141 | ``` 142 | 143 | The "{year+1}" part will be replaced with the next 4 digit year, and the "{month-3}" part will be replaced with the 2 digit month of three months ago. So in February 2023, the URL will be "https://www.a-url?year=2023&month=11", in November 2024, the URL will be "https://www.a-url?year=2025&month=8". 144 | 145 | [![Buy me some pizza](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/qpunYPZx5) 146 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | if test -n "$1" ; then 2 | TEST=$1 3 | else 4 | TEST=tests/ 5 | fi 6 | 7 | TOP=`pwd` 8 | rm -rf htmlcov/* coverage.xml 9 | PYTHONDONTWRITEBYTECODE=1 uv run --prerelease=allow pytest ${TEST} 10 | echo "To view coverage, open ${TOP}/htmlcov/index.html" 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Unit Tests.""" 2 | -------------------------------------------------------------------------------- /tests/allday.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:TOM 4 | BEGIN:VEVENT 5 | DTSTART:20220103 6 | DTEND:20220104 7 | SUMMARY:1 All Day, No Times, No Time Zone 8 | END:VEVENT 9 | BEGIN:VEVENT 10 | DTSTART:20220103T000000 11 | DTEND:20220104T000000 12 | SUMMARY:2 All Day, Time, No Time Zone 13 | END:VEVENT 14 | BEGIN:VEVENT 15 | DTSTART;TZID=America/New_York:20220103 16 | DTEND;TZID=America/New_York:20220104 17 | SUMMARY:3 All Day, No Times, TZID, current time zone 18 | END:VEVENT 19 | BEGIN:VEVENT 20 | DTSTART;TZID=America/New_York:20220103T000000 21 | DTEND;TZID=America/New_York:20220104T000000 22 | SUMMARY:4 All Day, Time, TZID, current time zone 23 | END:VEVENT 24 | BEGIN:VEVENT 25 | DTSTART;TZID=America/Chicago:20220103T000000 26 | DTEND;TZID=America/Chicago:20220104T000000 27 | SUMMARY:5 Not All Day, Time, TZID, not current time zone 28 | END:VEVENT 29 | BEGIN:VEVENT 30 | DTSTART:20220103T000000Z 31 | DTEND:20220104T000000Z 32 | SUMMARY:6 Not All Day, Time, Zulu 33 | END:VEVENT 34 | BEGIN:VEVENT 35 | DTSTART:20220103T000000 36 | DTEND:20220103T235959 37 | SUMMARY:7 All Day, 00:00:00 - 23:59:59, No Time Zone 38 | END:VEVENT 39 | BEGIN:VEVENT 40 | DTSTART:20220103 41 | DURATION:+P1D 42 | SUMMARY:8 All Day, No Time, No End, No Time Zone 43 | END:VEVENT 44 | BEGIN:VEVENT 45 | DTSTART:20220103T000000 46 | DURATION:+P1D 47 | SUMMARY:9 All Day, Time, No End, No Time Zone 48 | END:VEVENT 49 | BEGIN:VEVENT 50 | DTSTART;TZID=America/Chicago:20220103T130000 51 | DTEND;TZID=America/Chicago:20220103T140000 52 | SUMMARY:10 Not All Day, Time, TZID, 1pm - 2pm Chicago 53 | END:VEVENT 54 | BEGIN:VEVENT 55 | UID:ABC123 56 | DTSTART:20220103T130000 57 | DTEND:20220103T140000 58 | SUMMARY:11 Not All Day, Time, No Time Zone, 1pm - 2pm local time 59 | END:VEVENT 60 | END:VCALENDAR 61 | -------------------------------------------------------------------------------- /tests/allday.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "all_day": true, 4 | "summary": "1 All Day, No Times, No Time Zone", 5 | "start": "2022-01-03", 6 | "end": "2022-01-04" 7 | }, 8 | { 9 | "all_day": true, 10 | "summary": "2 All Day, Time, No Time Zone", 11 | "start": "2022-01-03", 12 | "end": "2022-01-04" 13 | }, 14 | { 15 | "all_day": true, 16 | "summary": "3 All Day, No Times, TZID, current time zone", 17 | "start": "2022-01-03", 18 | "end": "2022-01-04" 19 | }, 20 | { 21 | "all_day": true, 22 | "summary": "4 All Day, Time, TZID, current time zone", 23 | "start": "2022-01-03", 24 | "end": "2022-01-04" 25 | }, 26 | { 27 | "all_day": false, 28 | "summary": "5 Not All Day, Time, TZID, not current time zone", 29 | "start": "2022-01-03T01:00:00-05:00", 30 | "end": "2022-01-04T01:00:00-05:00" 31 | }, 32 | { 33 | "all_day": false, 34 | "summary": "6 Not All Day, Time, Zulu", 35 | "start": "2022-01-02T19:00:00-05:00", 36 | "end": "2022-01-03T19:00:00-05:00" 37 | }, 38 | { 39 | "all_day": true, 40 | "summary": "7 All Day, 00:00:00 - 23:59:59, No Time Zone", 41 | "start": "2022-01-03", 42 | "end": "2022-01-03" 43 | }, 44 | { 45 | "all_day": true, 46 | "summary": "8 All Day, No Time, No End, No Time Zone", 47 | "start": "2022-01-03", 48 | "end": "2022-01-04" 49 | }, 50 | { 51 | "all_day": true, 52 | "summary": "9 All Day, Time, No End, No Time Zone", 53 | "start": "2022-01-03", 54 | "end": "2022-01-04" 55 | }, 56 | { 57 | "all_day": false, 58 | "summary": "10 Not All Day, Time, TZID, 1pm - 2pm Chicago", 59 | "start": "2022-01-03T14:00:00-05:00", 60 | "end": "2022-01-03T15:00:00-05:00" 61 | }, 62 | { 63 | "all_day": false, 64 | "summary": "11 Not All Day, Time, No Time Zone, 1pm - 2pm local time", 65 | "start": "2022-01-03T13:00:00-05:00", 66 | "end": "2022-01-03T14:00:00-05:00" 67 | } 68 | ] 69 | 70 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures and helpers for tests.""" 2 | 3 | import json 4 | import logging 5 | from http import HTTPStatus 6 | 7 | import pytest 8 | from dateutil import parser as dtparser 9 | 10 | from custom_components.ics_calendar.const import DOMAIN 11 | from custom_components.ics_calendar.getparser import GetParser 12 | 13 | 14 | # Fixtures for test_calendar.py 15 | @pytest.fixture 16 | def set_tz(request): 17 | """Fake the timezone fixture.""" 18 | return request.getfixturevalue(request.param) 19 | 20 | 21 | @pytest.fixture 22 | async def utc(hass): 23 | """Set current time zone for HomeAssistant to UTC.""" 24 | await hass.config.async_set_time_zone("UTC") 25 | 26 | 27 | @pytest.fixture 28 | async def chicago(hass): 29 | """Set current time zone for HomeAssistant to America/Chicago.""" 30 | await hass.config.async_set_time_zone("America/Chicago") 31 | 32 | 33 | @pytest.fixture 34 | async def baghdad(hass): 35 | """Set current time zone for HomeAssistant to Asia/Baghdad.""" 36 | await hass.config.async_set_time_zone("Asia/Baghdad") 37 | 38 | 39 | @pytest.fixture 40 | def get_api_events(hass_client): 41 | """Provide fixture to mock get_api_events.""" 42 | 43 | async def api_call(entity_id): 44 | client = await hass_client() 45 | response = await client.get( 46 | f"/api/calendars/{entity_id}?start=2022-01-01&end=2022-01-06" 47 | ) 48 | assert response.status == HTTPStatus.OK 49 | return await response.json() 50 | 51 | return api_call 52 | 53 | 54 | @pytest.fixture() 55 | def allday_config(): 56 | """Provide fixture for config that includes allday events.""" 57 | return { 58 | DOMAIN: { 59 | "calendars": [ 60 | { 61 | "name": "allday", 62 | "url": "http://test.local/tests/allday.ics", 63 | "include_all_day": "true", 64 | "days": "1", 65 | } 66 | ], 67 | } 68 | } 69 | 70 | 71 | @pytest.fixture() 72 | def summary_default_config(): 73 | """Provide fixture for config that includes allday events.""" 74 | return { 75 | DOMAIN: { 76 | "calendars": [ 77 | { 78 | "name": "summary_default", 79 | "url": "http://test.local/tests/allday.ics", 80 | "include_all_day": "true", 81 | "days": "1", 82 | "summary_default": "Summary was empty", 83 | } 84 | ], 85 | } 86 | } 87 | 88 | 89 | @pytest.fixture() 90 | def noallday_config(): 91 | """Provide fixture for config that does not include allday events.""" 92 | return { 93 | DOMAIN: { 94 | "calendars": [ 95 | { 96 | "name": "noallday", 97 | "url": "http://test.local/tests/allday.ics", 98 | "include_all_day": "false", 99 | "days": "1", 100 | } 101 | ], 102 | } 103 | } 104 | 105 | 106 | @pytest.fixture() 107 | def positive_offset_hours_config(): 108 | """Provide fixture for config that does not include allday events.""" 109 | return { 110 | DOMAIN: { 111 | "calendars": [ 112 | { 113 | "name": "positive_offset_hours", 114 | "url": "http://test.local/tests/allday.ics", 115 | "include_all_day": "false", 116 | "days": "1", 117 | "offset_hours": 5, 118 | } 119 | ], 120 | } 121 | } 122 | 123 | 124 | @pytest.fixture() 125 | def negative_offset_hours_config(): 126 | """Provide fixture for config that does not include allday events.""" 127 | return { 128 | DOMAIN: { 129 | "calendars": [ 130 | { 131 | "name": "negative_offset_hours", 132 | "url": "http://test.local/tests/allday.ics", 133 | "include_all_day": "false", 134 | "days": "1", 135 | "offset_hours": -5, 136 | } 137 | ], 138 | } 139 | } 140 | 141 | 142 | @pytest.fixture() 143 | def prefix_config(): 144 | """Provide fixture for config that does not include allday events.""" 145 | return { 146 | DOMAIN: { 147 | "calendars": [ 148 | { 149 | "name": "prefix", 150 | "url": "http://test.local/tests/allday.ics", 151 | "include_all_day": "false", 152 | "days": "1", 153 | "prefix": "PREFIX ", 154 | } 155 | ], 156 | } 157 | } 158 | 159 | 160 | @pytest.fixture() 161 | def acceptheader_config(): 162 | """Provide fixture for config that uses user name and password.""" 163 | return { 164 | DOMAIN: { 165 | "calendars": [ 166 | { 167 | "name": "acceptheader", 168 | "url": "http://test.local/tests/allday.ics", 169 | "include_all_day": "false", 170 | "days": "1", 171 | "accept_header": "text/calendar", 172 | } 173 | ], 174 | } 175 | } 176 | 177 | 178 | @pytest.fixture() 179 | def useragent_config(): 180 | """Provide fixture for config that uses user name and password.""" 181 | return { 182 | DOMAIN: { 183 | "calendars": [ 184 | { 185 | "name": "useragent", 186 | "url": "http://test.local/tests/allday.ics", 187 | "include_all_day": "false", 188 | "days": "1", 189 | "user_agent": "Mozilla/5.0", 190 | } 191 | ], 192 | } 193 | } 194 | 195 | 196 | @pytest.fixture() 197 | def userpass_config(): 198 | """Provide fixture for config that uses user name and password.""" 199 | return { 200 | DOMAIN: { 201 | "calendars": [ 202 | { 203 | "name": "userpass", 204 | "url": "http://test.local/tests/allday.ics", 205 | "include_all_day": "false", 206 | "days": "1", 207 | "username": "username", 208 | "password": "password", 209 | } 210 | ], 211 | } 212 | } 213 | 214 | 215 | @pytest.fixture() 216 | def timeout_config(): 217 | """Provide fixture for config that uses user name and password.""" 218 | return { 219 | DOMAIN: { 220 | "calendars": [ 221 | { 222 | "name": "timeout", 223 | "url": "http://test.local/tests/allday.ics", 224 | "include_all_day": "false", 225 | "days": "1", 226 | "connection_timeout": "1.5", 227 | } 228 | ], 229 | } 230 | } 231 | 232 | 233 | # Fixtures and methods for test_parsers.py 234 | def datetime_hook(pairs): 235 | """Parse datetime values from JSON.""" 236 | _dict = {} 237 | for key, value in pairs: 238 | if isinstance(value, str): 239 | try: 240 | _dict[key] = dtparser.parse(value) 241 | if "T" not in value: 242 | _dict[key] = _dict[key].date() 243 | except ValueError: 244 | _dict[key] = value 245 | else: 246 | _dict[key] = value 247 | return _dict 248 | 249 | 250 | @pytest.fixture 251 | def rie_parser(): 252 | """Fixture for rie parser.""" 253 | return GetParser.get_parser("rie") 254 | 255 | 256 | @pytest.fixture 257 | def ics_parser(): 258 | """Fixture for ics parser.""" 259 | return GetParser.get_parser("ics") 260 | 261 | 262 | @pytest.fixture 263 | def parser(which_parser, request): 264 | """Fixture for getting a parser. 265 | 266 | :param which_parser identifies the parser fixture, ParserRIE or ParserICS 267 | :type which_parser str 268 | :param request the request for the fixture 269 | :returns an instance of the requested parser 270 | :rtype ICalendarParser 271 | """ 272 | return request.getfixturevalue(which_parser) 273 | 274 | 275 | @pytest.fixture() 276 | def calendar_data(file_name): 277 | """Return contents of a file with embedded NULL removed. 278 | 279 | :param fileName The name of the file 280 | :type str 281 | :returns the data 282 | :rtype str 283 | """ 284 | with open(f"tests/{file_name}", encoding="utf-8") as file_handle: 285 | return file_handle.read().replace("\0", "") 286 | 287 | 288 | @pytest.fixture() 289 | def expected_name(file_name): 290 | """Return {fileName}. 291 | 292 | :param fileName: The base name of the file 293 | :type fileName: str 294 | """ 295 | return file_name 296 | 297 | 298 | @pytest.fixture() 299 | def expected_data(file_name, expected_name): 300 | """Return content of tests/{fileName}.expected.json. 301 | 302 | :param fileName: The base name of the file 303 | :type fileName: str 304 | """ 305 | with open( 306 | f"tests/{expected_name}.expected.json", encoding="utf-8" 307 | ) as file_handle: 308 | return json.loads(file_handle.read(), object_pairs_hook=datetime_hook) 309 | 310 | 311 | @pytest.fixture(autouse=True) 312 | def logger(): 313 | """Provide autouse fixture for logger.""" 314 | return logging.getLogger(__name__) 315 | 316 | 317 | @pytest.helpers.register 318 | def assert_event_list_size(expected, event_list): 319 | """Assert event_list is not None and has the expected number of events. 320 | 321 | :param expected: The number of events that should be in event_list 322 | :type expected: int 323 | :param event_list: The array of events to check 324 | :type event_list: array 325 | """ 326 | assert event_list is not None 327 | assert expected == len(event_list) 328 | 329 | 330 | @pytest.helpers.register 331 | def compare_event_list(expected, actual): 332 | """Assert that each item in expected matches the item in actual. 333 | 334 | :param expected: The data to expect 335 | :type expected: dict 336 | :param actual: The data to check 337 | :type actual: dict 338 | """ 339 | for expected_part, actual_part in zip(expected, actual): 340 | pytest.helpers.compare_event(expected_part, actual_part) 341 | 342 | 343 | @pytest.helpers.register 344 | def compare_event(expected, actual): 345 | """Assert actual event matches expected event. 346 | 347 | :param expected: The event to expect 348 | :type expected: dict 349 | :param actual: The event to check 350 | :type actual: dict 351 | """ 352 | for key in expected.keys(): 353 | assert expected is not None 354 | assert actual is not None 355 | assert expected[key] == getattr(actual, key) 356 | -------------------------------------------------------------------------------- /tests/issue125.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:TOM 4 | BEGIN:VEVENT 5 | DTSTART:20220103 6 | DTEND:20220104 7 | SUMMARY: 8 | DESCRIPTION:Test description 9 | END:VEVENT 10 | END:VCALENDAR 11 | -------------------------------------------------------------------------------- /tests/issue17.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:课表 7 | X-WR-TIMEZONE:Asia/Hong_Kong 8 | BEGIN:VTIMEZONE 9 | TZID:Asia/Shanghai 10 | X-LIC-LOCATION:location 11 | BEGIN:STANDARD 12 | TZOFFSETFROM:+0800 13 | TZOFFSETTO:+0800 14 | TZNAME:CST 15 | DTSTART:19700101T000000 16 | END:STANDARD 17 | END:VTIMEZONE 18 | BEGIN:VEVENT 19 | DTSTART;TZID=Asia/Shanghai:20200914T140000 20 | DTEND;TZID=Asia/Shanghai:20200914T153500 21 | RRULE:FREQ=WEEKLY;UNTIL=20200928T160000Z;INTERVAL=1 22 | DTSTAMP:20200921T133750Z 23 | UID:WakeUpSchedule-f52f47c9-93e9-4cba-ae81-82b6c4558142 24 | CREATED:20200906T170121Z 25 | DESCRIPTION:description5 26 | LAST-MODIFIED:20200906T170121Z 27 | LOCATION:location 28 | SEQUENCE:0 29 | STATUS:CONFIRMED 30 | SUMMARY:summary1 31 | TRANSP:OPAQUE 32 | END:VEVENT 33 | BEGIN:VEVENT 34 | DTSTART;TZID=Asia/Shanghai:20200915T095500 35 | DTEND;TZID=Asia/Shanghai:20200915T113000 36 | RRULE:FREQ=WEEKLY;UNTIL=20200929T160000Z;INTERVAL=1 37 | DTSTAMP:20200921T133750Z 38 | UID:WakeUpSchedule-11585824-7b62-4dc8-bdb7-233b53e8f70e 39 | CREATED:20200906T170121Z 40 | DESCRIPTION:description5 41 | LAST-MODIFIED:20200906T170121Z 42 | LOCATION:location 43 | SEQUENCE:0 44 | STATUS:CONFIRMED 45 | SUMMARY:summary2 46 | TRANSP:OPAQUE 47 | END:VEVENT 48 | BEGIN:VEVENT 49 | DTSTART;TZID=Asia/Shanghai:20200907T080000 50 | DTEND;TZID=Asia/Shanghai:20200907T093500 51 | RRULE:FREQ=WEEKLY;UNTIL=20201019T160000Z;INTERVAL=1 52 | DTSTAMP:20200921T133750Z 53 | UID:WakeUpSchedule-5485f20b-d28b-4c6b-b081-a7424a904a45 54 | CREATED:20200906T170121Z 55 | DESCRIPTION:description5 56 | LAST-MODIFIED:20200906T170121Z 57 | LOCATION:location 58 | SEQUENCE:0 59 | STATUS:CONFIRMED 60 | SUMMARY:summary3 61 | TRANSP:OPAQUE 62 | END:VEVENT 63 | BEGIN:VEVENT 64 | DTSTART;TZID=Asia/Shanghai:20200908T080000 65 | DTEND;TZID=Asia/Shanghai:20200908T093500 66 | RRULE:FREQ=WEEKLY;UNTIL=20201027T160000Z;INTERVAL=1 67 | DTSTAMP:20200921T133750Z 68 | UID:WakeUpSchedule-efde1f15-0f72-4026-aea5-1f605a9c2943 69 | CREATED:20200906T170122Z 70 | DESCRIPTION:description5 71 | LAST-MODIFIED:20200906T170122Z 72 | LOCATION:location 73 | SEQUENCE:0 74 | STATUS:CONFIRMED 75 | SUMMARY:summary4 76 | TRANSP:OPAQUE 77 | END:VEVENT 78 | BEGIN:VEVENT 79 | DTSTART;TZID=Asia/Shanghai:20201013T095500 80 | DTEND;TZID=Asia/Shanghai:20201013T113000 81 | RRULE:FREQ=WEEKLY;UNTIL=20201020T160000Z;INTERVAL=1 82 | DTSTAMP:20200921T133750Z 83 | UID:WakeUpSchedule-8bacd160-12aa-4b94-b7b4-df39b396b850 84 | CREATED:20200906T170122Z 85 | DESCRIPTION:description5 86 | LAST-MODIFIED:20200906T170122Z 87 | LOCATION:location 88 | SEQUENCE:0 89 | STATUS:CONFIRMED 90 | SUMMARY:summary5 91 | TRANSP:OPAQUE 92 | END:VEVENT 93 | BEGIN:VEVENT 94 | DTSTART;TZID=Asia/Shanghai:20200909T080000 95 | DTEND;TZID=Asia/Shanghai:20200909T093500 96 | RRULE:FREQ=WEEKLY;UNTIL=20201028T160000Z;INTERVAL=1 97 | DTSTAMP:20200921T133750Z 98 | UID:WakeUpSchedule-f7538a26-8abf-4e4f-9117-26e2fdf79c91 99 | CREATED:20200906T170122Z 100 | DESCRIPTION:description5 101 | LAST-MODIFIED:20200906T170122Z 102 | LOCATION:location 103 | SEQUENCE:0 104 | STATUS:CONFIRMED 105 | SUMMARY:summary6 106 | TRANSP:OPAQUE 107 | END:VEVENT 108 | BEGIN:VEVENT 109 | DTSTART;TZID=Asia/Shanghai:20201012T140000 110 | DTEND;TZID=Asia/Shanghai:20201012T153500 111 | RRULE:FREQ=WEEKLY;UNTIL=20201019T160000Z;INTERVAL=1 112 | DTSTAMP:20200921T133750Z 113 | UID:WakeUpSchedule-ea6f31c8-2290-4ff7-af2c-9bc86247fae9 114 | CREATED:20200906T170122Z 115 | DESCRIPTION:description5 116 | LAST-MODIFIED:20200906T170122Z 117 | LOCATION:location 118 | SEQUENCE:0 119 | STATUS:CONFIRMED 120 | SUMMARY:summary7 121 | TRANSP:OPAQUE 122 | END:VEVENT 123 | BEGIN:VEVENT 124 | DTSTART;TZID=Asia/Shanghai:20200907T095500 125 | DTEND;TZID=Asia/Shanghai:20200907T113000 126 | RRULE:FREQ=WEEKLY;UNTIL=20201026T160000Z;INTERVAL=1 127 | DTSTAMP:20200921T133750Z 128 | UID:WakeUpSchedule-a3ee9fe9-9d7d-4e1f-bacf-4c58500c74a6 129 | CREATED:20200906T170122Z 130 | DESCRIPTION:description5 131 | LAST-MODIFIED:20200906T170122Z 132 | LOCATION:location 133 | SEQUENCE:0 134 | STATUS:CONFIRMED 135 | SUMMARY:summary8 136 | TRANSP:OPAQUE 137 | END:VEVENT 138 | BEGIN:VEVENT 139 | DTSTART;TZID=Asia/Shanghai:20200909T095500 140 | DTEND;TZID=Asia/Shanghai:20200909T113000 141 | RRULE:FREQ=WEEKLY;UNTIL=20201021T160000Z;INTERVAL=1 142 | DTSTAMP:20200921T133750Z 143 | UID:WakeUpSchedule-8c518666-2333-4bfb-a006-1c6af416bb2a 144 | CREATED:20200906T170123Z 145 | DESCRIPTION:description5 146 | LAST-MODIFIED:20200906T170123Z 147 | LOCATION:location 148 | SEQUENCE:0 149 | STATUS:CONFIRMED 150 | SUMMARY:summary9 151 | TRANSP:OPAQUE 152 | END:VEVENT 153 | BEGIN:VEVENT 154 | DTSTART;TZID=Asia/Shanghai:20200909T140000 155 | DTEND;TZID=Asia/Shanghai:20200909T153500 156 | RRULE:FREQ=WEEKLY;UNTIL=20201021T160000Z;INTERVAL=1 157 | DTSTAMP:20200921T133750Z 158 | UID:WakeUpSchedule-3d6cb25a-27bd-4997-9605-a8e8158f03a4 159 | CREATED:20200906T170123Z 160 | DESCRIPTION:description5 161 | LAST-MODIFIED:20200906T170123Z 162 | LOCATION:location 163 | SEQUENCE:0 164 | STATUS:CONFIRMED 165 | SUMMARY:summary10 166 | TRANSP:OPAQUE 167 | END:VEVENT 168 | BEGIN:VEVENT 169 | DTSTART;TZID=Asia/Shanghai:20201014T155000 170 | DTEND;TZID=Asia/Shanghai:20201014T163500 171 | RRULE:FREQ=WEEKLY;UNTIL=20201021T160000Z;INTERVAL=1 172 | DTSTAMP:20200921T133750Z 173 | UID:WakeUpSchedule-56dde502-81c7-483c-8450-93cb1c499dfb 174 | CREATED:20200906T170124Z 175 | DESCRIPTION:description5 176 | LAST-MODIFIED:20200906T170124Z 177 | LOCATION:location 178 | SEQUENCE:0 179 | STATUS:CONFIRMED 180 | SUMMARY:summary11 181 | TRANSP:OPAQUE 182 | END:VEVENT 183 | BEGIN:VEVENT 184 | DTSTART;TZID=Asia/Shanghai:20200917T095500 185 | DTEND;TZID=Asia/Shanghai:20200917T113000 186 | RRULE:FREQ=WEEKLY;UNTIL=20200924T160000Z;INTERVAL=1 187 | DTSTAMP:20200921T133750Z 188 | UID:WakeUpSchedule-cf492106-753e-413f-9fb5-481a65c2a762 189 | CREATED:20200906T170124Z 190 | DESCRIPTION:description5 191 | LAST-MODIFIED:20200906T170124Z 192 | LOCATION:location 193 | SEQUENCE:0 194 | STATUS:CONFIRMED 195 | SUMMARY:summary12 196 | TRANSP:OPAQUE 197 | END:VEVENT 198 | BEGIN:VEVENT 199 | DTSTART;TZID=Asia/Shanghai:20200910T080000 200 | DTEND;TZID=Asia/Shanghai:20200910T093500 201 | RRULE:FREQ=WEEKLY;UNTIL=20201022T160000Z;INTERVAL=1 202 | DTSTAMP:20200921T133750Z 203 | UID:WakeUpSchedule-a35705dc-4304-4d01-8831-1fb34c0b84ea 204 | CREATED:20200906T170124Z 205 | DESCRIPTION:description5 206 | LAST-MODIFIED:20200906T170124Z 207 | LOCATION:location 208 | SEQUENCE:0 209 | STATUS:CONFIRMED 210 | SUMMARY:summary13 211 | TRANSP:OPAQUE 212 | END:VEVENT 213 | BEGIN:VEVENT 214 | DTSTART;TZID=Asia/Shanghai:20201008T095500 215 | DTEND;TZID=Asia/Shanghai:20201008T113000 216 | RRULE:FREQ=WEEKLY;UNTIL=20201029T160000Z;INTERVAL=1 217 | DTSTAMP:20200921T133750Z 218 | UID:WakeUpSchedule-4706d696-438a-432c-a02a-1f9ce4c6d370 219 | CREATED:20200906T170124Z 220 | DESCRIPTION:description5 221 | LAST-MODIFIED:20200906T170124Z 222 | LOCATION:location 223 | SEQUENCE:0 224 | STATUS:CONFIRMED 225 | SUMMARY:summary14 226 | TRANSP:OPAQUE 227 | END:VEVENT 228 | END:VCALENDAR 229 | -------------------------------------------------------------------------------- /tests/issue17.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2020-09-14T02:00:00-04:00", 4 | "summary": "summary1" 5 | }, 6 | { 7 | "start": "2020-09-21T02:00:00-04:00", 8 | "summary": "summary1" 9 | }, 10 | { 11 | "start": "2020-09-28T02:00:00-04:00", 12 | "summary": "summary1" 13 | }, 14 | { 15 | "start": "2020-09-14T21:55:00-04:00", 16 | "summary": "summary2" 17 | }, 18 | { 19 | "start": "2020-09-21T21:55:00-04:00", 20 | "summary": "summary2" 21 | }, 22 | { 23 | "start": "2020-09-28T21:55:00-04:00", 24 | "summary": "summary2" 25 | }, 26 | { 27 | "start": "2020-09-20T20:00:00-04:00", 28 | "summary": "summary3" 29 | }, 30 | { 31 | "start": "2020-09-27T20:00:00-04:00", 32 | "summary": "summary3" 33 | }, 34 | { 35 | "start": "2020-09-14T20:00:00-04:00", 36 | "summary": "summary4" 37 | }, 38 | { 39 | "start": "2020-09-21T20:00:00-04:00", 40 | "summary": "summary4" 41 | }, 42 | { 43 | "start": "2020-09-28T20:00:00-04:00", 44 | "summary": "summary4" 45 | }, 46 | { 47 | "start": "2020-09-15T20:00:00-04:00", 48 | "summary": "summary6" 49 | }, 50 | { 51 | "start": "2020-09-22T20:00:00-04:00", 52 | "summary": "summary6" 53 | }, 54 | { 55 | "start": "2020-09-29T20:00:00-04:00", 56 | "summary": "summary6" 57 | }, 58 | { 59 | "start": "2020-09-20T21:55:00-04:00", 60 | "summary": "summary8" 61 | }, 62 | { 63 | "start": "2020-09-27T21:55:00-04:00", 64 | "summary": "summary8" 65 | }, 66 | { 67 | "start": "2020-09-15T21:55:00-04:00", 68 | "summary": "summary9" 69 | }, 70 | { 71 | "start": "2020-09-22T21:55:00-04:00", 72 | "summary": "summary9" 73 | }, 74 | { 75 | "start": "2020-09-29T21:55:00-04:00", 76 | "summary": "summary9" 77 | }, 78 | { 79 | "start": "2020-09-16T02:00:00-04:00", 80 | "summary": "summary10" 81 | }, 82 | { 83 | "start": "2020-09-23T02:00:00-04:00", 84 | "summary": "summary10" 85 | }, 86 | { 87 | "start": "2020-09-16T21:55:00-04:00", 88 | "summary": "summary12" 89 | }, 90 | { 91 | "start": "2020-09-23T21:55:00-04:00", 92 | "summary": "summary12" 93 | }, 94 | { 95 | "start": "2020-09-16T20:00:00-04:00", 96 | "summary": "summary13" 97 | }, 98 | { 99 | "start": "2020-09-23T20:00:00-04:00", 100 | "summary": "summary13" 101 | } 102 | ] 103 | -------------------------------------------------------------------------------- /tests/issue22.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//TOM//EN 3 | VERSION:2.0 4 | METHOD:PUBLISH 5 | BEGIN:VEVENT 6 | DTSTART:20200121T124500Z 7 | DTEND:20200121T131500Z 8 | DTSTAMP:20200213T202351Z 9 | UID:5go0u0n5ghfa91rvbsktblt9ti@google.com 10 | CREATED:20200116T165452Z 11 | DESCRIPTION:Betws-y-Coed (Caravan Holiday) 12 | LAST-MODIFIED:20200116T165553Z 13 | LOCATION:Rm 12 14 | SEQUENCE:0 15 | STATUS:CONFIRMED 16 | SUMMARY:Betws-y-Coed (Caravan Holiday) 17 | TRANSP:OPAQUE 18 | END:VEVENT 19 | END:VCALENDAR 20 | -------------------------------------------------------------------------------- /tests/issue22.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "summary": "Betws-y-Coed (Caravan Holiday)", 4 | "description": "Betws-y-Coed (Caravan Holiday)" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /tests/issue36.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | BEGIN:VEVENT 5 | UID:match_1025179 6 | DTSTAMP:20210911T014015Z 7 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 8 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 9 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 10 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 11 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 12 | werp: 0-0 13 | DTSTART:20210916T210000 14 | DTEND:20210916T224500 15 | SUMMARY:Olympiakos Piraeus - Antwerp 16 | END:VEVENT 17 | END:VCALENDAR 18 | -------------------------------------------------------------------------------- /tests/issue36.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16T21:00:00-04:00", 4 | "end": "2021-09-16T22:45:00-04:00" 5 | } 6 | ] 7 | 8 | -------------------------------------------------------------------------------- /tests/issue43-14.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "all_day": false, 4 | "summary": "Recycling Collection", 5 | "start": "2022-03-14T06:00:00-04:00", 6 | "end": "2022-03-14T06:00:00-04:00" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /tests/issue43.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "all_day": false, 4 | "summary": "Recycling Collection", 5 | "start": "2022-02-28T06:00:00-05:00", 6 | "end": "2022-02-28T06:00:00-05:00" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /tests/issue45.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "all_day": false, 4 | "summary": "Recycling Collection", 5 | "start": "2022-02-28T06:00:00-05:00", 6 | "end": "2022-02-28T06:00:00-05:00" 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /tests/issue48.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | X-WR-TIMEZONE:Europe/Brussels 5 | BEGIN:VEVENT 6 | UID:match_1025179 7 | DTSTAMP:20210911T014015Z 8 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 9 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 10 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 11 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 12 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 13 | werp: 0-0 14 | DTSTART:20210916T210000 15 | DTEND:20210916T224500 16 | SUMMARY:Olympiakos Piraeus - Antwerp 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/issue48.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16T21:00:00+02:00", 4 | "end": "2021-09-16T22:45:00+02:00" 5 | } 6 | ] 7 | 8 | -------------------------------------------------------------------------------- /tests/issue6.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VEVENT 2 | SUMMARY:Jobba 3 | DTSTART;TZID=W. Europe Standard Time:20200121T070000 4 | DTEND;TZID=W. Europe Standard Time:20200121T153000 5 | UID:040000008200E00074C5B7101A82E00800000000B01768504EA0D501000000000000000 6 | 0100000001A9B2363ACC29B4A85D65E582DC139B7 7 | CLASS:PUBLIC 8 | PRIORITY:5 9 | DTSTAMP:20191230T164515Z 10 | TRANSP:OPAQUE 11 | STATUS:CONFIRMED 12 | SEQUENCE:0 13 | LOCATION:KS Huddinge 14 | X-MICROSOFT-CDO-APPT-SEQUENCE:0 15 | X-MICROSOFT-CDO-BUSYSTATUS:BUSY 16 | X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY 17 | X-MICROSOFT-CDO-ALLDAYEVENT:FALSE 18 | X-MICROSOFT-CDO-IMPORTANCE:1 19 | X-MICROSOFT-CDO-INSTTYPE:0 20 | X-MICROSOFT-DISALLOW-COUNTER:FALSE 21 | END:VEVENT 22 | -------------------------------------------------------------------------------- /tests/issue8.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Google Inc//Google Calendar 70.9054//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | BEGIN:VTIMEZONE 7 | TZID:America/New_York 8 | X-LIC-LOCATION:America/New_York 9 | BEGIN:DAYLIGHT 10 | TZOFFSETFROM:-0500 11 | TZOFFSETTO:-0400 12 | TZNAME:EDT 13 | DTSTART:19700308T020000 14 | RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU 15 | END:DAYLIGHT 16 | BEGIN:STANDARD 17 | TZOFFSETFROM:-0400 18 | TZOFFSETTO:-0500 19 | TZNAME:EST 20 | DTSTART:19701101T020000 21 | RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU 22 | END:STANDARD 23 | END:VTIMEZONE 24 | BEGIN:VEVENT 25 | DTSTART;VALUE=DATE:20200610 26 | DTEND;VALUE=DATE:20200611 27 | RRULE:FREQ=WEEKLY;WKST=SU;UNTIL=20200614T030303Z;BYDAY=FR,MO,WE 28 | DTSTAMP:20200213T202351Z 29 | UID:blahblahblahuid@google.com 30 | CREATED:20190809T165706Z 31 | DESCRIPTION: 32 | LAST-MODIFIED:20190809T165731Z 33 | LOCATION: 34 | SEQUENCE:1 35 | STATUS:CONFIRMED 36 | SUMMARY:MyEvent 37 | TRANSP:OPAQUE 38 | END:VEVENT 39 | END:VCALENDAR 40 | -------------------------------------------------------------------------------- /tests/issue92.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:Microsoft Exchange Server 2010 4 | BEGIN:VEVENT 5 | UID:646343bb0d294 6 | DTSTART;VALUE=DATE:20221231 7 | SEQUENCE:0 8 | TRANSP:OPAQUE 9 | SUMMARY:Altpapier 10 | CLASS:PUBLIC 11 | X-MICROSOFT-CDO-ALLDAYEVENT:TRUE 12 | DTSTAMP:20230516T085003Z 13 | END:VEVENT 14 | END:VCALENDAR -------------------------------------------------------------------------------- /tests/issue92.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "all_day": true, 4 | "start": "2022-12-31", 5 | "end": "2023-01-01" 6 | } 7 | ] -------------------------------------------------------------------------------- /tests/negative_offset.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | X-WR-TIMEZONE:Europe/Brussels 5 | BEGIN:VEVENT 6 | UID:match_1025179 7 | DTSTAMP:20210911T014015Z 8 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 9 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 10 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 11 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 12 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 13 | werp: 0-0 14 | DTSTART:20210916T210000Z 15 | DTEND:20210916T224500Z 16 | SUMMARY:Olympiakos Piraeus - Antwerp 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/negative_offset.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16T17:00:00Z", 4 | "end": "2021-09-16T18:45:00Z" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /tests/negative_offset_all_day.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | X-WR-TIMEZONE:Europe/Brussels 5 | BEGIN:VEVENT 6 | UID:match_1025179 7 | DTSTAMP:20210911T014015Z 8 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 9 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 10 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 11 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 12 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 13 | werp: 0-0 14 | DTSTART:20210916 15 | DTEND:20210917 16 | SUMMARY:Olympiakos Piraeus - Antwerp 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/negative_offset_all_day.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16", 4 | "end": "2021-09-17" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /tests/positive_offset.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | X-WR-TIMEZONE:Europe/Brussels 5 | BEGIN:VEVENT 6 | UID:match_1025179 7 | DTSTAMP:20210911T014015Z 8 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 9 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 10 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 11 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 12 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 13 | werp: 0-0 14 | DTSTART:20210916T210000Z 15 | DTEND:20210916T224500Z 16 | SUMMARY:Olympiakos Piraeus - Antwerp 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/positive_offset.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16T23:00:00Z", 4 | "end": "2021-09-17T00:45:00Z" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /tests/positive_offset_all_day.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//team_2_nl//NONSGML kigkonsult.se iCalcreator 2.20.2// 4 | X-WR-TIMEZONE:Europe/Brussels 5 | BEGIN:VEVENT 6 | UID:match_1025179 7 | DTSTAMP:20210911T014015Z 8 | DESCRIPTION:Wedstrijd van Speeldag 1 uit de Europa League tussen Olympiako 9 | s Piraeus en Antwerp.\n\nOlympiakos Piraeus wint @ 1.76\nGelijk @ 4.00\nAn 10 | twerp wint @ 4.35\nWed op https://banners.livepartners.com/click.php?id=34 11 | 9374&p=10&t=betslip&MasterEventID=24267084\n\nOnderlinge duels:\n\n09/12/2 12 | 021 Antwerp - Olympiakos Piraeus: 0-0\n16/09/2021 Olympiakos Piraeus - Ant 13 | werp: 0-0 14 | DTSTART:20210916 15 | DTEND:20210917 16 | SUMMARY:Olympiakos Piraeus - Antwerp 17 | END:VEVENT 18 | END:VCALENDAR 19 | -------------------------------------------------------------------------------- /tests/positive_offset_all_day.ics.expected.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "start": "2021-09-16", 4 | "end": "2021-09-17" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /tests/test_filter.py: -------------------------------------------------------------------------------- 1 | """Test the Filter class.""" 2 | 3 | import pytest 4 | from dateutil import parser as dtparser 5 | 6 | from custom_components.ics_calendar.filter import Filter 7 | from custom_components.ics_calendar.parserevent import ParserEvent 8 | 9 | 10 | @pytest.fixture() 11 | def calendar_event() -> ParserEvent: 12 | """Fixture to return a ParserEvent.""" 13 | return ParserEvent( 14 | summary="summary", 15 | start=dtparser.parse("2020-01-01T0:00:00").astimezone(), 16 | end=dtparser.parse("2020-01-01T0:00:00").astimezone(), 17 | location="location", 18 | description="description", 19 | ) 20 | 21 | 22 | class TestFilter: 23 | """Test the Filter class.""" 24 | 25 | def test_filter_empty(self) -> None: 26 | """Test that an empty filter works.""" 27 | filt = Filter("", "") 28 | assert filt.filter("summary", "description") is True 29 | 30 | def test_filter_event_empty(self, calendar_event: ParserEvent) -> None: 31 | """Test that an empty filter works on an event.""" 32 | filt = Filter("", "") 33 | assert filt.filter_event(calendar_event) is True 34 | 35 | def test_filter_string_exclude_description(self) -> None: 36 | """Test that string exclude works on description.""" 37 | filt = Filter("['crip']", "") 38 | assert filt.filter("summary", "description") is False 39 | 40 | def test_filter_string_exclude_passes(self) -> None: 41 | """Test that string exclude works if string not found.""" 42 | filt = Filter("['blue']", "") 43 | assert filt.filter("summary", "description") is True 44 | 45 | def test_filter_string_exclude(self) -> None: 46 | """Test that string exclude works on summary.""" 47 | filt = Filter("['um']", "") 48 | assert filt.filter("summary", "description") is False 49 | 50 | def test_filter_string_exclude_is_not_case_sensitive(self) -> None: 51 | """Test that string exclude filter is not case-sensitive.""" 52 | filt = Filter("['um']", "") 53 | assert filt.filter("SUMMARY", "description") is False 54 | 55 | def test_filter_string_exclude_but_string_include(self) -> None: 56 | """Test that string exclude filter with including string works.""" 57 | filt = Filter("['um']", "['crip']") 58 | assert filt.filter("summary", "description") is True 59 | 60 | def test_filter_string_exclude_but_regex_include(self) -> None: 61 | """Test that string exclude filter with including regex works.""" 62 | filt = Filter("['um']", "['/crip/']") 63 | assert filt.filter("summary", "description") is True 64 | 65 | def test_filter_regex_exclude(self) -> None: 66 | """Test that regex exclude filter works.""" 67 | filt = Filter("['/um/']", "") 68 | assert filt.filter("summary", "description") is False 69 | 70 | def test_filter_regex_exclude_but_string_include(self) -> None: 71 | """Test that regex exclude filter with including string works.""" 72 | filt = Filter("['/um/']", "['crip']") 73 | assert filt.filter("summary", "description") is True 74 | 75 | def test_filter_regex_exclude_but_regex_include(self) -> None: 76 | """Test that regex exclude filter with including regex works.""" 77 | filt = Filter("['/um/']", "['/crip/']") 78 | assert filt.filter("summary", "description") is True 79 | 80 | def test_filter_regex_exclude_ignore_case(self) -> None: 81 | """Test that regex exclude filter with ignore case works.""" 82 | filt = Filter("['/UM/i']", "") 83 | assert filt.filter("summary", "description") is False 84 | 85 | def test_filter_regex_exclude_ignore_case_multiline(self) -> None: 86 | """Test that regex exclude filter with ignore case, multi-line works.""" 87 | filt = Filter("['/CRiPT-$/im']", "") 88 | assert ( 89 | filt.filter( 90 | "summary", 91 | """descript- 92 | ion""", 93 | ) 94 | is False 95 | ) 96 | 97 | def test_filter_regex_exclude_ignore_case_multiline_dotall(self) -> None: 98 | """Test that regex exclude filter with all options works.""" 99 | filt = Filter("['/cript-.ion/sim']", "") 100 | assert ( 101 | filt.filter( 102 | "summary", 103 | """descript- 104 | ion""", 105 | ) 106 | is False 107 | ) 108 | 109 | def test_filter_regex_exclude_multiline(self) -> None: 110 | """Test that regex exclude filter with multi-line works.""" 111 | filt = Filter("['/cript-$/m']", "") 112 | assert ( 113 | filt.filter( 114 | "summary", 115 | """descript- 116 | ion""", 117 | ) 118 | is False 119 | ) 120 | 121 | def test_filter_regex_exclude_multiline_dotall(self) -> None: 122 | """Test that regex exclude filter with multi-line dotall works.""" 123 | filt = Filter("['/cript-.ion/ms']", "") 124 | assert ( 125 | filt.filter( 126 | "summary", 127 | """descript- 128 | ion""", 129 | ) 130 | is False 131 | ) 132 | 133 | def test_filter_regex_exclude_dotall(self) -> None: 134 | """Test that regex exclude filter with dotall works.""" 135 | filt = Filter("['/cript-.ion/s']", "") 136 | assert ( 137 | filt.filter( 138 | "summary", 139 | """descript- 140 | ion""", 141 | ) 142 | is False 143 | ) 144 | 145 | def test_filter_regex_exclude_unknown_flag_ignored(self) -> None: 146 | """Test that regex exclude filter with dotall works.""" 147 | filt = Filter("['/cript-.ion/z']", "") 148 | assert ( 149 | filt.filter( 150 | "summary", 151 | """descript- 152 | ion""", 153 | ) 154 | is True 155 | ) 156 | 157 | def test_filter_with_description_none(self) -> None: 158 | """Test that filter works if description is None.""" 159 | filt = Filter("['exclude']", "['include']") 160 | assert filt.filter("summary", None) is True 161 | -------------------------------------------------------------------------------- /tests/test_icalendarparser.py: -------------------------------------------------------------------------------- 1 | """Test the icalendarparser class.""" 2 | 3 | from custom_components.ics_calendar.getparser import GetParser 4 | from custom_components.ics_calendar.icalendarparser import ICalendarParser 5 | 6 | 7 | class TestICalendarParser: 8 | """Test GetParser class.""" 9 | 10 | def test_get_parser_returns_ICalendarParser(self): 11 | """Test that get_parser returns parsers of ICalendarParser.""" 12 | assert isinstance(GetParser.get_parser("rie"), ICalendarParser) 13 | assert isinstance(GetParser.get_parser("ics"), ICalendarParser) 14 | 15 | def test_get_parser_returns_None(self): 16 | """Test that get_parser returns None for non-existing parser.""" 17 | assert GetParser.get_parser("unknown") is None 18 | -------------------------------------------------------------------------------- /tests/test_utility.py: -------------------------------------------------------------------------------- 1 | """Test utility.py.""" 2 | 3 | from datetime import date, datetime 4 | 5 | from custom_components.ics_calendar.utility import compare_event_dates 6 | 7 | # from unittest.mock import Mock 8 | 9 | 10 | # import pytest 11 | 12 | janOneTwelveThirty = datetime(2022, 1, 1, 12, 30, 0).astimezone() 13 | janOneTwelveThirtyFive = datetime(2022, 1, 1, 12, 35, 0).astimezone() 14 | janOneTwelveFourty = datetime(2022, 1, 1, 12, 40, 0).astimezone() 15 | janOneThirteenThirty = datetime(2022, 1, 1, 13, 30, 0).astimezone() 16 | janOneFourteenThirty = datetime(2022, 1, 1, 14, 30, 0).astimezone() 17 | janTwoTwelveThirty = datetime(2022, 1, 2, 12, 30, 0).astimezone() 18 | janTwoThirteenThirty = datetime(2022, 1, 2, 13, 30, 0).astimezone() 19 | febOneTwelveThirty = datetime(2022, 2, 1, 12, 30, 0).astimezone() 20 | janOne = date(2022, 1, 1) 21 | janOne = date(2022, 1, 1) 22 | janTwo = date(2022, 1, 2) 23 | janTwo = date(2022, 1, 2) 24 | janThree = date(2022, 1, 3) 25 | febOne = date(2022, 2, 1) 26 | 27 | 28 | class TestUtility: 29 | """Test the Filter class.""" 30 | 31 | def test_compare_event_dates_newer(self) -> None: 32 | """Test that Jan 2@12:30 is newer than Jan 1@12:30.""" 33 | assert ( 34 | compare_event_dates( 35 | febOneTwelveThirty, 36 | janTwoThirteenThirty, 37 | janTwoTwelveThirty, 38 | False, 39 | janOneThirteenThirty, 40 | janOneTwelveThirty, 41 | False, 42 | ) 43 | is True 44 | ) 45 | 46 | def test_compare_event_dates_older(self) -> None: 47 | """Test that Jan 1@12:30 is older than Jan 2@12:30.""" 48 | assert ( 49 | compare_event_dates( 50 | febOneTwelveThirty, 51 | janOneThirteenThirty, 52 | janOneTwelveThirty, 53 | False, 54 | janTwoThirteenThirty, 55 | janTwoTwelveThirty, 56 | False, 57 | ) 58 | is False 59 | ) 60 | 61 | def test_compare_event_dates_newer_all_day(self) -> None: 62 | """Test that Jan 1 is older than Jan 2.""" 63 | assert ( 64 | compare_event_dates( 65 | febOneTwelveThirty, janTwo, janTwo, True, janOne, janOne, True 66 | ) 67 | is True 68 | ) 69 | 70 | def test_compare_event_dates_older_all_day(self) -> None: 71 | """Test that Jan 1 is older than Jan 2.""" 72 | assert ( 73 | compare_event_dates( 74 | febOneTwelveThirty, janOne, janOne, True, janTwo, janTwo, True 75 | ) 76 | is False 77 | ) 78 | 79 | def test_compare_event_dates_all_day_newer_than_not_all_day(self) -> None: 80 | """Test that all day Jan 2 is newer than Jan 1@12:30.""" 81 | assert ( 82 | compare_event_dates( 83 | febOneTwelveThirty, 84 | janThree, 85 | janTwo, 86 | True, 87 | janOneThirteenThirty, 88 | janOneTwelveThirty, 89 | False, 90 | ) 91 | is True 92 | ) 93 | 94 | def test_compare_event_dates_all_day_older_than_not_all_day(self) -> None: 95 | """Test that all day Jan 1 is older than Jan 2@12:30.""" 96 | assert ( 97 | compare_event_dates( 98 | febOneTwelveThirty, 99 | janTwo, 100 | janOne, 101 | True, 102 | janTwoThirteenThirty, 103 | janTwoTwelveThirty, 104 | False, 105 | ) 106 | is False 107 | ) 108 | 109 | def test_compare_event_dates_not_all_day_newer_than_all_day(self) -> None: 110 | """Test that Jan 1@12:30 is newer than Jan 1 all day.""" 111 | assert ( 112 | compare_event_dates( 113 | datetime.now(), 114 | janTwoThirteenThirty, 115 | janTwoTwelveThirty, 116 | False, 117 | janOne, 118 | janTwo, 119 | True, 120 | ) 121 | is True 122 | ) 123 | 124 | def test_compare_event_dates_not_all_day_older_than_all_day(self) -> None: 125 | """Test that Jan 1@12:30 is older than Jan 2 all day.""" 126 | assert ( 127 | compare_event_dates( 128 | datetime.now(), 129 | janOneThirteenThirty, 130 | janOneTwelveThirty, 131 | False, 132 | janTwo, 133 | janThree, 134 | True, 135 | ) 136 | is False 137 | ) 138 | 139 | def test_compare_event_dates_newer_same_end_later_start(self) -> None: 140 | """Test that Jan 1@12:35 is newer than Jan 1@12:30 for same end.""" 141 | assert ( 142 | compare_event_dates( 143 | febOneTwelveThirty, 144 | janOneThirteenThirty, 145 | janOneTwelveThirtyFive, 146 | False, 147 | janOneThirteenThirty, 148 | janOneTwelveThirty, 149 | False, 150 | ) 151 | is True 152 | ) 153 | 154 | def test_compare_event_dates_older_same_end_earlier_start(self) -> None: 155 | """Test that Jan 1@12:30 is older than Jan 1@12:35 for same end.""" 156 | assert ( 157 | compare_event_dates( 158 | febOneTwelveThirty, 159 | janOneThirteenThirty, 160 | janOneTwelveThirty, 161 | False, 162 | janOneThirteenThirty, 163 | janOneTwelveThirtyFive, 164 | False, 165 | ) 166 | is False 167 | ) 168 | 169 | def test_compare_event_dates_newer_not_all_day_during(self) -> None: 170 | """Test that Jan 1@12:30 is newer than Jan 1 all day.""" 171 | assert ( 172 | compare_event_dates( 173 | janOneTwelveThirtyFive, 174 | janOneThirteenThirty, 175 | janOneTwelveThirty, 176 | False, 177 | janTwo, 178 | janOne, 179 | True, 180 | ) 181 | is True 182 | ) 183 | 184 | def test_compare_event_dates_older_not_all_day_during(self) -> None: 185 | """Test that Jan 1 all day is older than Jan 1@12:30.""" 186 | assert ( 187 | compare_event_dates( 188 | janOneTwelveThirtyFive, 189 | janTwo, 190 | janOne, 191 | True, 192 | janOneThirteenThirty, 193 | janOneTwelveThirty, 194 | False, 195 | ) 196 | is False 197 | ) 198 | 199 | def test_compare_event_dates_older_earlier_end_later_start(self) -> None: 200 | """Test that Jan 1@12:30-1:30 is newer than Jan1@12:30-12:35.""" 201 | assert ( 202 | compare_event_dates( 203 | janOneTwelveThirtyFive, 204 | janOneThirteenThirty, 205 | janOneTwelveThirty, 206 | True, 207 | janOneTwelveThirtyFive, 208 | janOneTwelveThirty, 209 | False, 210 | ) 211 | is False 212 | ) 213 | 214 | def test_compare_event_dates_older_earlier_end_later_start2(self) -> None: 215 | """Test that Jan 1@12:30-1:30 is newer than Jan1@12:30-12:35.""" 216 | assert ( 217 | compare_event_dates( 218 | janOneTwelveThirtyFive, 219 | janOneTwelveThirtyFive, 220 | janOneTwelveThirty, 221 | True, 222 | janOneThirteenThirty, 223 | janOneTwelveThirty, 224 | False, 225 | ) 226 | is False 227 | ) 228 | 229 | 230 | # TODO: all day v not-all-day events, for overlapping times 231 | # A not-all-day event should be "newer" than an all day event if now is during 232 | # the not-all-day event 233 | # An all day event should be "nweer" than an all day event if now is NOT during 234 | # the not-all-day event 235 | --------------------------------------------------------------------------------