pax_global_header00006660000000000000000000000064147760343670014533gustar00rootroot0000000000000052 comment=689f4d8f1f5d017e1df08f227b523aee4f78eddd khal-0.11.4/000077500000000000000000000000001477603436700125355ustar00rootroot00000000000000khal-0.11.4/.coveragerc000066400000000000000000000000251477603436700146530ustar00rootroot00000000000000[run] omit=khal/ui/* khal-0.11.4/.github/000077500000000000000000000000001477603436700140755ustar00rootroot00000000000000khal-0.11.4/.github/ISSUE_TEMPLATE/000077500000000000000000000000001477603436700162605ustar00rootroot00000000000000khal-0.11.4/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000017711477603436700207600ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **If applicable: Stack Trace** Please copy the stack trace if khal crashes. **To Reproduce** Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **OS, version, khal version and how you installed it:** - The output of khal --version: [e.g. `khal, version 0.11.2.dev20+g0c47162.d20230530` ] - Installation method [e.g. PyPI, git, OS repo] - python version [e.g. python 3.9] - OS [e.g. arch] - Your khal config file - The versions of your other python packages [e.g. the output of `pip freeze`] **Additional context** Add any other context about the problem here. Especially, if the issue came up when reading an .ics file, please provide the content of that file (anonymize if needed). khal-0.11.4/.github/workflows/000077500000000000000000000000001477603436700161325ustar00rootroot00000000000000khal-0.11.4/.github/workflows/ci.yml000066400000000000000000000025411477603436700172520ustar00rootroot00000000000000--- name: CI on: push: branches: ["master"] pull_request: branches: ["master"] workflow_dispatch: jobs: tests: name: "Python ${{ matrix.python-version }} ${{ matrix.tox-test }}" runs-on: "ubuntu-latest" strategy: fail-fast: false matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] tox-test: ["default"] steps: - uses: "actions/checkout@v3" - uses: "actions/setup-python@v4" with: python-version: "${{ matrix.python-version }}" - name: "Install test locales" run: | echo "de_DE.UTF-8 UTF-8" | sudo tee -a /etc/locale.gen echo "cs_CZ.UTF-8 UTF-8" | sudo tee -a /etc/locale.gen echo "el_GR.UTF-8 UTF-8" | sudo tee -a /etc/locale.gen echo "fr_FR.UTF-8 UTF-8" | sudo tee -a /etc/locale.gen sudo locale-gen - name: "Install dependencies" run: | set -xe python -VV python -m site python -m pip install --upgrade pip setuptools wheel python -m pip install --upgrade coverage[toml] virtualenv tox tox-gh-actions - name: "Run tox targets for ${{ matrix.python-version }}" if: ${{ matrix.tox-test == 'default' }} # Fake a TTY shell: 'script -q -e -c "bash --noprofile --norc -eo pipefail {0}"' run: "python -m tox" khal-0.11.4/.gitignore000066400000000000000000000003171477603436700145260ustar00rootroot00000000000000*.pyc *.swp khal/version.py khal.egg-info/ build/ dist/ .eggs/* *.egg *.db .tox .coverage .cache htmlcov doc/source/configspec.rst .mypy_cache/ .env .venv env/ venv/ .hypothesis/ .python-version .dmypy.json khal-0.11.4/.pre-commit-config.yaml000066400000000000000000000014551477603436700170230ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-toml - id: check-added-large-files - id: debug-statements - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy additional_dependencies: - types-tzlocal - types-freezegun - types-pytz - types-setuptools - types-python-dateutil - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.1.5' hooks: - id: ruff args: ["--fix"] - repo: https://github.com/netromdk/vermin rev: v1.5.2 hooks: - id: vermin args: ['-t=3.8-', '--violations'] khal-0.11.4/.readthedocs.yml000066400000000000000000000004311477603436700156210ustar00rootroot00000000000000# .readthedocs.yml version: 2 sphinx: configuration: doc/source/conf.py build: os: "ubuntu-22.04" tools: python: "3.11" python: install: - method: pip path: . extra_requirements: - docs # This is the default, you can omit this formats: [] khal-0.11.4/AUTHORS.txt000066400000000000000000000056661477603436700144400ustar00rootroot00000000000000Christian Geier David Soulayrol - david.soulayrol [at] gmail [dot] com - http://david.soulayrol.name Aaron Bishop - abishop [at] linux [dot] com Thomas Dwyer - github [at] tomd [dot] tel - http://tomd.tel Thomas Tschager - github [at] tschager [dot] net - https://thomas.tschager.net Patrice Peterson - runiq [at] archlinux [dot] us Eric Scheibler - email [at] eric-scheibler [dot] de - http://eric-scheibler.de Pierre David - pdagog [at] gmail [dot] com Markus Unterwaditzer - markus [at] unterwaditzer [dot] net - https://unterwaditzer.net Hugo Osvaldo Barrera - hugo@whynothugo.nl - https://whynothugo.nl Bradley Jones - caffeinatedbrad [at] gmail [dot] com - http://caffeinatedbrad.com Micah Nordland - micah [at] rehack [dot] me - https://w3.thoughtfuldragon.com/ Thomas Glanzmann - thomas [at] glanzmann [dot] de - https://thomas.glanzmann.de/ John Shea - coachshea [at] fastmail [dot] com Dominik Joe Pantůček - joe [at] joe [dot] cz - http://joe.cz/ Thomas Schaper - libreman [at] libremail [dot] nl Oliver Kiddle - okiddle [at] yahoo [dot] co [dot] uk Filip Pytloun - filip [at] pytloun [dot] cz - https://fpy.cz Sebastian Hamann Lucas Hoffmann Johannes Wienke - languitar [at] semipol [dot] de - https://www.semipol.de Laurent Arnoud - laurent [at] spkdev [dot] net - http://spkdev.net/ Julian Mehne Stephan Weller Max Voit - max.voit+dvkh [at] with-eyes [dot] net Taylor L Money - - http://taylorlmoney.com Troy Sankey - sankeytms [at] gmail [dot] com Mart Lubbers - mart [at] martlubbers [dot] net Paweł Fertyk - pfertyk [at] openmailbox [dot] org Moritz Kobel - moritz [at] kobelnet [dot] ch - http://www.kobelnet.ch Guilhem Saurel - guilhem [at] saurel [dot] me - https://saurel.me Stefan Siegel - ssiegel [at] sdas [dot] net August Lindberg Thomas Kluyver - thomas [at] kluyver [dot] me [dot] uk Tobias Brummer - hallo [at] t0bybr.de - https://t0bybr.de Amanda Hickman - amanda [at] velociraptor [dot] info Raef Coles - raefcoles [at] gmail [dot] com Nito Martinez - nito [at] qindel [dot] com - http://qindel.com http://theqvd.com Florian Wehner - florian [at] whnr [dot] de Martin Stone Maxime Ocafrain Axel Danguin Yorick Barbanneau - git [at] epha [dot] se - https://xieme-art.org Florian Lassenay - pizzacoca [at] aquilenet [dot] fr Simon Crespeau - simon [at] art-e-toile [dot] com - https://www.art-e-toile.com Fred Thomsen - me [at] fredthomsen [dot] net - http://fredthomsen.net Robin Schubert Axel Gschaider - axel.gschaider [at] posteo [dot] de Anuragh - kpanuragh [at] gmail [dot] com Henning Ullrich - github {at] henning-ullrich [dot] de Jason Cox - me [at] jasoncarloscox [dot] com - https://jasoncarloscox.com Michael Tretter - michael.tretter [at] posteo [dot] net Raúl Medina - raulmgcontact [at] gmail (dot] com Matthew Rademaker - matthew.rademaker [at] gmail [dot] com Valentin Iovene - val [at] too [dot] gy Julian Wollrath Mattori Birnbaum - me [at] mattori [dot] com - https://mattori.com Pi R Alnoman Kamil - noman [at] kamil [dot] gr - https://kamil.gr khal-0.11.4/CHANGELOG.rst000066400000000000000000000654261477603436700145730ustar00rootroot00000000000000Changelog ######### All notable changes to this project should be documented here. For more detailed information have a look at the git log. Package maintainers and users who have to manually update their installation may want to subscribe to `GitHub's tag feed `_. 0.11.4 ====== 2025-04-10 * UPDATED REQUIREMENT urwid is now required >= 2.6.15 * NEW REQUIREMENT for tests on python >= 3.12: pkg_resources * optimization in ikhal when editing events in the far future or past * FIX an issue in ikhal with updating the view of the event list after editing an event * NEW properties of ikhal themes (dark and light) can now be overriden from the config file (via the new [palette] section, check the documenation) * NEW timedelta strings can now have a leading ``+``, e.g. ``+1d`` * NEW Add ``--json`` option to output event data as JSON objects * NEW Add default alarms configuration option * FIX defaults for ``default_event_duration`` and ``default_dayevent_duration`` where mixed up, ``default_dayevent_duration`` is the default for all-day events * NEW event format option ``status-symbol`` which represents the status of an event with a symbol (e.g. ``✓`` for confirmed, ``✗`` for cancelled, ``?`` for tentative) * NEW event format option ``partstat-symbol`` which represents the participation status of an event with a symbol (e.g. ``✓`` for accepted, ``✗`` for declined, ``?`` for tentative); partication status is shown for the email addresses configured for the event's calendar * NEW support for color theme, command, and formatter plugins * FIX an issue where ikhal would forget changes to time or date fields if you left the field with page up/down or meta+enter * NEW support python 3.13 * CHANGE various UI improvments to ikhal. * FIX Deleting multiple of instances of a recurring event in ikhal * NEW Add ``enable_mouse`` configuration option. * CHANGE the ``atomicwrites`` library is no longer required. 0.11.3 ====== 2024-02-12 * FIX support urwid 2.4.2 0.11.2 ====== 2023-06-07 * FIX khal `at` also uses `event_format` not `agenda_event_format` * FIX duplicating an event using `p` in ikhal * NEW Add ability to change the minimum number of months displayed with `min_calendar_display` * FIX ikhal don't crash when jumping long distances in time * FIX do not use urwid's private methods, would crash with latest urwid version * FIX light colorscheme in ikhal, would crash with recent urwid versions * FIX better error messages when we cannot import an event 0.11.1 ====== 2023-04-23 * FIX README.rst formatting to allow upload to PyPI 0.11.0 ====== 2023-04-23 * DROPPED support for python versions < 3.8 * UPDATED REQUIREMENT pytz is now required >= 2018.7 * NEW test REQUIREMENT: packaging * FIX support in tests for pytz version numbers of the format year.month.minor * FIX deleting of instances of recurring events in ikhal * FIX if a `discover` collection is set to "readonly", discovered collections will now inherit the readonly property * FIX ikhal will not wrap date headers into the next line in narrow terminals * FIX `configure` should only suggest valid default collection names * NEW the `configure` command can now set up vdirsyncer * NEW better error message for misuses of `at` and `list` * NEW `discover` collection type now supports `**` (arbitrary depths) * NEW Add testing for Python 3.11 0.10.5 ====== 2022-06-26 * FIX support for tzlocal >= 4.0 * FIX ability to show an event's calendar in ikhal * FIX an error logging for certain broken icalendar events that made ikhal crash after editing those events * NEW Add widget to interactive event editor that allows adding attendees as comma separated list of email addresses * FIX event creation for events after the second next DST transition * NEW Add support for Python 3.10 * CHANGE `search`, `at`, and `list` don't print "No events" anymore if no matching events are found * NEW Add option to use `multiple` color only when not all calendar colors can be displayed. * CHANGE we are not shipping a zsh completion file anymore but provide documentation on how to generate completion files for bash, zsh, and fish (see the install section of the documentation) **Packagers**: please generate and ship those completion files if possible 0.10.4 ====== 2021-07-29 * DROPPED support for Python 3.5 * CHANGE ikhal: tab (and shift tab) jump from the events back to the calendar * NEW Add symbol for events with at least one alarm * FIX URL can now be set/updated from ikhal * FIX Imported events without an end or duration will now last one day if `DTSTART` is a date (as per RFC) or one hour if it is a datetime. 0.10.3 ====== 2021-04-27 * DROPPED support for Python 3.4 * FIX `khal interactive` now accepts -a/-d options (as documented) * FIX Strip whitespace when loading `displayname` and `color` files * FIX Warn when loading events with a recurrence that finishes before it starts * FIX Warn when loading events with a recurrence that never occurs * FIX Alarms without descriptions no longer crash `ikhal` * FIX Display all-day events at the top of the day in `ikhal` * FIX Keybindings in empty search results no longer crash `ikhal` * NEW Possibility to add a blank line before day in `khal` with `blank_line_before_day` option * FIX `new` keybinding in search no longer crash `ikhal` * NEW Improved sorting of events. Sort by `DTSTART`, `DTEND` then `SUMMARY`. * NEW Add url input and `{url}` template option 0.10.2 ====== 2020-07-29 * NEW Parse `X-ANNIVERSARY`, `ANNIVERSARY` and `X-ABDATE` fields from vcards * NEW Add ability to change default event duration with `default_event_duration` and `default_dayevent_duration` for a day-long event * NEW Add `{uid}` property to template options in `--format` * FIX No warning when importing event with Windows timezone format * FIX Launching an external editor no longer crashes `ikhal` * UPDATED DEPENDENCY urwid>=1.3.0 * FIX Wrong left pane width calculation in ikal when `frame` is `width` or `color` in configuration. * CHANGE Remove check for timezones in `UNTIL` that aren't in `DTSTART` and vice-versa. The check wasn't fulfilling its purpose and was raising warnings when no `UNTIL` value was set. 0.10.1 ====== 2019-03-30 * FIX error with the new color priority system and `discover` calendar type * FIX search results in ikhal are ordered, same as in `khal search` 0.10.0 ====== 2019-03-25 * In contrast to what was stated here before, at release time, khal >0.10.0 supported dateutil 2.7 * NEW DEPENDENCY added click_log >= 0.2.0 * NEW DEPENDENCY for Python 3.4: typing * UPDATED DEPENDENCY icalendar>=4.03 * DROPPED support for Python 3.3 * vdirsyncer is still a test dependency (and always has been) * FIX ordinal numbers in birthday entries (before, all number would end on `th`) * FIX `search` will no longer break on overwritten events with a master event * FIX when using short dates, khal infers that you meant next year, when date is before today * FIX Check for multi_uid .ics files in vdirs and don't import those events (All .ics files in vdirs should only contain VEVENTS with the same UID.) * CHANGE only searched configuration file paths are now $XDG_CONFIG_HOME/khal/config and $XDG_CONFIG_HOME/khal/khal.conf (deprecated) * CHANGE removed default command * CHANGE default date/time formats to be the system's locale's formats * CHANGE ``--verbose`` flag to ``--verbosity``, allowing finer granularity * CHANGE `search` will now print one line for every different event in a recurrence set, that is one line for the master event, and one line for every different overwritten event * CHANGE khal learned to read .ics files with nonsenscial TZOFFSETs > 24h and prints a warning * CHANGE better error message for a specific kind of invalid config file * NEW khal learned the ``--logfile/-l LOGFILE`` flag which allows logging to a file * NEW format can now print the duration of an event with `{duration}` * NEW format supports `{nl}`, `{tab}`, `{bell}`. `{status}` has a whitespace like `{cancelled}` * NEW configuration option: [view]monthdisplay = firstday|firstfullweek, if set to 'firstday', khal displays the month name as soon as any day in the week is within the new month. If set to 'firstfullweek', khal displays the month name only if the first day of the week is within the new month. * NEW ikhal learned to show log messages in the header and in a new log pane, access with default keybinding `L` * NEW python 3.7 is now officially supported. * NEW configuration option [[per_calendar]]priority = int (default 10). If multiple calendars events are on the same day, the day will be colored with the color of the calendar with highest priority. If multiple calendars have the same highest priority, it falls back to the previous system. * NEW format can now print the organizer of the event with '(organizer)' 0.9.8 ===== released 2017-10-05 * FIX a bug in ikhal: when editing events and not editing the dates, the end time could erroneously be moved to the start time + 1h 0.9.7 ===== released 2017-09-15 * FIX don't crash when editing events with datetime UNTIL properties 0.9.6 ===== released 2017-06-13 * FIX set PRODID to khal/icalendar * FIX don't crash on updated vcards * FIX checking for RRULEs we understand * FIX after editing an event in ikhal, make sure both the calendar and the eventcolumn are focused on the new date * FIX no more crashes if only one event which is an overwritten instance is present in an .ics file * FIX .ics files containing only overwritten instances are not expanded anymore, even if they contain a RRULE or RDATE * FIX valid UNTIL entry for recurring datetime events * CHANGE the symbol used for indicating a recurring event now has a space in front of it, also the ascii version changed to `(R)` * CHANGE birthdays on leap 29th of February are shown on 1st of March in non-leap years * NEW import and printics will read from stdin if not filename(s) are provided. * NEW new entry points recommended for packagers to use. * NEW support keyword `yesterday` for querying and creating events 0.9.5 ====== released 2017-04-08 * FIX khal new -i does not crash anymore * FIX make tests run with latest pytz (2017.2) 0.9.4 ===== released 2017-03-30 * FIX ikhal's event editor now warns before allowing to edit recurrence rules it doesn't understand * CHANGE improved the initial configuration wizard * CHANGE improved ikhal's `light` color scheme * NEW ikhal's event editor now allows better editing of recurrence rules, including INTERVALs, end dates, and more * NEW ikhal will now check if any configured vdir has been updated, and, if applicable, refresh its UI to reflect the latest changes 0.9.3 ===== released 2017-03-06 * FIX `list` (and commands based on it like `calendar`, `at`, and `search`) crashed if `--notstarted` was given and allday events were found (introduced in 0.9.2) * FIX `list --notstarted` (and commands based on it) would show events only on the first day of their occurrence and not on all further days * FIX `configure` would crash if neither "import config from vdirsyncer" nor "create locale vdir" was selected * FIX `at` will now show an error message if a date instead of a datetime is given * FIX `at`'s default header will now show the datetime queried for (instead of just the date) * FIX validate vdir metadata in color files * FIX show the actually configured keybindings in ikhal * NEW khal will now show cancelled events with a big CANCELLED in front (can be configured via event formatting) * NEW ikhal supports editing an event's raw icalendar content in an external editor ($EDITOR), default keybinding is `alt + shift + e`. Only use this, if you know what you are doing, the icalendar library we use doesn't do a lot of validation, it silently disregards most invalid data. 0.9.2 ===== released 2017-02-13 * FIX if weekstart != 0 ikhal would show wrong weekday names * FIX allday events added with `khal new DATE TIMEDELTA` (e.g., 2017-01-18 3d) were lasting one day too long * FIX no more crashes when using timezones that have a constant UTC offset (like UTC itself) * FIX updated outdated zsh completion file * FIX display search results for events with neither DTEND nor DURATION * FIX display search results that are all-day events * in ikhal, update the date-titles on date change * FIX printing a new event's path if [default] print_new = path * FIX width of calendar in `khal calendar` was off by two if locale.weeknumbers was set to "right" * CHANGED default `agenda_day_format` to include the actual date of the day * NEW configuration option: [view]dynamic_days = True, if set to False, ikhal's right column behaves similar as it did in 0.8.x 0.9.1 ===== released 2017-01-25 * FIX detecting not understood timezone information failed on python 3.6, this may lead to erroneous offsets in start and end times for those events, as those datetimes were treated as if they were in the system's local time, not as if they are in the (possibly) configured default_timezone. * python 3.6 is now officially supported 0.9.0 ===== released 2017-01-24 Dependency Changes ------------------ * vdirsyncer isn't a hard dependency any more Bug Fixes --------- * fixed various bugs in `configure` * fix bug in `new` that surfaces when date(time)format does contain a year * fix bug in `import` that allows importing into read-only and/or non-default calendar * fix how color discovered in calendars Backwards Incompatibilities --------------------------- * calendar path is now a glob without recursion for discover, if your calendars are no longer found, please consult the documentation (Taylor Money) * `at` command now works like `list` with a timedelta of `0m`, this means that `at` will no longer print events that end at exactly the time asked for (Taylor Money) * renamed `agenda` to `list` (Taylor Money) * removed `days` configuration option in favor of `timedelta`, see documentation for details (Taylor Money) * configuration file path $XDG_CONFIG_HOME/khal/config is now supported and $XDG_CONFIG_HOME/khal/khal.conf deprecated * ikhal: introduction of three different new frame styles, new allowed values for `[view] frame` are `False`, `width`, `color`, `top` (with default `False`), `True` isn't allowed any more, please provide feedback over the usual channels if and which of those you consider useful as some of those might be removed in future releases (Christian Geier) * removed configuration variable `encoding` (in section [locale]), the correct locale should now be figured out automatically (Markus Unterwaditzer) * events that start and end at the same time are now displayed as if their duration was one hour instead of one day (Guilhem Saurel) Enhancements ------------ * (nearly) all commands allow formatting of how events are printed with `--format`, also see the new configuration options `event_format`, `agenda_event_format`, `agenda_day_format` (Taylor Money) * support for categories (and add `-g` flag for `khal new`) (Pierre David) * search results are now sorted by start date (Taylor Money) * added command `edit`, which also allows deletion of events (Taylor Money) * `new` has interactive option (Taylor Money) * `import` can now import multiple files at once (Christian Geier) ikhal ----- * BUGFIX no more crashing if invalid date is entered and mini-calendar displayed * make keybinding for quitting configurable, defaults to *q* and *Q*, escape only backtracks to last pane but doesn't exit khal anymore (Christian Geier) * default keybinding changed: `tab` no longer shows details of focused events and does not open the event editor either (Christian Geier) * right column changed, it will now show as many days/events as fit, if users move to another date (while the event column is in focus), that date should be highlighted in the calendar (Christian Geier) * cursor indicates which element is selected 0.8.4 ===== released 2016-10-06 * **IMPORTANT BUGFIX** fixed a bug that lead to imported events being erroneously shifted if they had a timezone identifier that wasn't an Olson database identifier. All users are advised to upgrade as soon as possible. To see if you are affected by this and how to resolve any issues, please see the release announcement (khal/doc/source/news/khal084.rst or http://lostpackets.de/khal/news/khal084.html). Thanks to Wayne Werner for finding and reporting this bug. 0.8.3 ===== released 2016-08-28 * fixed some bugs in the test suite on different operating systems * fixed a check for icalendar files containing RDATEs 0.8.2 ===== released on 2016-05-16 * fixed some bugs in `configure` that would lead to invalid configuration files and crashes (Christian Geier) * fixed detecting of icalendar version (Markus Unterwaditzer) 0.8.1 ===== released on 2016-04-13 * fix bug in CalendarWidget.set_focus_date() (Christian Geier) 0.8.0 ===== released on 2016-04-13 * BREAKING CHANGE: python 2 is no longer supported (Hugo Osvaldo Barrera) * updated dependency: vdirsyncer >= 0.5.2 * make tests work with icalendar 3.9.2 (no functional changes) (Christian Geier) * new dependency: freezegun (only for running the tests) * khal's git repository moved to https://github.com/pimutils/khal * support for showing the birthday of contacts with no FN property (Hugo Osvaldo Barrera) * increased start up time when coloring is enabled (Christian Geier) * improved color support (256 colors and 24-bit colors), see configuration documentation for details (Sebastian Hamann) * renamed color `grey` to `gray` (Sebastian Hamann) * in `khal new` treat 24:00 as the end of a day/00:00 of the next (Christian Geier) * new allowed value for a calendar's color: `auto` (also the new default), if set, khal will try to read a file called `color` from that calendar's vdir (see vdirsyncer's documentation on `metasync`). If that file is not present or its contents is not understood, the default color will be used (Christian Geier) * new allowed value for calendar's type: `discover`, if set, khal will (recursively) search that calendar's path for valid vdirs and add those to the configured calendars (Christian Geier) * new command `configure` which should help new users set up a configuration file (Christian Geier) * warn user when parsing broken icalendar files, this requires icalendar > 3.9.2 (Christian Geier) * khal will now strip all ANSI escape codes when it detects that stdout is no tty, this behaviour can be overwritten with the new options --color/ --no-color (Markus Unterwaditzer) * calendar and agenda have a new option --week, if set all events from current week (or the week containing the given date) are shown (Stephan Weller) * new option --alarm DURATION for `new` (Max Voit) ikhal ----- * basic export of events from event editor pane and from event lists (default keybinding: *e*) (Filip Pytloun) * pressing *enter* in a date editing widget will now open a small calendar widget, arrow keys can be used to select a date, enter (or escape) will close it again (Christian Geier) * in highlight/date range selection mode the other end can be selected, default keybinding `o` (as in *Other*) (Christian Geier) * basic search is now supported (default keybinding `/`) (Christian Geier) * in the event editor and pop-up Dialogs select the next (previous) item with tab (shift tab) (Christian Geier) * only allow saving when starttime < endtime (Christian Geier) * the event editor now allows editing of alarms (but khal will not actually alarm you at the given time) (Johannes Wienke) 0.7.0 ===== released on 2015-11-24 There are no new or dropped dependencies. * most of the internal representation of events was rewritten, the current benefit is that floating events are properly represented now, hopefully more is to come (Christian Geier) * `printformats` uses a more sensible date now (John Shea) * khal and ikhal can now highlight dates with events, at the moment, enabling it does noticably slow down (i)khal's start; set *[default] highlight_event_days = True* and see section *[highlight_days]* for further configuration (Dominik Joe Pantůček) * fixed line wrapping for `at` (Thomas Schape) * `calendar` and `agenda` optionally print location and description of all events, enable with the new --full/-f flag (Thomas Schaper) * updated and improved zsh completion file (Oliver Kiddle) * FIX: deleting events did not always work if an event with the same filename existed in another calendar (but no data lost incurred) (Christian Geier) ikhal ----- * events are now displayed nicer (Thomas Glanzmann) * support for colorschemes, a *light* and *dark* one are currently included, help is wanted to make them prettier and more functional (config option *[view] theme: (dark|light)*) (Christian Geier) * ikhal can now display frames around some user interface elements, making it nicer to look at in some eyes (config option *[view] frame: True*) (Christian Geier) * events can now be duplicated (default keybinding: *p*) (Christian Geier) * events created while time ranges are selected (default keybinding to enable date range selection: *v*) will default to that date range (Christian Geier) * when trying to delete recurring events, users are now asked if they want to delete the complete event or just this instance (Christian Geier) 0.6.0 ===== 2015-07-15 * BUGFIX Recurrent events with a THISANDFUTURE parameter could affect other events. This could lead to events not being found by the normal lookup functionality when they should and being found when they shouldn't. As the second case should result in an error that nobody reported yet, I hope nobody got bitten by this. * new dependency for running the tests: freezegun * new dependency for setup from scm: setuptools_scm * khal now needs to be installed for building the documentation * ikhal's should now support ctrl-e, ctrl-a, ctrl-k and ctrl-u in editable text fields (Thomas Glanzmann) * ikhal: space and backspace are new (additional) default keybindings for right and left (Pierre David) * when editing descriptions you can now insert new lines (Thomas Glanzmann) * khal should not choose an arbitrary default calendar anymore (Markus Unterwaditzer) * the zsh completion file has been updated (Hugo Osvaldo Barrera) * new command `import` lets users import .ics files (Christian Geier) * khal should accept relative dates on the command line (today, tomorrow and weekday names) (Christian Geier) * keybinding for saving an event from ikhal's event editor (default is `meta + enter`) (Christian Geier) 0.5.0 ===== released on 2015-06-01 * fixed several bugs relating to events with unknown timezones but UNTIL, RDATE or EXDATE properties that are in Zulu time (thanks to Michele Baldessari for reporting those) * bugfix: on systems with a local time of UTC-X dealing with allday events lead to crashes * bugfix: British summer time is recognized as daylight saving time (Bradley Jones) * compatibility with vdirsyncer 0.5 * new command `search` allows searching for events * user changeable keybindings in ikhal, with hjkl as default alternatives for arrows in calendar browser, see documentation for more details * new command `at` shows all events scheduled for a specific datetime * support for reading birthdays from vcard collections (set calendar/collection `type` to *birthdays*) * new command `printformats` prints a fixed date in all configured date-time settings * `new` now supports the `--until`/`-u` flag to specify until when recurring events should run (Micah Nordland) * python 3 (>= 3.3) support (Hugo Osvaldo Barrera) ikhal ----- * minimal support for reccurring events in ikhal's editor (Micah Nordland) * configurable view size in ikhal (Bradley Jones) * show events organizers (Bradley Jones) * major reorganisation of ikhal layout (Markus Unterwaditzer) 0.4.0 ===== released on 2015-02-02 dependency changes ------------------ * new dependency: click>3.2 * removed dependency: docopt * note to package mantainers: `requirements.txt` has been removed, dependencies are still listed in `setup.py` note to users ------------- * users will need to delete the local database, no data should be lost (and khal will inform the user about this) new and changed features ------------------------ * new config_option: `[default] print_new`, lets the user decide what should be printed after adding a new event * new config option: `[default] show_all_days` lets users decide if they want to see days without any events in agenda and calendar view (thanks to Pierre David) * khal (and ikhal) can now display weeknumbers * khal new can now create repetitive events (with --repeat), see documentation (thanks to Eric Scheibler) * config file: the debug option has been removed (use `khal -v` instead) * FIX: vtimezones were not assembled properly, this lead to spurious offsets of events in some other calendar applications * change in behaviour: recurring events are now always expanded until 2037 * major speedup in inserting events into the caching database, especially noticeable when running khal for the first time or after a deleting the database (Thanks to Markus Unterwaditzer) * better support for broken events, e.g. events ending before they start (Thanks to Markus Unterwaditzer) * more recurrence rules are supported, khal will print warnings on unsupported rules ikhal ----- * ikhal's calendar should now be filled on startup * pressing `t` refocuses on today * pressing ctrl-w in input fields should delete the last word before the cursor * when the focus is set on the events list/editor, the current date should still be visible in the calendar 0.3.1 ===== released on 2014-09-08 * FIX: events deleted in the vdir are not shown anymore in khal. You might want to delete your local database file, if you have deleted any events on the server. * FIX: in some cases non-ascii characters were printed even if unicode_symbols is set to False in the config * FIX: events with different start and end timezones are now properly exported (the end timezone was disregarded when building an icalendar, but since timezones cannot be edited anyway, this shouldn't have caused any problems) * FIX: calendars marked as read-only in the configuration file should now really be read-only 0.3.0 ===== released on 2014-09-03 * new unified documentation * html documentation (website) and man pages are all generated from the same sources via sphinx (type `make html` or `make man` in doc/, the result will be build in *build/html* or *build/man* respectively) * the new documentation lives in doc/ * the package sphinxcontrib-newsfeed is needed for generating the html version (for generating an RSS feed) * the man pages live doc/build/man/, they can be build by running `make man` in doc/sphinx/ * new dependencies: configobj, tzlocal>=1.0 * **IMPORTANT**: the configuration file's syntax changed (again), have a look at the new documentation for details * local_timezone and default_timezone will now be set to the timezone the computer is set to (if they are not set in the configuration file) khal-0.11.4/CODE_OF_CONDUCT.rst000066400000000000000000000000631477603436700155430ustar00rootroot00000000000000See `the pimutils CoC `_. khal-0.11.4/CONTRIBUTING.rst000066400000000000000000000001741477603436700152000ustar00rootroot00000000000000Please see `the documentation `_ for how to contribute to this project. khal-0.11.4/COPYING000066400000000000000000000020521477603436700135670ustar00rootroot00000000000000Copyright (c) 2013-2022 khal contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. khal-0.11.4/MANIFEST.in000066400000000000000000000002371477603436700142750ustar00rootroot00000000000000include khal.conf.sample include README.rst include CONTRIBUTING.txt include AUTHORS.txt include COPYING include CHANGELOG.rst include khal/settings/khal.spec khal-0.11.4/README.rst000066400000000000000000000103251477603436700142250ustar00rootroot00000000000000khal ==== .. image:: https://github.com/pimutils/khal/actions/workflows/ci.yml/badge.svg?branch=master&event=push :target: https://github.com/pimutils/khal/actions/workflows/ci.yml .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master :target: https://codecov.io/github/pimutils/khal?branch=master .. image:: https://readthedocs.org/projects/khal/badge/?version=latest&style=flat :target: https://khal.readthedocs.io/en/latest/ *Khal* is a standards based CLI and terminal calendar program, able to synchronize with CalDAV_ servers through vdirsyncer_. .. image:: http://lostpackets.de/images/khal.png Features -------- (or rather: limitations) - khal can read and write events/icalendars to vdir_, so vdirsyncer_ can be used to `synchronize calendars with a variety of other programs`__, for example CalDAV_ servers. - fast and easy way to add new events - ikhal (interactive khal) lets you browse and edit calendars and events - no support for editing the timezones of events yet - works with python 3.8+ - khal should run on all major operating systems [1]_ .. [1] except for Microsoft Windows Feedback -------- Please do provide feedback if *khal* works for you or even more importantly if it doesn't. The preferred way to get in contact (especially if something isn't working) is via github or via IRC (#pimutils on Libera.Chat). .. _vdir: https://vdirsyncer.readthedocs.org/en/stable/vdir.html .. _vdirsyncer: https://github.com/pimutils/vdirsyncer .. _CalDAV: http://en.wikipedia.org/wiki/CalDAV .. _github: https://github.com/pimutils/khal/ .. __: http://en.wikipedia.org/wiki/Comparison_of_CalDAV_and_CardDAV_implementations Documentation ------------- For khal's documentation have a look at readthedocs_. .. _readthedocs: http://khal.readthedocs.org/ Installation ------------ khal is packaged for most `operating systems`__ and should be installable with your standard package manager. .. __: https://repology.org/project/python:khal/versions For some exemplary OS you can find installation instructions below. Otherwise see the documentation_ for more information. .. _documentation: https://khal.readthedocs.io/en/latest/install.html Debian/Ubuntu ~~~~~~~~~~~~~ apt install khal Nix ~~~ nix-env -i khal Arch ~~~~ pacman -S khal Brew ~~~~ brew install khal Fedora ~~~~~~ dnf install khal FreeBSD ~~~~~~~ pkg install py-khal Install latest version ~~~~~~~~~~~~~~~~~~~~~~ pip install git+https://github.com/pimutils/khal Alternatives ------------ Projects with similar aims you might want to check out are calendar-cli_ (no offline storage and a bit different scope) and gcalcli_ (only works with google's calendar). .. _calendar-cli: https://github.com/tobixen/calendar-cli .. _gcalcli: https://github.com/insanum/gcalcli Contributing ------------ You want to contribute to *khal*? Awesome! The most appreciated way of contributing is by supplying code or documentation, reporting bugs, creating packages for your favorite operating system, making khal better known by telling your friends about it, etc. License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2022 khal contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. khal-0.11.4/bin/000077500000000000000000000000001477603436700133055ustar00rootroot00000000000000khal-0.11.4/bin/ikhal000066400000000000000000000001431477603436700143160ustar00rootroot00000000000000#!/usr/bin/env python from khal.cli import main_ikhal if __name__ == "__main__": main_ikhal() khal-0.11.4/bin/khal000066400000000000000000000001411477603436700141430ustar00rootroot00000000000000#!/usr/bin/env python from khal.cli import main_khal if __name__ == "__main__": main_khal() khal-0.11.4/codecov.yml000066400000000000000000000001071477603436700147000ustar00rootroot00000000000000coverage: status: patch: false project: false comment: false khal-0.11.4/doc/000077500000000000000000000000001477603436700133025ustar00rootroot00000000000000khal-0.11.4/doc/Makefile000066400000000000000000000151631477603436700147500ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettextp source help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/khal.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/khal.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/khal" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/khal" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." khal-0.11.4/doc/source/000077500000000000000000000000001477603436700146025ustar00rootroot00000000000000khal-0.11.4/doc/source/changelog.rst000066400000000000000000000000611477603436700172600ustar00rootroot00000000000000.. _changelog: .. include:: ../../CHANGELOG.rst khal-0.11.4/doc/source/conf.py000066400000000000000000000241541477603436700161070ustar00rootroot00000000000000# noqa: E265 # # khal documentation build configuration file, created by # sphinx-quickstart on Fri Jul 4 00:00:47 2014. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. from configobj import ConfigObj import khal try: # Available from configobj 5.1.0 import configobj.validate as validate except ModuleNotFoundError: import validate # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) # -- Generate configspec.rst ---------------------------------------------- specpath = '../../khal/settings/khal.spec' config = ConfigObj( None, configspec=specpath, stringify=False, list_values=False ) validator = validate.Validator() config.validate(validator) spec = config.configspec def write_section(specsection, secname, key, comment, output): # why is _parse_check a "private" method? seems to be rather useful... # we don't need fun_kwargs fun_name, fun_args, fun_kwargs, default = validator._parse_check(specsection) output.write(f'\n.. _{secname}-{key}:') output.write('\n') output.write(f'\n.. object:: {key}\n') output.write('\n') output.write(' ' + '\n '.join([line.strip('# ') for line in comment])) output.write('\n') if fun_name == 'option': fun_args = [f'*{arg}*' for arg in fun_args] fun_args = fun_args[:-2] + [fun_args[-2] + ' and ' + fun_args[-1]] fun_name += f", allowed values are {', '.join(fun_args)}" fun_args = [] if fun_name == 'integer' and len(fun_args) == 2: fun_name += f', allowed values are between {fun_args[0]} and {fun_args[1]}' fun_args = [] output.write('\n') if fun_name in ['expand_db_path', 'expand_path']: fun_name = 'string' elif fun_name in ['force_list']: fun_name = 'list' if isinstance(default, list): default = ['space' if one == ' ' else one for one in default] default = ', '.join(default) output.write(f' :type: {fun_name}') output.write('\n') if fun_args != []: output.write(f' :args: {fun_args}') output.write('\n') output.write(f' :default: {default}') output.write('\n') with open('configspec.rst', 'w') as f: for secname in sorted(spec): f.write('\n') heading = f'The [{secname}] section' f.write(f'{heading}\n{ len(heading) * "~"}') f.write('\n') comment = spec.comments[secname] f.write('\n'.join([line[2:] for line in comment])) f.write('\n') for key, comment in sorted(spec[secname].comments.items()): if key == '__many__': comment = spec[secname].comments[key] f.write('\n'.join([line[2:] for line in comment])) f.write('\n') for key, comment in sorted(spec[secname]['__many__'].comments.items()): write_section(spec[secname]['__many__'][key], secname, key, comment, f) else: write_section(spec[secname][key], secname, key, comment, f) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinxcontrib.newsfeed', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['ytemplates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'khal' copyright = 'Copyright (c) 2013-2022 khal contributors' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = khal.__version__ # The full version, including alpha/beta/rc tags. release = khal.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['configspec.rst'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { 'github_user': 'pimutils', 'github_repo': 'khal', } # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['ystatic'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. html_sidebars = { '**': [ 'about.html', 'navigation.html', 'relations.html', 'searchbox.html', 'donate.html', ] } # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = False # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'khaldoc' # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('man', 'khal', 'khal Documentation', ['Christan Geier et al.'], 1) ] # If true, show URL addresses after external links. man_show_urls = True # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'khal', 'khal Documentation', 'khal contributors', 'khal', 'A standards based calendar program', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} khal-0.11.4/doc/source/configure.rst000066400000000000000000000041401477603436700173140ustar00rootroot00000000000000Configuration ============= :command:`khal` reads configuration files in the *ini* syntax, meaning it understands keys separated from values by a **=**, while section and subsection names are enclosed by single or double square brackets (like **[sectionname]** and **[[subsectionname]]**). Any line beginning with a **#** will be treated as a comment. Help with initial configuration ------------------------------- If you do not have a configuration file yet, running :command:`khal configure` will launch a small, interactive tool that should help you with initial configuration of :command:`khal`. Location of configuration file ------------------------------ :command:`khal` is looking for configuration files in the following places and order: :file:`$XDG_CONFIG_HOME/khal/config` (on most systems this is :file:`~/.config/khal/config`), :file:`~/.khal/khal.conf` (deprecated) and a file called :file:`khal.conf` in the current directory (deprecated). Alternatively you can specify which configuration file to use with :option:`-c path/to/config` at runtime. .. include:: configspec.rst A minimal sample configuration could look like this: Example ------- .. literalinclude:: ../../tests/configs/simple.conf :language: ini Exemplary discover usage ------------------------- If you have the following directory layout:: ~/calendars ├- work/ ├- home/ └─ family/ where `work`, `home` and `family` are all different vdirs, each containing one calendar, a matching calendar section could look like this: .. highlight:: ini :: [[calendars]] path = ~/calendars/* type = discover color = dark green Syncing ------- To get :command:`khal` working with CalDAV you will first need to setup vdirsyncer_. After each start :command:`khal` will automatically check if anything has changed and automatically update its caching db (this may take some time after the initial sync, especially for large calendar collections). Therefore, you might want to execute :command:`khal` automatically after syncing with :command:`vdirsyncer` (e.g. via :command:`cron`). .. _vdirsyncer: https://github.com/pimutils/vdirsyncer khal-0.11.4/doc/source/faq.rst000066400000000000000000000007521477603436700161070ustar00rootroot00000000000000FAQ === Frequently asked questions: * **Installation stops with an error: source/str_util.c:25:20: fatal error: Python.h: No such file or directory** You do not have the Python development headers installed, on Debian based Distributions you can install them via *aptitude install python-dev*. * **unknown key "default_command"** This key was deprecated by f8d9135. See https://github.com/pimutils/khal/issues/648 for the rationale behind this removal. khal-0.11.4/doc/source/feedback.rst000066400000000000000000000045241477603436700170650ustar00rootroot00000000000000Feedback ======== .. note:: All participants must follow the `pimutils Code of Conduct `_. Please do provide feedback if *khal* works for you or even more importantly, if it doesn't. Feature requests and other ideas on how to improve khal are also welcome (see below). In case you are not satisfied with khal, there are at least two other projects with similar aims you might want to check out: calendar-cli_ (no offline storage and a bit different scope) and gcalcli_ (only works with google's calendar). .. _calendar-cli: https://github.com/tobixen/calendar-cli .. _gcalcli: https://github.com/insanum/gcalcli Submitting a Bug ---------------- If you found a bug or any part of khal isn't working as you expected, please check if that bug is also present in the latest version from github (see :doc:`install`) and is not already reported_ (you still might want to comment on an already open issue). If it isn't, please open a new bug. In case you submit a new bug report, please include: * how you ran khal (please run in verbose mode with `-v DEBUG`) * what you expected khal to do * what it did instead * everything khal printed to the screen (you may redact private details) * in case khal complains about a specific .ics file, please include that as well (or create a .ics which leads to the same error without any private information) * the version of khal and python you are using, which operating system you are using and how you installed khal Suggesting Features ------------------- If you believe khal is lacking a useful feature or some part of khal is not working the way you think it should, please first check if there isn't already a relevant issue_ for it and otherwise open a new one. .. _contact: Contact ------- * You might get quick answers on the `#pimutils`_ IRC channel on Libera.Chat, if nobody is answering you, please hang around for a bit. You can also use this channel for general discussions about :command:`khal` and `related tools`_. * Open a github issue_ * If the above mentioned methods do not work, you can always contact the `main developer`_. .. _#pimutils: irc://#pimutils@Libera.Chat .. _related tools: https://github.com/pimutils/ .. _issue: https://github.com/pimutils/khal/issues .. _reported: https://github.com/pimutils/khal/issues .. _main developer: https://lostpackets.de khal-0.11.4/doc/source/hacking.rst000066400000000000000000000267121477603436700167500ustar00rootroot00000000000000Hacking ======= .. note:: All participants must follow the `pimutils Code of Conduct `_. **Please discuss your ideas with us, before investing a lot of time into khal** (to make sure, no efforts are wasted). Also, if you have any questions on khal's codebase, please don't hesitate to :ref:`contact ` us, we will gladly provide you with any information you need or set up a joined hacking session. The preferred way of submitting patches is via `github pull requests`_ (PRs). If you are not comfortable with that, please :ref:`contact ` us and we can work out something else. If you have something working, don't hesitate to open a PR very early and ask for opinions. Before we will accept your PR, we will ask you to: * add yourself to ``AUTHORS.txt`` if you haven't done it before * add a note to ``CHANGELOG.rst`` explaining your changes (if you changed anything user facing) * edit the documentation (again, only if your changes impact the way users interact with khal) * make sure all tests pass (see below) * write some tests covering your patch (this really is mandatory, unless it's in the urwid part, testing which is often difficult) * make sure your patch conforms with :pep:`008` (should be covered by passing tests) Plugins ------- Khal now supports plugins, currently for supporting new commands (`example command plugin`_), formatting (`example formatting plugin`_), and colors (`example color plugin`_). If you want to develop a new feature, please check if it can be implemented as a plugin. If you are unsure, please ask us, we will gladly help you and, if needed, also extend the plugin API. We would like to see new functionality matured in plugins before we consider integrating it into khal's core. .. _`example command plugin`: https://github.com/geier/khal_navigate .. _`example formatting plugin`: https://github.com/tcuthbert/khal/tree/plugin/example .. _`example color plugin`: https://github.com/geier/khal_gruvbox/tree/importlib Color scheme plugins ********************* Khal color schemes plugins are only availlable for the `ikhal` interface. They are installed as python packages (e.g. `python -m pip install khal_gruvbox`). A color scheme plugin must provide an entry point `khal_colorscheme` and contain an urwid palette definition. The palette definition is a list of tuples, where each tuple contains an attribute name and a color definition. See the `urwid documentation`_ for more information. All currently avaialable attributes can be found in `khal's source code`_. .. _`urwid documentation`: http://urwid.org/manual/displayattributes.html .. _`khal's source code`: https://github.com/pimutils/khal/blob/master/khal/ui/colors.py General notes for developing khal (and lots of other python packages) --------------------------------------------------------------------- The below notes are meant to be helpful if you are new to developing python packages in general and/or khal specifically. While some of these notes are therefore specific to khal, most should apply to lots of other python packages developed in comparable setup. Please note that all commands (if not otherwise noted) should be executed at the root of khal's source directory, i.e., the directory you got by cloning khal via git. Please note that fixes and enhancements to these notes are very welcome, too. Isolation ********* When working on khal (or for any other python package) it has proved very beneficial to create a new *virtual environments* (with the help of virtualenv_), to be able to run khal in isolation from your globally installed python packages and to ensure to not run into any conflicts very different python packages depend on different version of the same library. virtualenvwrapper_ (for bash and zsh users) and virtualfish_ (for fish users) are handy wrappers that make working with virtual environments very comfortable. After you have created and activated a virtual environment, it is recommended to install khal via :command:`pip install -e .` (from the base of khal's source directory), this install khal in an editable development mode, where you do not have to reinstall khal after every change you made, but where khal will always have picked up all the latest changes (except for adding new files, hereafter reinstalling khal *is* necessary). Testing ******* khal has an extensive self test suite, that lives in :file:`tests/`. To run the test suite, install `pytest` and run :command:`py.test tests`, pytest will then collect and run all tests and report on any failures (which you should then proceed to fix). If you only want to run tests contained in one file, run, e.g., :command:`py.test tests/backend_test.py`. If you only want to run one or more specific tests, you can filter for them with :command:`py.test -k calendar`, which would only run tests including `calendar` in their name. To ensure that khal runs on all currently supported version of python, the self test suite should also be run with all supported versions of python. This can locally be done with tox_. After installing tox, running tox will create new virtual environments (which it will reuse on later runs), one for each python version specified in :file:`tox.ini`, run the test suite and report on it. If you open a pull request (*PR*) on github, the continuous integration service `GitHub Actions`_ will automatically perform exactly those tasks and then comment on the success or failure. If you make any non-trivial changes to khal, please ensure that those changes are covered by (new) tests. As testing :command:`ikhal` (the part of :command:`khal` making use of urwid_) has proven rather complicated (as can be seen in the lack tests covering that part of khal), automated testing of changes of that part is therefore not mandatory, but very welcome nonetheless. To make sure all major code paths are run through at least once, please check the *coverage* the tests provide. This can be done with pytest-cov_. After installing pytest-cov, running :command:`py.test --cov khal --cov-report=html tests` will generate an html-based report on test coverage (which can be found in :file:`htmlcov`), including a color-coded version of khal's source code, indicating which lines have been run and which haven't. Debugging ********* For an improved debugging experience on the command line, `pdb++`_ is recommended (install with :command:`pip install pdbpp`). :command:`pdb++` is a drop in replacement for python's default debugger, and can therefore be used like the default debugger, e.g., invoked by placing ``import pdb; pdb.set_trace()`` at the respective place. One of the main reasons for choosing :command:`pdb++` over alternatives like IPython's debugger ipdb_, is that it works nicely with :command:`pytest`, e.g., running `py.test --pdb tests` will drop you at a :command:`pdb++` prompt at the place of the first failing test. Documentation ************* Khal's documentation, which is living in :file:`doc`, is using sphinx_ to generate the html documentation as well as the man page from the same sources. After install `sphinx` and `sphinxcontrib-newsfeed` you should be able to build the documentation with :command:`make html` and :command:`make man` respectively from the root of the :file:`doc` directory (note that this requires `GNU make`, so on some system running :command:`gmake` may be required). If you make any changes to how a user would interact with khal, please change or add the relevant section(s) in the documentation, which uses the reStructuredText_ format, which shouldn't be too hard to use after looking at some of the existing documentation (even for users who never used it before). Also, summarize your changes in :file:`CHANGELOG.rst`, pointing readers to the (updated) documentation is fine. Code Style ********** khal's source code should adhere to the rules laid out in :pep:`008`, except for allowing line lengths of up to 100 characters if it improves overall legibility (use your judgement). This can be checked by installing and running ruff_ (run with :command:`ruff` from khal's source directory), which will also be run with tox and GitHub Actions, see section above. We try to document the parameters functions and methods accept, including their types, and their return values in the `sphinx style`_, though this is currently not used thoroughly. Note that we try to use double quotes for human readable strings, e.g., strings that one would internationalize and single quotes for strings used as identifiers, e.g., in dictionary keys:: my_event['greeting'] = "Hello World!" .. _github: https://github.com/pimutils/khal/ .. _reported: https://github.com/pimutils/khal/issues?state=open .. _issue: https://github.com/pimutils/khal/issues .. _GitHub Actions: https://github.com/pimutils/khal/actions/workflows/ci.yml .. _github pull requests: https://github.com/pimutils/khal/pulls .. _tox: https://tox.readthedocs.org/ .. _pytest: http://pytest.org/ .. _pytest-cov: https://pypi.python.org/pypi/pytest-cov .. _ruff: https://github.com/charliermarsh/ruff .. _sphinx: http://www.sphinx-doc.org .. _restructuredtext: http://www.sphinx-doc.org/en/1.5.1/rest.html .. _ipdb: https://pypi.python.org/pypi/ipdb .. _pdb++: https://pypi.python.org/pypi/pdbpp/ .. _urwid: http://urwid.org/ .. _virtualenv: https://virtualenv.pypa.io/en/stable/ .. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io/ .. _virtualfish: https://github.com/adambrenecki/virtualfish .. _sphinx style: http://www.sphinx-doc.org/en/1.5.1/domains.html#info-field-lists iCalendar peculiarities ----------------------- These notes are meant for people who want to deep dive into :file:`khal.khalendar.backend.py` and are not recommended reading material for anyone else. A single `.ics` can contain several VEVENTS, which might or might not be the part of the same event. This can lead to issues with straight forward implementations. Some of these, and the way khal is dealing with them, are described below. While one would expect every VEVENT to have its own unique UID (for what it's worth they are named *unique identifier*), there is a case where several VEVENTS have the same UID, but do describe the same (recurring) event. In this case, one VEVENT, containing an RRULE or RDATE element would be the *proto* event, from which all recurrence instances are derived. All other VEVENTS with the same UID would then have a RECURRENCE-ID element (I'll call them *child* event from now on) and describe deviations of at least one recurrence instance (RECURRENCE-ID elements can also have the added property RANGE=THISANDFUTURE, meaning the deviations described by this child event also apply to all further recurrence instances. Because it is possible that an event already in the database consists of a master event and at least one child event gets updated and then consists only of a master event, we currently *delete* all events with the same UID from the database when inserting or updating a new event. But this means that we need to update an event always at once (master and all child events) at the same time (using `Calendar.update()` or `Calendar.new()` in this case) As this wouldn't be bad enough, the standard looses no words on the ordering on those VEVENTS in any given `.ics` file (at least I didn't find any). Not only can the proto event be *behind* any or all RECURRENCE-ID events, but also events with different UIDs can be in between. We therefore currently first collect all events with the same UID and then sort those by their type (proto or child), and the children by the value of the RECURRENCE-ID property. khal-0.11.4/doc/source/images/000077500000000000000000000000001477603436700160475ustar00rootroot00000000000000khal-0.11.4/doc/source/images/rss.png000066400000000000000000000016001477603436700173610ustar00rootroot00000000000000PNG  IHDRasBIT|d pHYs  ~tEXtSoftwareAdobe Fireworks CS4ӠtEXtCreation Time20/7/09zcIDAT8MKUsLOwNbM8Y F, M BDIY2K!."(( B>F7>bBtH;y8=U5_G`,:&܋g1bpn*Gc ?DmjM)(|D*d KAA<>8 ݠ-A1l U 3 XE|zW,3!.8U]َ Sr^; 4c`3H`{e x0FL>1Ъl-a=̣~$\xXF%?PEA`Q*+d'BF"GtɵCgan!v0[Hs= #"]̙Qw B8# `;aS8åc(w=@BI,Fs)EA!?||'25G / 4y\x7hĠ ;ѧ2JC`_. Otherwise, you can always download the latest release from pypi_ and execute:: python setup.py install or better:: pip install . in the unpacked distribution folder. Since version 0.11, *khal* **only supports python 3.8+**. If you have python 2 and 3 installed in parallel you might need to use `pip3` instead of `pip` and `python3` instead of `python`. In case your operating system cannot deal with python 2 and 3 packages concurrently, we suggest installing *khal* in a virtualenv_ (e.g. by using virtualenvwrapper_ or with the help of pipsi_) and then starting khal from that virtual environment. .. _pipsi: https://github.com/mitsuhiko/pipsi .. _pypi: https://pypi.python.org/pypi/khal .. _virtualenv: https://virtualenv.pypa.io .. _virtualenvwrapper: http://virtualenvwrapper.readthedocs.org/ Shell Completion ---------------- khal uses click_ for it's commpand line interface. click can automatically generate completion_ files for bash, fish, and zsh which can either be generated and sourced each time your shell is started or, generated once, save to a file and sourced from there. The latter version is much more efficient. .. note:: If you package khal, please generate and include the completion file in the package if possible. .. _click: https://click.palletsprojects.com .. _completion: https://click.palletsprojects.com/en/8.1.x/shell-completion/ bash ~~~~ For bash, you can either add the following line to your ``.bashrc``:: eval "$(_KHAL_COMPLETE=bash_source khal)" Or, save the file somewhere:: _KHAL_COMPLETE=bash_source khal > ~/.khal-complete.bash and then source it from your ``.bashrc``:: . ~/.khal-complete.bash zsh ~~~ For zsh, you can either add the following line to your ``.zshrc``:: eval "$(_KHAL_COMPLETE=zsh_source khal)" Or, save the file somewhere:: _KHAL_COMPLETE=zsh_source khal > ~/.khal-complete.zsh and then source it from your ``.zshrc``:: . ~/.khal-complete.zsh fish ~~~~ For fish, add this to ``~/.config/fish/completions/khal.fish``:: eval (env _KHAL_COMPLETE=fish_source khal) Or save the script to ``~/.config/fish/completions/khal.fish``:: _KHAL_COMPLETE=fish_source khal > ~/.config/fish/completions/khal.fish Note, that the latter basically does the same as the former and no efficiency is gained. .. _requirements: Requirements ------------ *khal* is written in python and can run on Python 3.8+. It requires a Python with ``sqlite3`` support enabled (which is usually the case). If you are installing python via *pip* or from source, be aware that since *khal* indirectly depends on lxml_ you need to either install it via your system's package manager or have python's libxml2's and libxslt1's headers (included in a separate "development package" on some distributions) installed. .. _icalendar: https://github.com/collective/icalendar .. _vdirsyncer: https://github.com/pimutils/vdirsyncer .. _lxml: http://lxml.de/ Packaging --------- If your packages are generated by running ``setup.py install`` or some similar mechanism, you'll end up with very slow entry points (eg: ``/usr/bin/khal``). Package managers should use the files included in ``bin`` as a replacement for those. The root cause of the issue is really how python's setuptools generates these and outside of the scope of this project If your packages are generated using python wheels, this should not be an issue (much like it won't be an issue for users installing via ``pip``). khal-0.11.4/doc/source/license.rst000066400000000000000000000022461477603436700167620ustar00rootroot00000000000000License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2022 khal contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. khal-0.11.4/doc/source/man.rst000066400000000000000000000005421477603436700161100ustar00rootroot00000000000000khal ==== Khal is a calendar program for the terminal for viewing, adding and editing events and calendars. Khal is build on the iCalendar and vdir (allowing the use of :manpage:`vdirsyncer(1)` for CalDAV compatibility) standards. Table of Contents ================= .. toctree:: :maxdepth: 1 usage configure standards faq license khal-0.11.4/doc/source/news.rst000066400000000000000000000014531477603436700163130ustar00rootroot00000000000000News ==== Below is a list of new releases and other khal related news. This is also available as an `rss feed `_ |rss|. .. |rss| image:: images/rss.png :target: https://lostpackets.de/khal/index.rss .. feed:: :rss: index.rss :title: khal news :link: http://lostpackets.de/khal/ news/khal0100 news/khal098 news/khal097 news/khal096 news/khal095 news/khal094 news/khal093 news/khal092 news/khal091 news/khal09 news/khal071 news/khal084 news/khal083 news/khal082 news/khal081 news/khal08 news/khal07 news/khal06 news/khal05 news/khal04 news/31c3 news/khal031 news/khal03 news/khal02 news/khal011 news/khal01 news/30c3 news/callfortesting khal-0.11.4/doc/source/news/000077500000000000000000000000001477603436700155565ustar00rootroot00000000000000khal-0.11.4/doc/source/news/30c3.rst000066400000000000000000000006351477603436700167640ustar00rootroot00000000000000pycarddav and khal at 30c3 ========================== .. feed-entry:: :date: 2013-12-13 If you will be 30C3_ and would like to discuss the faults and merits of khal or pycarddav, commandline calendaring/addressbooking in general, your ideas or just have a beer or mate, I'd love to meet up. You can find my contact details under *Feedback*. .. _30C3: https://events.ccc.de/congress/2013/wiki/Main_Page khal-0.11.4/doc/source/news/31c3.rst000066400000000000000000000006401477603436700167610ustar00rootroot00000000000000pycarddav and khal at 31c3 ========================== .. feed-entry:: :date: 2014-12-09 If you will be at 31C3_ and would like to discuss the faults and merits of khal or pycarddav, commandline calendaring/addressbooking in general, your ideas or just have a beer or mate, I'd love to meet up. You can find my contact details under *Feedback*. .. _31C3: https://events.ccc.de/congress/2014/wiki/Main_Page khal-0.11.4/doc/source/news/callfortesting.rst000066400000000000000000000011271477603436700213310ustar00rootroot00000000000000Call for Testing ================= .. feed-entry:: :date: 2013-11-19 While there isn't a release yet, *khal* is, at least partly, in a usable shape by now. Please report any errors you stumble upon and improvement suggestions you have either via email or github_ (if you don't have any privacy concerns etc. I'd prefer you use github since it is public, but I'll soon set up a mailing list). TODO.rst_ gives you an idea about the plans I currently have for *khal*'s near future. .. _github: https://github.com/geier/khal/ .. _TODO.rst: https://github.com/geier/khal/blob/master/TODO.rst khal-0.11.4/doc/source/news/khal01.rst000066400000000000000000000011221477603436700173640ustar00rootroot00000000000000khal v0.1 released ================== .. feed-entry:: :date: 2014-04-03 The first release of khal is here: `khal v0.1.0`__ (and also available on pypi_ now). __ https://lostpackets.de/khal/downloads/khal-0.1.0.tar.gz The next release, hopefully coming rather sooner than later, will get rid of its own CalDAV implementation, but instead use vdirsyncer_; you can already try it out via checking out the branch *vdir* at github_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _vdirsyncer: https://github.com/pimutils/vdirsyncer/ .. _github: https://github.com/geier/khal/tree/vdir khal-0.11.4/doc/source/news/khal0100.rst000066400000000000000000000041111477603436700175250ustar00rootroot00000000000000khal v0.10.0 released ===================== .. feed-entry:: :date: 2019-03-25 This is not only the first bugfix release in more than a year, but also the first release containing new features in nearly two years. v0.10.0 contains some breaking changes from earlier versions of khal, most notably the removal of the default command [1]_. For users who want the old functionality back, a shell function seems to be the best option. Please use the wiki_ to share your solutions. Have a look at the Changelog_ for more changes and fixes. With this release I want to bring the aforementioned changes to more people, find (and fix) as many more bugs as possible and then release a version 1.0. For convenience issues, khal will only be available on pypi_ in the future, with minor versions (v0.10.x) not getting announced here any more. Users and packagers who want to stay on a current version of khal are therefore advised to watch pypi_ for new versions. .. Important:: **Contributors and maintainers wanted** As I find myself having less and less time to devout to khal, I'm looking for more developers and maintainers. Even if you are not a python developer, your help with helping new users and triaging and prioritizing bugs is very much appreciated. If you don't know how to contact the current team, open an `issue on github`_. **vdirsyncer**, which many khal users are probably dependend on, is also looking for new maintainers_. .. [1] The implementation of the default command proved to be a source of constant headache for users and developers alike. This was due to the library chosen for handling argument parsing. Feel free to share other suggestions in the wiki_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _issue on github: https://github.com/pimutils/khal/issues .. _issues: https://github.com/pimutils/khal/issues .. _wiki: https://github.com/pimutils/khal/wiki/Default-command-alternatives .. _changelog: changelog.html#id2 .. _vdirsyncer: https://github.com/pimutils/vdirsyncer .. _maintainers: https://github.com/pimutils/vdirsyncer/issues/790 khal-0.11.4/doc/source/news/khal011.rst000066400000000000000000000003421477603436700174500ustar00rootroot00000000000000khal v0.1.1 released ==================== .. feed-entry:: :date: 2014-05-07 A small bugfix release: `khal v0.1.0`__ Example config file now in source dist. __ https://lostpackets.de/khal/downloads/khal-0.1.1.tar.gz khal-0.11.4/doc/source/news/khal02.rst000066400000000000000000000023041477603436700173700ustar00rootroot00000000000000khal v0.2 released ================== .. feed-entry:: :date: 2014-06-27 A new release of khal is here: `khal v0.2.0`__ (also available on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.2.0.tar.gz If you want to update your installation from pypi_, you can run `sudo pip install --upgrade khal`. From now on *khal* relies on vdirsyncer_ for CalDAV sync. While this makes *khal* a bit more complicated to setup, *vdirsyncer* is much better tested than *khal* and also the `bus factor`__ increased (at least for parts of the project). __ http://en.wikipedia.org/wiki/Bus_factor You might want to head over to the tutorial_ on how to setup *vdirsyncer*. Afterwards you will need to re-setup your *khal* configuration (copy the new example config file), also you will need to delete your old (local) database, so please make sure you did sync everything. Also *khal*'s command line syntax changed quite a bit, so you might want to head over the documentation_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _vdirsyncer: https://github.com/pimutils/vdirsyncer/ .. _tutorial: https://vdirsyncer.readthedocs.org/en/latest/tutorial.html .. _documentation: http://lostpackets.de/khal/pages/usage.html khal-0.11.4/doc/source/news/khal03.rst000066400000000000000000000024651477603436700174010ustar00rootroot00000000000000khal v0.3 released ================== .. feed-entry:: :date: 2014-09-03 A new release of khal is here: `khal v0.3.0`__ (also available on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.3.0.tar.gz If you want to update your installation from pypi_, you can run `sudo pip install --upgrade khal`. CHANGELOG --------- * new unified documentation * html documentation (website) and man pages are all generated from the same sources via sphinx (type `make html` or `make man` in doc/, the result will be build in *build/html* or *build/man* respectively (also available on `Read the Docs`__) * the new documentation lives in doc/ * the package sphinxcontrib-newsfeed is needed for generating the html version (for generating an RSS feed) * the man pages live doc/build/man/, they can be build by running `make man` in doc/sphinx/ * new dependencies: configobj, tzlocal>=1.0 * **IMPORTANT**: the configuration file's syntax changed (again), have a look at the new documentation for details * local_timezone and default_timezone will now be set to the timezone the computer is set to (if they are not set in the configuration file) __ https://khal.readthedocs.org .. _pypi: https://pypi.python.org/pypi/khal/ .. _vdirsyncer: https://github.com/pimutils/vdirsyncer/ khal-0.11.4/doc/source/news/khal031.rst000066400000000000000000000011631477603436700174540ustar00rootroot00000000000000khal v0.3.1 released ==================== .. feed-entry:: :date: 2014-09-08 A new release of khal is here: `khal v0.3.1`__ (also available on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.3.1.tar.gz This is a bugfix release, bringing no new features. The last release suffered from a major bug, where events deleted on the server (and in the vdir) were not deleted in khal's caching database and therefore still displayed in khal. Therefore, after updating please delete your local database. For more information on other fixed bugs, see :ref:`changelog`. .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal04.rst000066400000000000000000000014351477603436700173760ustar00rootroot00000000000000khal v0.4.0 released ==================== .. feed-entry:: :date: 2015-02-02 A new release of khal is here: `khal v0.4.0`__ (also available on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.4.0.tar.gz This release offers several functional improvements like better support for recurring events or a major speedup when creating the caching database and some new features like week number support or creating recurring events with `khal new --repeat`. Note to users ------------- khal now requires click_ instead of docopt_ and, as usual, the local database will need to be deleted. For a more detailed list of changes, please have a look at the :ref:`changelog`. .. _click: http://click.pocoo.org/ .. _docopt: http://docopt.org/ .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal05.rst000066400000000000000000000013621477603436700173760ustar00rootroot00000000000000khal v0.5.0 released ==================== .. feed-entry:: :date: 2015-06-01 A new release of khal is here: `khal v0.5.0`__ (also available on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.5.0.tar.gz This release brings a lot of new features (like rudimentary search support, user changeable keybindings in ikhal, new command `at`), python 3 support and some assorted bugfixes. Thanks to everybody who contributed with bug reports, suggestions and code, especially to everyone contributing for the first time! For a more detailed list of changes, please have a look at the changelog_. .. _click: http://click.pocoo.org/ .. _docopt: http://docopt.org/ .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 khal-0.11.4/doc/source/news/khal06.rst000066400000000000000000000013701477603436700173760ustar00rootroot00000000000000khal v0.6.0 released ==================== .. feed-entry:: :date: 2015-07-15 Only six weeks after the last version `khal v0.6.0`__ is now available (yes, also on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.6.0.tar.gz This release fixes an unfortunate bug which could lead to wrong shifts in other events when inserting complicated recurring events. All users are therefore advised to quickly upgrade to khal 0.6. There are also quite a bunch of new features, among other nicer editing capabilities in ikhal's text edits and import of .ics files. For a more detailed list of changes, please have a look at the changelog_ (especially if you package khal). .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 khal-0.11.4/doc/source/news/khal07.rst000066400000000000000000000015511477603436700174000ustar00rootroot00000000000000khal v0.7.0 released ==================== .. feed-entry:: :date: 2015-11-24 The latest version of khal has been released: `khal v0.7.0`__ (as always, also on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.7.0.tar.gz This release brings a lot of new features, by an ever increasing number of new contributors (welcome everyone!). With highlighting of days that have events we now have one of the most requested features implemented (because it does noticeably slow down khal's start it is disabled by default, startup performance will hopefully be increased soon). Among the other new features are duplicating events (in ikhal), prettier event display (also in ikhal) and a better zsh completion file. Have a look at the changelog_ for more complete list of new features. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 khal-0.11.4/doc/source/news/khal071.rst000066400000000000000000000016321477603436700174610ustar00rootroot00000000000000khal v0.7.1 released ==================== .. feed-entry:: :date: 2016-10-11 `khal v0.7.1`_ (pypi_) is a bugfix release that fixes a **critical bug** in `khal import`. This is a backport of the fix that got released with v0.8.4_, for those users than cannot (or *really* don't want to) upgrade to a more recent version of khal (most likely because of the dropped support for python 2). Please note, that khal v0.7.x is generally *not maintained* anymore, will not receive any new features, and any non-critical bugs will not be fixed either. See the `0.8.4 release announcement`_ for more details regarding the fixed bug. .. _khal v0.7.1: https://lostpackets.de/khal/downloads/khal-0.7.1.tar.gz .. _pypi: https://pypi.python.org/pypi?:action=display&name=khal&version=0.7.1 .. _v0.8.4: https://lostpackets.de/khal/news/khal084.html .. _0.8.4 release announcement: https://lostpackets.de/khal/news/khal084.html khal-0.11.4/doc/source/news/khal08.rst000066400000000000000000000017071477603436700174040ustar00rootroot00000000000000khal v0.8.0 released ==================== .. feed-entry:: :date: 2016-04-13 The latest version of khal has been released: `khal v0.8.0`__ (as always, also on pypi_). __ https://lostpackets.de/khal/downloads/khal-0.8.0.tar.gz We have recently dropped python 2 support, so this release is the first one that only supports python 3 (3.3+). There is one more backwards incompatible change: The color `grey` has been renamed to `gray`, if you use it in your configuration file, you will need to update to `gray`. There are some new features that should be configuring khal easier, especially for new users (e.g., new command `configure` helps with the initial configuration). Also alarms can now be entered either when creating new events with `new` or when editing them in ikhal. Have a look at the changelog_ for more complete list of new features (of which there are many). .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 khal-0.11.4/doc/source/news/khal081.rst000066400000000000000000000005521477603436700174620ustar00rootroot00000000000000khal v0.8.1 released ==================== .. feed-entry:: :date: 2016-04-13 The second version released today (`khal v0.8.1`__, yes, also on pypi_) fixes a bug in the CalendarWidget() that probably would not have trigged but made the tests fail. __ https://lostpackets.de/khal/downloads/khal-0.8.1.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal082.rst000066400000000000000000000010601477603436700174560ustar00rootroot00000000000000khal v0.8.2 released ==================== .. feed-entry:: :date: 2016-05-16 `khal v0.8.2`__ (pypi_) is a maintenance release that fixes several bugs in `configure` that would lead to crashes during the initial configuration and following runs of khal (due to an invalid configuration file getting written to disk) and improves the detection of the installed icalendar version. If khal currently works for you, there is no need for an upgrade. __ https://lostpackets.de/khal/downloads/khal-0.8.2.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal083.rst000066400000000000000000000005431477603436700174640ustar00rootroot00000000000000khal v0.8.3 released ==================== .. feed-entry:: :date: 2016-08-28 `khal v0.8.3`__ (pypi_) is a maintenance release that fixes several bugs, mostly in the test suite. If khal is working fine for you, there is no need to upgrade. __ https://lostpackets.de/khal/downloads/khal-0.8.3.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal084.rst000066400000000000000000000035071477603436700174700ustar00rootroot00000000000000khal v0.8.4 released ==================== .. feed-entry:: :date: 2016-10-06 `khal v0.8.4`_ (pypi_) is a bugfix release that fixes a **critical bug** in `khal import`. **All users are advised to upgrade as soon as possible**. Details ~~~~~~~ If importing events from `.ics` files, any VTIMEZONEs (specifications of the timezone) would *not* be imported with those events. As khal understands Olson DB timezone specifiers (such as "Europe/Berlin" or "America/New_York", events using those timezones are displayed in the correct timezone, but all other events are displayed as if they were in the configured *default timezone*. **This can lead to imported events being shown at wrong times!** Solution ~~~~~~~~ First, please upgrade khal to either v0.8.4 or, if you are using a version of khal directly from the git repository, upgrade to the latest version from github_. To see if you are affected by this bug, delete your local khal caching db, (usually `~/.local/share/khal/khal.db`), re-run khal and watch out for lines looking like this: ``warning: $PROPERTY has invalid or incomprehensible timezone information in $long_uid.ics in $my_collection``. You will then need to edit these files by hand and either replace the timezone identifiers with the corresponding one from the Olson DB (e.g., change `Europe_Berlin` to `Europe/Berlin`) or copy original VTIMZONE definition in. If you have any problems with this, please either open an `issue at github`_ or come into our `irc channel`_ (`#pimutils` on Libera.Chat). We are sorry for any inconveniences this is causing you! .. _khal v0.8.4: https://lostpackets.de/khal/downloads/khal-0.8.4.tar.gz .. _github: https://github.com/pimutils/khal/ .. _issue at github: https://github.com/pimutils/khal/issues .. _pypi: https://pypi.python.org/pypi/khal/ .. _irc channel: irc://#pimutils@Libera.Chat khal-0.11.4/doc/source/news/khal09.rst000066400000000000000000000020651477603436700174030ustar00rootroot00000000000000khal v0.9.0 released ==================== .. feed-entry:: :date: 2017-01-24 This is probably the biggest release of khal to date, that is, the one with the most changes since the last release. This changes are made up of a bunch of bug fixes and enhancements. Unfortunately, some of these break backwards compatibility, many of which will make themselves noticeable, because your config file will no longer be valid, consult the changelog_ or the documentation_. Noticable is also, that command `agenda` has been renamed to `list` Some of the larger changes include, among others, new configuration options on how events are printed (thanks to first time contributor Taylor Money) and a new look for ikhal's event-list column. Have a look at the changelog_ for more complete list of new features (of which there are many). Get `khal v0.9.0`__ from this site, or from pypi_. __ https://lostpackets.de/khal/downloads/khal-0.9.0.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ khal-0.11.4/doc/source/news/khal091.rst000066400000000000000000000020051477603436700174560ustar00rootroot00000000000000khal v0.9.1 released ==================== .. feed-entry:: :date: 2017-01-25 This is a bug fix release for python 3.6. Under python 3.6, datetimes with timezone information that is missing from the icalendar file would be treated if they were in the system's local timezone, not as if they were in khal's configured default timezone. This could therefore lead to erroneous offsets in start and end times for those events. To check if you are affected by this bug, delete khal's database file (usually :file:`~/.local/share/khal/khal.db`), rerun khal and watch for error messages that look like the one below: warning: DTSTART localized in invalid or incomprehensible timezone `FOO` in events/event_dt_local_missing_tz.ics. This could lead to this event being wrongly displayed. All users (of python 3.6) are advised to upgrade as soon as possible. Get `khal v0.9.1`__ from this site, or from pypi_. __ https://lostpackets.de/khal/downloads/khal-0.9.1.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal092.rst000066400000000000000000000021561477603436700174660ustar00rootroot00000000000000khal v0.9.2 released ==================== .. feed-entry:: :date: 2017-02-13 This is an **important bug fix release**, that fixes a bunch of different bugs, but most importantly: * if weekstart != 0 ikhal would show wrong weekday names * allday events added with `khal new DATE TIMEDELTA` (e.g., 2017-01-18 3d) were lasting one day too long Special thanks to Tom Rushworth for finding and reporting both bugs! All other fixed bugs would be rather obvious if you happened to run into them, as they would lead to khal crashing in one way or another. One new feature made its way into this release as well, which is good news for all users pining for the way ikhal's right column behaved in pre 0.9.0 days: setting new configuration option [view]dynamic_days=False, will make that column behave similar as it used to. .. Warning:: All users of khal 0.9.x are advised to **upgrade as soon as possible**. Users of khal 0.8.x are not affected by either bug. Get `khal v0.9.2`__ from this site, or from pypi_. __ https://lostpackets.de/khal/downloads/khal-0.9.2.tar.gz .. _pypi: https://pypi.python.org/pypi/khal/ khal-0.11.4/doc/source/news/khal093.rst000066400000000000000000000013211477603436700174600ustar00rootroot00000000000000khal v0.9.3 released ==================== .. feed-entry:: :date: 2017-03-06 Sadly, the biggest release in khal's history, also brought the most bugs. The latest release, khal version 0.9.3, fixes some more of them. The good news: while most of these bugs lead to khal crashing, no harm was done, that is all (calendar) related data shown was correct. Again, some new features sneaked in, for those and for the complete list of fixed bugs, have a look at the changelog_. Get `khal v0.9.3`__ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ __ https://lostpackets.de/khal/downloads/khal-0.9.3.tar.gz khal-0.11.4/doc/source/news/khal094.rst000066400000000000000000000015401477603436700174640ustar00rootroot00000000000000khal v0.9.4 released ==================== .. feed-entry:: :date: 2017-03-30 Another minor release, this time bringing some features, mostly for ikhal: among others are an improved light color scheme, an improved editor for recurrence rules and the ability to detect updates the underlying vdirs and refreshing the user interface. Furthermore, the configuration wizard helping new users generating a configuration file got streamlined, making it's use much easier. Special thanks to first time contributors August Lindberg and Thomas Kluyver. For a more complete list of changes, have a look at the changelog_. Get `khal v0.9.4`__ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ __ https://lostpackets.de/khal/downloads/khal-0.9.4.tar.gz khal-0.11.4/doc/source/news/khal095.rst000066400000000000000000000007041477603436700174660ustar00rootroot00000000000000khal v0.9.5 released ==================== .. feed-entry:: :date: 2017-04-10 Another minor release, some non-serious bugs this time. For a more complete list of changes, have a look at the changelog_. Get `khal v0.9.5`__ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ __ https://lostpackets.de/khal/downloads/khal-0.9.5.tar.gz khal-0.11.4/doc/source/news/khal096.rst000066400000000000000000000007221477603436700174670ustar00rootroot00000000000000khal v0.9.6 released ==================== .. feed-entry:: :date: 2017-06-13 Another minor release, some non-serious bugs and some minor features. For a more complete list of changes, have a look at the changelog_. Get `khal v0.9.6`__ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ __ https://lostpackets.de/khal/downloads/khal-0.9.6.tar.gz khal-0.11.4/doc/source/news/khal097.rst000066400000000000000000000012501477603436700174650ustar00rootroot00000000000000khal v0.9.7 released ==================== .. feed-entry:: :date: 2017-09-15 `khal v0.9.7`_ comes with two fixes (no more crashing on datetime events with UNTIL properties and no more crashes on search finding events with overwritten subevents) and one change: `search` will now print subevents of matching events once, i.e., that is one line for the master event, and one line for every different overwritten event. Get `khal v0.9.7`_ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _changelog: changelog.html#id2 .. _documentation: https://lostpackets.de/khal/ .. _khal v0.9.7: https://lostpackets.de/khal/downloads/khal-0.9.7.tar.gz khal-0.11.4/doc/source/news/khal098.rst000066400000000000000000000012641477603436700174730ustar00rootroot00000000000000khal v0.9.8 released with an IMPORTANT BUGFIX ============================================= .. feed-entry:: :date: 2017-10-05 `khal v0.9.8`_ comes with an **IMPORTANT BUGFIX**: If editing an event in ikhal and not editing the end time but moving the cursor through the end time field, the end time could be moved to the start time + 1 hour (the end *date* was not affected). .. Warning:: All users of khal are advised to **upgrade as soon as possible!** Users of khal v0.9.3 and earlier are not affected. Get `khal v0.9.8`_ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _khal v0.9.8: https://lostpackets.de/khal/downloads/khal-0.9.8.tar.gz khal-0.11.4/doc/source/standards.rst000066400000000000000000000103121477603436700173140ustar00rootroot00000000000000Standards ========= *khal* tries to follow standards and RFCs (most importantly :rfc:`5545` *iCalendar*) wherever possible. Known intentional and unintentional deviations are listed below. RDATE;VALUE=PERIOD ------------------ `RDATE` s with `PERIOD` values are currently not supported, as icalendar_ does not support it yet. Please submit any real world examples of events with `RDATE;VALUE=PERIOD` you might encounter (khal will print warnings if you have any in your calendars). RANGE=THISANDPRIOR ------------------ Recurrent events with the `RANGE=THISANDPRIOR` are and will not be [1]_ supported by khal, as applications supporting the latest standard_ MUST NOT create those. khal will print a warning if it encounters an event containing `RANGE=THISANDPRIOR`. .. [1] unless a lot of users request this feature .. _standard: http://tools.ietf.org/html/rfc5546 Events with neither END nor DURATION ------------------------------------ While the RFC states:: A calendar entry with a "DTSTART" property but no "DTEND" property does not take up any time. It is intended to represent an event that is associated with a given calendar date and time of day, such as an anniversary. Since the event does not take up any time, it MUST NOT be used to record busy time no matter what the value for the "TRANSP" property. khal transforms those events into all-day events lasting for one day (the start date). As long a those events do not get edited, these changes will not be written to the vdir (and with that to the CalDAV server). Any timezone information that was associated with the start date gets discarded. .. note:: While the main rationale for this behaviour was laziness on part of khal's main author, other calendar software shows the same behaviour (e.g. Google Calendar and Evolution). Timezones --------- Getting localized time right, seems to be the most difficult part about calendaring (and messing it up ends in missing the one important meeting of the week). So I'll briefly describe here, how khal tries to handle timezone information, which information it can handle and which it can't. In general, there are two different type of events. *Localized events* (with *localized* start and end datetimes) which have timezone information attached to their start and end datetimes, and *floating* events (with *floating* start and end datetimes), which have no timezone information attached (all-day events, events that last for complete days are floating as well). Localized events are always observed at the same UTC_ (no matter what time zone the observer is in), but different local times. On the other hand, floating events are always observed at the same local time, which might be different in UTC. In khal all localized datetimes are saved to the local database as UTC. Datetimes that are already UTC, e.g. ``19980119T070000Z``, are saved as such, others are converted to UTC (but don't worry, the timezone information does not get lost). Floating events get saved in floating time, independently of the localized events. If you want to look up which events take place at a specified datetime, khal always expects that you want to know what events take place at that *local* datetime. Therefore, the (local) datetime you asked for gets converted to UTC, the appropriate *localized* events get selected and presented with their start and end datetimes *converted* to *your local datetime*. For floating events no conversion is necessary. Khal (i.e. icalendar_) can understand all timezone identifiers as used in the `Olson DB`_ and custom timezone definitions, if those VTIMEZONE components are placed before the VEVENTS that make use of them (as most calendar programs seem to do). In case an unknown (or unsupported) timezone is found, khal will assume you want that event to be placed in the *default timezone* (which can be configured in the configuration file as well). khal expects you *always* want *all* start and end datetimes displayed in *local time* (which can be set in the configuration file as well, otherwise your computer's timezone is used). .. _Olson DB: https://en.wikipedia.org/wiki/Tz_database .. _UTC: https://en.wikipedia.org/wiki/Coordinated_Universal_Time .. _icalendar: https://github.com/collective/icalendar khal-0.11.4/doc/source/usage.rst000066400000000000000000000472221477603436700164470ustar00rootroot00000000000000Usage ===== Khal offers a set of commands, most importantly :command:`list`, :command:`calendar`, :command:`interactive`, :command:`new`, :command:`printcalendars`, :command:`printformats`, and :command:`search`. See below for a description of what every command does. :program:`khal` does currently not support any default command, i.e., run a command, even though none has been specified. This is intentional. Options ------- :program:`khal` (without any commands) has some options to print some information about :program:`khal`: .. option:: --version Prints khal's version number and exits .. option:: -h, --help Prints a summary of khal's options and commands and then exits Several options are common to almost all of :program:`khal`'s commands (exceptions are described below): .. option:: -v, --verbosity LVL Configure verbosity (e.g. print debugging information), `LVL` needs to be one of CRITICAL, ERROR, WARNING, INFO, or DEBUG. .. option:: -l, --logfile LOFILE Use logfile `LOGFILE` for logging, default is logging to stdout. .. option:: -c CONFIGFILE Use an alternate configuration file. .. option:: -a CALENDAR Specify a calendar to use (which must be configured in the configuration file), can be used several times. Calendars not specified will be disregarded for this run. .. option:: -d CALENDAR Specify a calendar which will be disregarded for this run, can be used several times. .. option:: --color/--no-color :program:`khal` will detect if standard output is not a tty, e.g., you redirect khal's output into a file, and if so remove all highlighting/coloring from its output. Use :option:`--color` if you want to force highlighting/coloring and :option:`--no-color <--color>` if you want coloring always removed. .. option:: --format FORMAT For all of khal's commands that print events, the formatting of that event can be specified with this option. ``FORMAT`` is a template string, in which identifiers delimited by curly braces (`{}`) will be expanded to an event's properties. ``FORMAT`` supports all formatting options offered by python's `str.format()`_ (as it is used internally). The available template options are: title The title of the event. description The description of the event. description-separator A separator: " :: " that appears when there is a description. uid The UID of the event. start The start datetime in datetimeformat. start-long The start datetime in longdatetimeformat. start-date The start date in dateformat. start-date-long The start date in longdateformat. start-time The start time in timeformat. end The end datetime in datetimeformat. end-long The end datetime in longdatetimeformat. end-date The end date in dateformat. end-date-long The end date in longdateformat. end-time The end time in timeformat. repeat-symbol A repeating symbol (loop arrow) if the event is repeating. alarm-symbol An alarm symbol (alarm clock) if the event has at least one alarm. location The event location. calendar The calendar name. calendar-color Changes the output color to the calendar's color. start-style The start time in timeformat OR an appropriate symbol. to-style A hyphen "-" or nothing such that it appropriately fits between start-style and end-style. end-style The end time in timeformat OR an appropriate symbol. start-end-time-style A concatenation of start-style, to-style, and end-style OR an appropriate symbol. end-necessary For an allday event this is an empty string unless the end date and start date are different. For a non-allday event this will show the time or the datetime if the event start and end date are different. end-necessary-long Same as end-necessary but uses datelong and datetimelong. status The status of the event (if this event has one), something like `CONFIRMED` or `CANCELLED`. status-symbol The status of the event as a symbol, `✓` or `✗` or `?`. partstat-symbol The participation status of the event as a symbol, `✓` or `✗` or `?`. cancelled The string `CANCELLED` (plus one blank) if the event's status is cancelled, otherwise nothing. organizer The organizer of the event. If the format has CN then it returns "CN (email)" if CN does not exist it returns just the email string. Example: ORGANIZER;CN=Name Surname:mailto:name@mail.com returns Name Surname (name@mail.com) and if it has no CN attribute it returns the last element after the colon: ORGANIZER;SENT-BY="mailto:toemail@mail.com":mailto:name@mail.com returns name@mail.com url The URL embedded in the event, otherwise nothing. url-separator A separator: " :: " that appears when there is a url. duration The duration of the event in terms of days, hours, months, and seconds (abbreviated to `d`, `h`, `m`, and `s` respectively). repeat-pattern The raw iCal recurrence rule if the event is repeating. all-day A boolean indicating whether it is an all-day event or not. categories The categories of the event. By default, all-day events have no times. To see a start and end time anyway simply add `-full` to the end of any template with start/end or duration, for instance `start-time` becomes `start-time-full` and will always show start and end times (instead of being empty for all-day events). In addition, there are colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` (and their bold versions: `red-bold`, etc.). There is also `reset`, which clears the styling, and `bold`, which is the normal bold. A few control codes are exposed. You can access newline (`nl`), 'tab', and 'bell'. Control codes, such as `nl`, are best used with `--list` mode. Below is an example command which prints the title and description of all events today. :: khal list --format "{title} {description}" .. option:: --json FIELD ... Works similar to :option:`--format`, but instead of defining a format string a JSON object is created for each specified field. The matching events are collected into a JSON array. This option accepts the following subset of :option:`--format` template options:: title, description, uid, start, start-long, start-date, start-date-long, start-time, end, end-long, end-date, end-date-long, end-time, start-full, start-long-full, start-date-full, start-date-long-full, start-time-full, end-full, end-long-full, end-date-full, end-date-long-full, end-time-full, repeat-symbol, location, calendar, calendar-color, start-style, to-style, end-style, start-end-time-style, end-necessary, end-necessary-long, status, cancelled, organizer, url, duration, duration-full, repeat-pattern, all-day, categories Note that `calendar-color` will be the actual color name rather than the ANSI color code, and the `repeat-symbol`, `status`, and `cancelled` values will have leading/trailing whitespace stripped. Additionally, if only the special value `all` is specified then all fields will be enabled. Below is an example command which prints a JSON list of objects containing the title and description of all events today. .. code-block:: console khal list --json title --json description .. option:: --day-format DAYFORMAT works similar to :option:`--format`, but for day headings. It only has a few options (in addition to all the color options): date The date in dateformat. date-long The date in longdateformat. name The date's name (`Monday`, `Tuesday`,…) or `today` or `tomorrow`. If the `--day-format` is passed an empty string then it will not print the day headers (for an empty line pass in a whitespace character). dates ----- Almost everywhere khal accepts dates, khal should recognize relative date names like *today*, *tomorrow* and the names of the days of the week (also in three letters abbreviated form). Week day names get interpreted as the date of the next occurrence of a day with that name. The name of the current day gets interpreted as that date *next* week (i.e. seven days from now). If a short datetime format is used (no year is given), khal will interpret the date to be in the future. The inferred it might be in the next year if the given date has already passed in the current year. Commands -------- list **** shows all events scheduled for a given date (or datetime) range, with custom formatting:: khal list [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT] [--json FIELD ...] [--day-format DAYFORMAT] [--once] [--notstarted] [START [END | DELTA] ] START and END can both be given as dates, datetimes or times (it is assumed today is meant in the case of only a given time) in the formats configured in the configuration file. If END is not given, midnight of the start date is assumed. Today is used for START if it is not explicitly given. If DELTA, a (date)time range in the format `I{m,h,d}`, where `I` is an integer and `m` means minutes, `h` means hours, and `d` means days, is given, END is assumed to be START + DELTA. A value of `eod` is also accepted as DELTA and means the end of day of the start date. In addition, the DELTA `week` may be used to specify that the daterange should actually be the week containing the START. The `--once` option only allows events to appear once even if they are on multiple days. With the `--notstarted` option only events are shown that start after `START`. **Some examples** Including or excluding specific calendars: * `khal list -d soccer` will display events, in list form, from every calendar except "soccer." * `khal list -a soccer` will display events, in list form, from only the "soccer" calendar. Working with date ranges: * `khal list -a soccer today 30d` will show all events in next 30 days (from the "soccer" calendar). * `khal list 2019-12-01 31d` will show all all events for the 31 days following Dec 1, 2019. at ** shows all events scheduled for a given datetime. ``khal at`` should be supplied with a date and time, a time (the date is then assumed to be today) or the string *now*. ``at`` defaults to *now*. The ``at`` command works just like the ``list`` command, except it has an implicit end time of zero minutes after the start. :: khal at [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT] [--json FIELD ...] [--notstarted] [[START DATE] TIME | now] calendar ******** shows a calendar (similar to :manpage:`cal(1)`) and list. ``khal calendar`` should understand the following syntax: :: khal calendar [-a CALENDAR ... | -d CALENDAR ...] [START DATETIME] [END DATETIME] Date selection works exactly as for ``khal list``. The displayed calendar contains three consecutive months, where the first month is the month containing the first given date. If today is included, it is highlighted. Have a look at ``khal list`` for a description of the options. configure ********* will help users creating an initial configuration file. :command:`configure` will refuse to run if there already is a configuration file. import ****** lets the user import ``.ics`` files with the following syntax: :: khal import [-a CALENDAR] [--batch] [--random-uid|-r] ICSFILE If an event with the same UID is already present in the (implicitly) selected calendar ``khal import`` will ask before updating (i.e. overwriting) that old event with the imported one, unless --batch is given, than it will always update. If this behaviour is not desired, use the `--random-uid` flag to generate a new, random UID. If no calendar is specified (and not `--batch`), you will be asked to choose a calendar. You can either enter the number printed behind each calendar's name or any unique prefix of a calendar's name. interactive *********** invokes the interactive version of khal, can also be invoked by calling :command:`ikhal`. While ikhal can be used entirely with the keyboard, some elements respond if clicked on with a mouse (mostly by being selected). When the calendar on the left is in focus, you can * move through the calendar (default keybindings are the arrow keys, :kbd:`space` and :kbd:`backspace`, those keybindings are configurable in the config file) * focus on the right column by pressing :kbd:`tab` or :kbd:`enter` * re-focus on the current date, default keybinding :kbd:`t` as in today * marking a date range, default keybinding :kbd:`v`, as in visual, think visual mode in Vim, pressing :kbd:`esc` escapes this visual mode * if in visual mode, you can select the other end of the currently marked range, default keybinding :kbd:`o` as in other (again as in Vim) * create a new event on the currently focused day (or date range if a range is selected), default keybinding :kbd:`n` as in new * search for events, default keybinding :kbd:`/`, a pop-up will ask for your search term When an event list is in focus, you can * view an event's details with pressing :kbd:`enter` (or :kbd:`tab`) and edit it with pressing :kbd:`enter` (or :kbd:`tab`) again (if ``[default] event_view_always_visible`` is set to True, the event in focus will always be shown in detail) * toggle an event's deletion status, default keybinding :kbd:`d` as in delete, events marked for deletion will appear with a :kbd:`D` in front and will be deleted when khal exits. * duplicate the selected event, default keybinding :kbd:`p` as in duplicate (d was already taken) * export the selected event, default keybinding :kbd:`e` In the event editor, you can * jump to the next (previous) selectable element with pressing :kbd:`tab` (:kbd:`shift+tab`) * quick save, default keybinding :kbd:`meta+enter` (:kbd:`meta` will probably be :kbd:`alt`) * use some common editing short cuts in most text fields (:kbd:`ctrl+w` deletes word before cursor, :kbd:`ctrl+u` (:kbd:`ctrl+k`) deletes till the beginning (end) of the line, :kbd:`ctrl+a` (:kbd:`ctrl+e`) will jump to the beginning (end) of the line * in the date and time fields you can increment and decrement the number under the cursor with :kbd:`ctrl+a` and :kbd:`ctrl+x` (time in 15 minute steps) * in the date fields you can access a miniature calendar by pressing `enter` * activate actions by pressing :kbd:`enter` on text enclosed by angled brackets, e.g. :guilabel:`< Save >` (sometimes this might open a pop up) Pressing :kbd:`esc` will cancel the current action and/or take you back to the previously shown pane (i.e. what you see when you open ikhal), if you are at the start pane, ikhal will quit on pressing :kbd:`esc` again. new *** allows for adding new events. ``khal new`` should understand the following syntax: :: khal new [-a CALENDAR] [OPTIONS] [START [END | DELTA] [TIMEZONE] SUMMARY [:: DESCRIPTION]] where start- and enddatetime are either datetimes, times, or keywords and times in the formats defined in the config file. If no calendar is given via :option:`-a`, the default calendar is used. :command:`new` does not support :option:`-d` and also :option:`-a` may only be used once. :command:`new` accepts these combinations for start and endtimes (specifying the end is always optional): * `datetime [datetime|time] [timezone]` * `time [time] [timezone]` * `date [date]` where the formats for datetime and time are as follows: * `datetime = (longdatetimeformat|datetimeformat|keyword-date timeformat)` * `time = timeformat` * `date = (longdateformat|dateformat)` and `timezone`, which describes the timezone the events start and end time are in, should be a valid Olson DB identifier (like `Europe/Berlin` or `America/New_York`. If no timezone is given, the *defaulttimezone* as configured in the configuration file is used instead. The exact format of longdatetimeformat, datetimeformat, timeformat, longdateformat and dateformat can be configured in the configuration file. Valid keywords for dates are *today*, *tomorrow*, the English name of all seven weekdays and their three letter abbreviations (their next occurrence is used). If no end is given, the default length of one hour or one day (for all-day events) is used. If only a start time is given the new event is assumed to be starting today. If only a time is given for the event to end on, the event ends on the same day it starts on, unless that would make the event end before it has started, then the next day is used as end date If a 24:00 time is configured (timeformat = %H:%M) an end time of `24:00` is accepted as the end of a given date. If the **summary** contains the string `::`, everything after `::` is taken as the **description** of the new event, i.e., the "body" of the event (and `::` will be removed). Passing the option :option:`--interactive` (:option:`-i`) makes all arguments optional and interactively prompts for required fields, then the event may be edited, the same way as in the `edit` command. Options """"""" * **-l, --location=LOCATION** specify where this event will be held. * **-g, --categories=CATEGORIES** specify which categories this event belongs to. Comma separated list of categories. Beware: some servers (e.g. SOGo) do not support multiple categories. * **-r, --repeat=RRULE** specify if and how this event should be recurring. Valid values for *RRULE* are `daily`, `weekly`, `monthly` and `yearly` * **-u, --until=UNTIL** specify until when a recurring event should run * **--url** specify the URL element of the event * **--alarms DURATION,...** will add alarm times as DELTAs comma separated for this event, *DURATION* should look like `1day 10minutes` or `1d3H10m`, negative *DURATIONs* will set alarm after the start of the event. Examples """""""" These may need to be adapted for your configuration and/or locale (START and END need to match the format configured). See :command:`printformats`. :: khal new 18:00 Awesome Event adds a new event starting today at 18:00 with summary 'awesome event' (lasting for the default time of one hour) to the default calendar :: khal new tomorrow 16:30 Coffee Break adds a new event tomorrow at 16:30 :: khal new 25.10. 18:00 24:00 Another Event :: with Alice and Bob adds a new event on 25th of October lasting from 18:00 to 24:00 with an additional description :: khal new -a work 26.07. Great Event -g meeting -r weekly adds a new all day event on 26th of July to the calendar *work* in the *meeting* category, which recurs every week. edit **** an interactive command for editing and deleting events using a search string :: khal edit [--show-past] event_search_string the command will loop through all events that match the search string, prompting the user to delete, or change attributes. printcalendars ************** prints a list of all configured calendars. printformats ************ prints a fixed date (*2013-12-21 21:45*) in all configured date(time) formats. This is supposed to help check if those formats are configured as intended. search ****** search for events matching a search string and print them. Currently, search will print one line for every different event in a recurrence set, that is one line for the master event, and one line for every different overwritten event. No advanced search features are currently supported. The command :: khal search party prints all events matching `party`. .. _str.format(): https://docs.python.org/3/library/string.html#formatstrings khal-0.11.4/doc/source/ystatic/000077500000000000000000000000001477603436700162625ustar00rootroot00000000000000khal-0.11.4/doc/source/ystatic/.gitignore000066400000000000000000000000001477603436700202400ustar00rootroot00000000000000khal-0.11.4/doc/source/ytemplates/000077500000000000000000000000001477603436700167715ustar00rootroot00000000000000khal-0.11.4/doc/source/ytemplates/layout.html000066400000000000000000000003041477603436700211710ustar00rootroot00000000000000{% extends "!layout.html" %} {% block linktags %} {{ super() }} {% endblock %} khal-0.11.4/doc/webpage/000077500000000000000000000000001477603436700147145ustar00rootroot00000000000000khal-0.11.4/doc/webpage/src/000077500000000000000000000000001477603436700155035ustar00rootroot00000000000000khal-0.11.4/doc/webpage/src/new_rss_url.rst000066400000000000000000000003051477603436700205750ustar00rootroot00000000000000New RSS URL =========== :date: 20.08.2014 :category: News The newsfeed will now be available under a `new url`__, for now there will be only an RSS feed. __ https://lostpackets.de/khal/index.rss khal-0.11.4/khal.conf.sample000066400000000000000000000014331477603436700156040ustar00rootroot00000000000000#/etc/khal/khal.conf.sample [calendars] [[home]] path = ~/.khal/calendars/home/ color = dark blue [[work]] path = ~/.khal/calendars/work/ readonly = True [sqlite] path = ~/.khal/khal.db [locale] local_timezone = Europe/Berlin default_timezone = America/New_York # If you use certain characters (e.g. commas) in these formats you may need to # enclose them in "" to ensure that they are loaded as strings. timeformat = %H:%M dateformat = %d.%m. longdateformat = %d.%m.%Y datetimeformat = %d.%m. %H:%M longdatetimeformat = %d.%m.%Y %H:%M firstweekday = 0 monthdisplay = firstday [default] default_calendar = home timedelta = 2d # the default timedelta that list uses highlight_event_days = True # the default is False enable_mouse = True # mouse is enabled by default in interactive mode khal-0.11.4/khal/000077500000000000000000000000001477603436700134545ustar00rootroot00000000000000khal-0.11.4/khal/__init__.py000066400000000000000000000033131477603436700155650ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. try: from khal.version import version except ImportError: version = 'invalid' import sys sys.exit('Failed to find (autogenerated) version.py. This might be due to ' 'using GitHub\'s tarballs or svn access. Either clone ' 'from GitHub via git or get a tarball from PyPI.') __productname__ = 'khal' __version__ = version __author__ = 'Christian Geier' __copyright__ = 'Copyright (c) 2013-2022 khal contributors' __author_email__ = 'khal@lostpackets.de' __description__ = 'A standards based terminal calendar' __license__ = 'Expat/MIT, see COPYING' __homepage__ = 'https://lostpackets.de/khal/' khal-0.11.4/khal/__main__.py000066400000000000000000000022331477603436700155460ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from khal.cli import main_khal if __name__ == '__main__': main_khal() khal-0.11.4/khal/_compat.py000066400000000000000000000003231477603436700154460ustar00rootroot00000000000000__all__ = ["importlib_metadata"] import sys if sys.version_info >= (3, 10): # pragma: no cover from importlib import metadata as importlib_metadata else: # pragma: no cover import importlib_metadata khal-0.11.4/khal/calendar_display.py000066400000000000000000000216361477603436700173340ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import calendar import datetime as dt from locale import LC_ALL, LC_TIME, getlocale, setlocale from typing import List, Optional, Union from click import style from .khalendar import CalendarCollection from .terminal import colored from .utils import get_month_abbr_len setlocale(LC_ALL, '') def get_weekheader(firstweekday: int) -> str: try: mylocale = '.'.join(getlocale(LC_TIME)) # type: ignore except TypeError: mylocale = 'C' _calendar = calendar.LocaleTextCalendar(firstweekday, locale=mylocale) # type: ignore return _calendar.formatweekheader(2) def getweeknumber(date: dt.date) -> int: """return iso week number for datetime.date object :param date: date :return: weeknumber """ return dt.date.isocalendar(date)[1] def get_calendar_color(calendar: str, default_color: str, collection: CalendarCollection) -> str: """Because multi-line lambdas would be un-Pythonic """ if collection._calendars[calendar]['color'] == '': return default_color return collection._calendars[calendar]['color'] def get_color_list( calendars: List[str], default_color: str, collection: CalendarCollection ) -> List[str]: """Get the list of possible colors for the day, taking into account priority""" dcolors = [ ( get_calendar_color(x, default_color, collection), collection._calendars[x]["priority"], ) for x in calendars ] dcolors.sort(key=lambda x: x[1], reverse=True) maxPriority = dcolors[0][1] return list({x[0] for x in filter(lambda x: x[1] == maxPriority, dcolors)}) def str_highlight_day( day: dt.date, calendars: List[str], hmethod: Optional[str], default_color: str, multiple: str, multiple_on_overflow: bool, color: str, bold_for_light_color: bool, collection: CalendarCollection, ) -> str: """returns a string with day highlighted according to configuration """ dstr = str(day.day).rjust(2) if color == '': dcolors = get_color_list(calendars, default_color, collection) if len(dcolors) > 1: if multiple == '' or (multiple_on_overflow and len(dcolors) == 2): if hmethod == "foreground" or hmethod == "fg": return colored(dstr[:1], fg=dcolors[0], bold_for_light_color=bold_for_light_color) + \ colored(dstr[1:], fg=dcolors[1], bold_for_light_color=bold_for_light_color) else: return colored(dstr[:1], bg=dcolors[0], bold_for_light_color=bold_for_light_color) + \ colored(dstr[1:], bg=dcolors[1], bold_for_light_color=bold_for_light_color) else: dcolor = multiple else: dcolor = dcolors[0] or default_color else: dcolor = color if dcolor != '': if hmethod == "foreground" or hmethod == "fg": return colored(dstr, fg=dcolor, bold_for_light_color=bold_for_light_color) else: return colored(dstr, bg=dcolor, bold_for_light_color=bold_for_light_color) return dstr def str_week( week: List[dt.date], today: dt.date, collection: Optional[CalendarCollection]=None, hmethod: Optional[str]=None, default_color: str='', multiple: str='', multiple_on_overflow: bool=False, color: str='', highlight_event_days: bool=False, locale=None, bold_for_light_color: bool=True, ) -> str: """returns a string representing one week, if for day == today color is reversed :param week: list of 7 datetime.date objects (one week) :param today: the date of today :return: string, which if printed on terminal appears to have length 20, but may contain ascii escape sequences """ strweek = '' if highlight_event_days and collection is None: raise ValueError( 'if `highlight_event_days` is True, `collection` must be a CalendarCollection' ) for day in week: if day == today: day_str = style(str(day.day).rjust(2), reverse=True) elif highlight_event_days: assert collection is not None devents = list(collection.get_calendars_on(day)) if len(devents) > 0: day_str = str_highlight_day( day, devents, hmethod, default_color, multiple, multiple_on_overflow, color, bold_for_light_color, collection, ) else: day_str = str(day.day).rjust(2) else: day_str = str(day.day).rjust(2) strweek = strweek + day_str + ' ' return strweek def vertical_month(month: Optional[int]=None, year: Optional[int]=None, today: Optional[dt.date]=None, weeknumber: Union[bool, str]=False, count: int=3, firstweekday: int=0, monthdisplay: str='firstday', collection=None, hmethod: str='fg', default_color: str='', multiple: str='', multiple_on_overflow: bool=False, color: str='', highlight_event_days: bool=False, locale=None, bold_for_light_color: bool=True, ) -> List[str]: """ returns a list() of str() of weeks for a vertical arranged calendar :param month: first month of the calendar, if non given, current month is assumed :param year: year of the first month included, if non given, current year is assumed :param today: day highlighted, if non is given, current date is assumed :param weeknumber: if not False the iso weeknumber will be shown for each week, if weeknumber is 'right' it will be shown in its own column, if it is 'left' it will be shown interleaved with the month names :returns: calendar strings, may also include some ANSI (color) escape strings """ if month is None: month = dt.date.today().month if year is None: year = dt.date.today().year if today is None: today = dt.date.today() khal = [] w_number = ' ' if weeknumber == 'right' else '' calendar.setfirstweekday(firstweekday) weekheaders = get_weekheader(firstweekday) month_abbr_len = get_month_abbr_len() khal.append(style(' ' * month_abbr_len + weekheaders + ' ' + w_number, bold=True)) _calendar = calendar.Calendar(firstweekday) for _ in range(count): for week in _calendar.monthdatescalendar(year, month): if monthdisplay == 'firstday': new_month = len([day for day in week if day.day == 1]) else: new_month = len(week if week[0].day <= 7 else []) strweek = str_week(week, today, collection, hmethod, default_color, multiple, multiple_on_overflow, color, highlight_event_days, locale, bold_for_light_color) if new_month: m_name = style(calendar.month_abbr[week[6].month].ljust(month_abbr_len), bold=True) elif weeknumber == 'left': m_name = style(str(getweeknumber(week[0])).center(month_abbr_len), bold=True) else: m_name = ' ' * month_abbr_len if weeknumber == 'right': w_number = style(f'{getweeknumber(week[0]):2}', bold=True) else: w_number = '' sweek = m_name + strweek + w_number if sweek != khal[-1]: khal.append(sweek) month = month + 1 if month > 12: month = 1 year = year + 1 return khal khal-0.11.4/khal/cli.py000066400000000000000000000511061477603436700146000ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import datetime as dt import logging import os import stat import sys import textwrap from shutil import get_terminal_size import click import click_log from . import controllers, plugins from .cli_utils import ( _select_one_calendar_callback, build_collection, calendar_option, global_options, logger, mouse_option, multi_calendar_option, multi_calendar_select, prepare_context, ) from .exceptions import FatalError from .plugins import COMMANDS from .terminal import colored from .utils import human_formatter, json_formatter try: from setproctitle import setproctitle except ImportError: def setproctitle(_): pass click_log.basic_config('khal') days_option = click.option('--days', default=None, type=int, help='How many days to include.') week_option = click.option('--week', '-w', help='Include all events in one week.', is_flag=True) events_option = click.option('--events', default=None, type=int, help='How many events to include.') dates_arg = click.argument('dates', nargs=-1) def time_args(f): return dates_arg(events_option(week_option(days_option(f)))) def stringify_conf(conf): # since we have only two levels of recursion, a recursive function isn't # really worth it out = [] for key, value in conf.items(): out.append(f'[{key}]') for subkey, subvalue in value.items(): if isinstance(subvalue, dict): out.append(f' [[{subkey}]]') for subsubkey, subsubvalue in subvalue.items(): out.append(f' {subsubkey}: {subsubvalue}') else: out.append(f' {subkey}: {subvalue}') return '\n'.join(out) class _KhalGroup(click.Group): def list_commands(self, ctx): return super().list_commands(ctx) + list(COMMANDS.keys()) def get_command(self, ctx, name): if name in COMMANDS: logger.debug(f'found command {name} as a plugin') return COMMANDS[name] return super().get_command(ctx, name) @click.group(cls=_KhalGroup) @click_log.simple_verbosity_option('khal') @global_options @click.pass_context def cli(ctx, config): # setting the process title so it looks nicer in ps # shows up as 'khal' under linux and as 'python: khal (python2.7)' # under FreeBSD, which is still nicer than the default setproctitle('khal') if ctx.logfilepath: logger = logging.getLogger('khal') logger.handlers = [logging.FileHandler(ctx.logfilepath)] prepare_context(ctx, config) @cli.command() @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.option('--day-format', '-df', help=('The format of the day line.')) @click.option( '--once', '-o', help=('Print each event only once (even if it is repeated or spans multiple days).'), is_flag=True) @click.option('--notstarted', help=('Print only events that have not started.'), is_flag=True) @click.argument('DATERANGE', nargs=-1, required=False) @click.pass_context def calendar(ctx, include_calendar, exclude_calendar, daterange, once, notstarted, format, day_format): '''Print calendar with agenda.''' try: rows = controllers.calendar( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), agenda_format=format, day_format=day_format, once=once, notstarted=notstarted, daterange=daterange, conf=ctx.obj['conf'], firstweekday=ctx.obj['conf']['locale']['firstweekday'], locale=ctx.obj['conf']['locale'], weeknumber=ctx.obj['conf']['locale']['weeknumbers'], monthdisplay=ctx.obj['conf']['view']['monthdisplay'], hmethod=ctx.obj['conf']['highlight_days']['method'], default_color=ctx.obj['conf']['highlight_days']['default_color'], multiple=ctx.obj['conf']['highlight_days']['multiple'], multiple_on_overflow=ctx.obj['conf']['highlight_days']['multiple_on_overflow'], color=ctx.obj['conf']['highlight_days']['color'], highlight_event_days=ctx.obj['conf']['default']['highlight_event_days'], bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color'], env={"calendars": ctx.obj['conf']['calendars']} ) click.echo('\n'.join(rows)) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command("list") @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.option('--day-format', '-df', help=('The format of the day line.')) @click.option('--once', '-o', is_flag=True, help=('Print each event only once ' '(even if it is repeated or spans multiple days).') ) @click.option('--notstarted', help=('Print only events that have not started.'), is_flag=True) @click.option('--json', help=("Fields to output in json"), multiple=True) @click.argument('DATERANGE', nargs=-1, required=False, metavar='[DATETIME [DATETIME | RANGE]]') @click.pass_context def klist(ctx, include_calendar, exclude_calendar, daterange, once, notstarted, json, format, day_format): """List all events between a start (default: today) and (optional) end datetime.""" enabled_eventformatters = plugins.FORMATTERS # TODO: register user given format string as a plugin logger.debug(f'{enabled_eventformatters}') try: event_column = controllers.khal_list( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), agenda_format=format, day_format=day_format, daterange=daterange, once=once, notstarted=notstarted, conf=ctx.obj['conf'], env={"calendars": ctx.obj['conf']['calendars']}, json=json ) if event_column: click.echo('\n'.join(event_column)) else: logger.debug('No events found') except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @calendar_option @click.option('--interactive', '-i', help=('Add event interactively'), is_flag=True) @click.option('--location', '-l', help=('The location of the new event.')) @click.option('--categories', '-g', help=('The categories of the new event, comma separated.')) @click.option('--repeat', '-r', help=('Repeat event: daily, weekly, monthly or yearly.')) @click.option('--until', '-u', help=('Stop an event repeating on this date.')) @click.option('--format', '-f', help=('The format to print the event.')) @click.option('--json', help=("Fields to output in json"), multiple=True) @click.option('--alarms', '-m', help=('Alarm times for the new event as DELTAs comma separated')) @click.option('--url', help=("URI for the event.")) @click.argument('info', metavar='[START [END | DELTA] [TIMEZONE] [SUMMARY] [:: DESCRIPTION]]', nargs=-1) @click.pass_context def new(ctx, calendar, info, location, categories, repeat, until, alarms, url, format, json, interactive): '''Create a new event from arguments. START and END can be either dates, times or datetimes, please have a look at the man page for details. Everything that cannot be interpreted as a (date)time or a timezone is assumed to be the event's summary, if two colons (::) are present, everything behind them is taken as the event's description. ''' if not info and not interactive: raise click.BadParameter( 'no details provided, did you mean to use --interactive/-i?' ) calendar = calendar or ctx.obj['conf']['default']['default_calendar'] if calendar is None: if interactive: while calendar is None: calendar = click.prompt('calendar') if calendar == '?': for calendar in ctx.obj['conf']['calendars']: click.echo(calendar) calendar = None elif calendar not in ctx.obj['conf']['calendars']: click.echo('unknown calendar enter ? for list') calendar = None else: raise click.BadParameter( 'No default calendar is configured, ' 'please provide one explicitly.' ) try: new_func = controllers.new_from_string if interactive: new_func = controllers.new_interactive new_func( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), calendar, ctx.obj['conf'], info=' '.join(info), location=location, categories=categories, repeat=repeat, env={"calendars": ctx.obj['conf']['calendars']}, until=until, alarms=alarms, url=url, format=format, json=json ) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command('import') @click.option('--include-calendar', '-a', help=('The calendar to use.'), callback=_select_one_calendar_callback, multiple=True) @click.option('--batch', help=('do not ask for any confirmation.'), is_flag=True) @click.option('--random_uid', '-r', help=('Select a random uid.'), is_flag=True) @click.argument('ics', type=click.File('rb'), nargs=-1) @click.option('--format', '-f', help=('The format to print the event.')) @click.pass_context def import_ics(ctx, ics, include_calendar, batch, random_uid, format): '''Import events from an .ics file (or stdin). If an event with the same UID is already present in the (implicitly) selected calendar import will ask before updating (i.e. overwriting) that old event with the imported one, unless --batch is given, than it will always update. If this behaviour is not desired, use the `--random-uid` flag to generate a new, random UID. If no calendar is specified (and not `--batch`), you will be asked to choose a calendar. You can either enter the number printed behind each calendar's name or any unique prefix of a calendar's name. ''' if include_calendar: ctx.obj['calendar_selection'] = {include_calendar, } collection = build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)) if batch and len(collection.names) > 1 and \ ctx.obj['conf']['default']['default_calendar'] is None: raise click.UsageError( 'When using batch import, please specify a calendar to import ' 'into or set the `default_calendar` in the config file.') rvalue = 0 # Default to stdin: if not ics: ics_strs = ((sys.stdin.read(), 'stdin'),) if not batch: def isatty(_file): try: return _file.isatty() except Exception: return False if isatty(sys.stdin) and os.stat('/dev/tty').st_mode & stat.S_IFCHR > 0: sys.stdin = open('/dev/tty') else: logger.warning('/dev/tty does not exist, importing might not work') else: ics_strs = ((ics_file.read(), ics_file.name) for ics_file in ics) for ics_str, filename in ics_strs: try: controllers.import_ics( collection, ctx.obj['conf'], ics=ics_str, batch=batch, random_uid=random_uid, env={"calendars": ctx.obj['conf']['calendars']}, ) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(f"An error occurred when trying to import the file from {filename}") logger.fatal("Events from it will not be available in khal") if not batch: sys.exit(1) rvalue = 1 sys.exit(rvalue) @cli.command() @multi_calendar_option @mouse_option @click.pass_context def interactive(ctx, include_calendar, exclude_calendar, mouse): '''Interactive UI. Also launchable via `ikhal`.''' if mouse is not None: ctx.obj['conf']['default']['enable_mouse'] = mouse controllers.interactive( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), ctx.obj['conf'] ) @click.command() @global_options @multi_calendar_option @mouse_option @click.pass_context def interactive_cli(ctx, config, include_calendar, exclude_calendar, mouse): '''Interactive UI. Also launchable via `khal interactive`.''' prepare_context(ctx, config) if mouse is not None: ctx.obj['conf']['default']['enable_mouse'] = mouse controllers.interactive( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), ctx.obj['conf'] ) @cli.command() @multi_calendar_option @click.pass_context def printcalendars(ctx, include_calendar, exclude_calendar): '''List all calendars.''' try: click.echo('\n'.join(build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ).names)) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @click.pass_context def printformats(ctx): '''Print a date in all formats. Print the date 2013-12-21 21:45 in all configured date(time) formats to check if these locale settings are configured to ones liking.''' time = dt.datetime(2013, 12, 21, 21, 45) try: for strftime_format in [ 'longdatetimeformat', 'datetimeformat', 'longdateformat', 'dateformat', 'timeformat']: dt_str = time.strftime(ctx.obj['conf']['locale'][strftime_format]) click.echo(f'{strftime_format}: {dt_str}') except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @click.argument('ics', type=click.File('rb'), required=False) @click.option('--format', '-f', help=('The format to print the event.')) @click.pass_context def printics(ctx, ics, format): '''Print an ics file (or read from stdin) without importing it. Just print the ics file, do nothing else.''' try: if ics: ics_str = ics.read() name = ics.name else: ics_str = sys.stdin.read() name = 'stdin input' controllers.print_ics(ctx.obj['conf'], name, ics_str, format) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.option('--json', help=("Fields to output in json"), multiple=True) @click.argument('search_string') @click.pass_context def search(ctx, format, json, search_string, include_calendar, exclude_calendar): '''Search for events matching SEARCH_STRING. For recurring events, only the master event and different overwritten events are shown. ''' # TODO support for time ranges, location, description etc if format is None: format = ctx.obj['conf']['view']['event_format'] try: collection = build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ) events = sorted(collection.search(search_string)) event_column = [] term_width, _ = get_terminal_size() now = dt.datetime.now() env = {"calendars": ctx.obj['conf']['calendars']} if len(json) == 0: formatter = human_formatter(format) else: formatter = json_formatter(json) for event in events: desc = textwrap.wrap(formatter( event.attributes(relative_to=now, env=env)), term_width) event_column.extend( [colored(d, event.color, bold_for_light_color=ctx.obj['conf']['view']['bold_for_light_color']) for d in desc] ) if event_column: click.echo('\n'.join(event_column)) else: logger.debug('No events found') except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.option('--show-past', help=('Show events that have already occurred as options'), is_flag=True) @click.argument('search_string', nargs=-1) @click.pass_context def edit(ctx, format, search_string, show_past, include_calendar, exclude_calendar): '''Interactively edit (or delete) events matching the search string.''' try: controllers.edit( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), ' '.join(search_string), format=format, allow_past=show_past, locale=ctx.obj['conf']['locale'], conf=ctx.obj['conf'] ) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.option('--day-format', '-df', help=('The format of the day line.')) @click.option('--notstarted', help=('Print only events that have not started'), is_flag=True) @click.option('--json', help=("Fields to output in json"), multiple=True) @click.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]') @click.pass_context def at(ctx, datetime, notstarted, format, day_format, json, include_calendar, exclude_calendar): '''Print all events at a specific datetime (defaults to now).''' if not datetime: datetime = ("now",) if format is None: format = ctx.obj['conf']['view']['event_format'] try: rows = controllers.khal_list( build_collection( ctx.obj['conf'], multi_calendar_select(ctx, include_calendar, exclude_calendar) ), agenda_format=format, day_format=day_format, datepoint=list(datetime), once=True, notstarted=notstarted, conf=ctx.obj['conf'], env={"calendars": ctx.obj['conf']['calendars']}, json=json ) if rows: click.echo('\n'.join(rows)) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) @cli.command() @click.pass_context def configure(ctx): """Helper for initial configuration of khal.""" from . import configwizard try: configwizard.configwizard() except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) main_khal, main_ikhal = cli, interactive_cli khal-0.11.4/khal/cli_utils.py000066400000000000000000000170561477603436700160260ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import logging import sys import click import click_log from . import __version__, khalendar from .exceptions import FatalError from .settings import InvalidSettingsError, NoConfigFile, get_config logger = logging.getLogger('khal') click_log.basic_config('khal') days_option = click.option('--days', default=None, type=int, help='How many days to include.') week_option = click.option('--week', '-w', help='Include all events in one week.', is_flag=True) events_option = click.option('--events', default=None, type=int, help='How many events to include.') dates_arg = click.argument('dates', nargs=-1) def time_args(f): return dates_arg(events_option(week_option(days_option(f)))) def multi_calendar_select(ctx, include_calendars, exclude_calendars): if include_calendars and exclude_calendars: raise click.UsageError('Can\'t use both -a and -d.') selection = set() if include_calendars: for cal_name in include_calendars: if cal_name not in ctx.obj['conf']['calendars']: raise click.BadParameter( f'Unknown calendar {cal_name}, run `khal printcalendars` ' 'to get a list of all configured calendars.' ) selection.update(include_calendars) elif exclude_calendars: selection.update(ctx.obj['conf']['calendars'].keys()) for value in exclude_calendars: selection.remove(value) return selection or None def multi_calendar_option(f): a = click.option('--include-calendar', '-a', multiple=True, metavar='CAL', help=('Include the given calendar. Can be specified ' 'multiple times.')) d = click.option('--exclude-calendar', '-d', multiple=True, metavar='CAL', help=('Exclude the given calendar. Can be specified ' 'multiple times.')) return d(a(f)) def mouse_option(f): o = click.option( '--mouse/--no-mouse', is_flag=True, default=None, help='Disable mouse in interactive UI' ) return o(f) def _select_one_calendar_callback(ctx, option, calendar): if isinstance(calendar, tuple): if len(calendar) > 1: raise click.UsageError( 'Can\'t use "--include-calendar" / "-a" more than once for this command.') elif len(calendar) == 1: calendar = calendar[0] return _calendar_select_callback(ctx, option, calendar) def _calendar_select_callback(ctx, option, calendar): if calendar and calendar not in ctx.obj['conf']['calendars']: raise click.BadParameter( f'Unknown calendar {calendar}, run `khal printcalendars` to get a ' 'list of all configured calendars.' ) return calendar def calendar_option(f): return click.option('--calendar', '-a', metavar='CAL', callback=_calendar_select_callback)(f) def global_options(f): def color_callback(ctx, option, value): ctx.color = value def logfile_callback(ctx, option, path): ctx.logfilepath = path config = click.option( '--config', '-c', help='The config file to use.', default=None, metavar='PATH' ) color = click.option( '--color/--no-color', help=('Use colored/uncolored output. Default is to only enable colors ' 'when not part of a pipe.'), expose_value=False, default=None, callback=color_callback ) logfile = click.option( '--logfile', '-l', help='The logfile to use [defaults to stdout]', type=click.Path(), callback=logfile_callback, default=None, expose_value=False, metavar='LOGFILE', ) version = click.version_option(version=__version__) return logfile(config(color(version(f)))) def build_collection(conf, selection): """build and return a khalendar.CalendarCollection from the configuration""" try: props = {} for name, cal in conf['calendars'].items(): if selection is None or name in selection: props[name] = { 'name': name, 'path': cal['path'], 'readonly': cal['readonly'], 'color': cal['color'], 'priority': cal['priority'], 'ctype': cal['type'], 'addresses': cal['addresses'] if 'addresses' in cal else '', } collection = khalendar.CalendarCollection( calendars=props, color=conf['highlight_days']['color'], locale=conf['locale'], dbpath=conf['sqlite']['path'], hmethod=conf['highlight_days']['method'], default_color=conf['highlight_days']['default_color'], multiple=conf['highlight_days']['multiple'], multiple_on_overflow=conf['highlight_days']['multiple_on_overflow'], highlight_event_days=conf['default']['highlight_event_days'], ) except FatalError as error: logger.debug(error, exc_info=True) logger.fatal(error) sys.exit(1) collection._default_calendar_name = conf['default']['default_calendar'] return collection class _NoConfig: def __getitem__(self, key): logger.fatal( 'Cannot find a config file. If you have no configuration file ' 'yet, you might want to run `khal configure`.') sys.exit(1) def prepare_context(ctx, config): assert ctx.obj is None logger.debug('khal %s' % __version__) try: conf = get_config(config) except NoConfigFile: conf = _NoConfig() except InvalidSettingsError: logger.info('If your configuration file used to work, please have a ' 'look at the Changelog to see what changed.') sys.exit(1) else: logger.debug('Using config:') logger.debug(stringify_conf(conf)) ctx.obj = {'conf_path': config, 'conf': conf} def stringify_conf(conf): # since we have only two levels of recursion, a recursive function isn't # really worth it out = [] for key, value in conf.items(): out.append(f'[{key}]') for subkey, subvalue in value.items(): if isinstance(subvalue, dict): out.append(f' [[{subkey}]]') for subsubkey, subsubvalue in subvalue.items(): out.append(f' {subsubkey}: {subsubvalue}') else: out.append(f' {subkey}: {subvalue}') return '\n'.join(out) khal-0.11.4/khal/configwizard.py000066400000000000000000000336451477603436700165270ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import datetime as dt import json import logging from functools import partial from itertools import zip_longest from os import environ, makedirs from os.path import dirname, exists, expanduser, expandvars, isdir, join, normpath from subprocess import call import xdg from click import Choice, UsageError, confirm, prompt from .exceptions import FatalError from .settings import find_configuration_file, utils logger = logging.getLogger('khal') def compressuser(path): """Abbreviate home directory to '~', for presenting a path.""" home = normpath(expanduser('~')) path = normpath(path) if path.startswith(home): path = '~' + path[len(home):] return path def validate_int(input, min_value, max_value): try: number = int(input) except ValueError: raise UsageError('Input must be an integer') if min_value <= number <= max_value: return number else: raise UsageError(f'Input must be between {min_value} and {max_value}') DATE_FORMAT_INFO = [ ('Year', ['%Y', '%y']), ('Month', ['%m', '%B', '%b']), ('Day', ['%d', '%a', '%A']) ] def present_date_format_info(example_date): columns = [] widths = [] for title, formats in DATE_FORMAT_INFO: newcol = [title] for f in formats: newcol.append(f'{f}={example_date.strftime(f)}') widths.append(max(len(s) for s in newcol) + 2) columns.append(newcol) print('Common fields for date formatting:') for row in zip_longest(*columns, fillvalue=''): print(''.join(s.ljust(w) for (s, w) in zip(row, widths))) print('More info: ' 'https://docs.python.org/3/library/datetime.html#strftime-and-strptime-behavior') def choose_datetime_format(): """query user for their date format of choice""" choices = [ ('year-month-day', '%Y-%m-%d'), ('day/month/year', '%d/%m/%Y'), ('month/day/year', '%m/%d/%Y'), ] validate = partial(validate_int, min_value=0, max_value=3) today = dt.date.today() print("What ordering of year, month, date do you want to use?") for num, (desc, fmt) in enumerate(choices): print(f'[{num}] {desc} (today: {today.strftime(fmt)})') print('[3] Custom') choice_no = prompt("Please choose one of the above options", value_proc=validate) if choice_no == 3: present_date_format_info(today) dateformat = prompt('Make your date format') else: dateformat = choices[choice_no][1] print(f"Date format: {dateformat} " f"(today as an example: {today.strftime(dateformat)})") return dateformat def choose_time_format(): """query user for their time format of choice""" choices = ['%H:%M', '%I:%M %p'] print("What timeformat do you want to use?") print("[0] 24 hour clock (recommended)\n[1] 12 hour clock") validate = partial(validate_int, min_value=0, max_value=1) prompt_text = "Please choose one of the above options" timeformat = choices[prompt(prompt_text, default=0, value_proc=validate)] now = dt.datetime.now() print(f"Time format: {timeformat} " f"(current time as an example: {now.strftime(timeformat)})") return timeformat def get_collection_names_from_vdirs(vdirs): names = [] for name, path, vtype in sorted(vdirs or ()): if vtype == 'discover': for vpath in utils.get_all_vdirs(utils.expand_path(path)): names.append(utils.get_unique_name(vpath, names)) else: names.append(name) return names def choose_default_calendar(vdirs): names = get_collection_names_from_vdirs(vdirs) print("Which calendar do you want as a default calendar?") print("(The default calendar is used when no calendar is specified.)") print(f"Configured calendars: {', '.join(names)}") default_calendar = prompt( "Please type one of the above options", default=names[0], type=Choice(names), ) return default_calendar def get_vdirs_from_vdirsyncer_config(): """trying to load vdirsyncer's config and read all vdirs from it""" try: from vdirsyncer.cli import config from vdirsyncer.exceptions import UserError except ImportError: print("Couldn't load vdirsyncer to discover its calendars.") return None try: vdir_config = config.load_config() except UserError as error: print("Sorry, loading vdirsyncer config failed with the following " "error message:") print(error) return None vdirs = [] for storage in vdir_config.storages.values(): if storage['type'] == 'filesystem': # TODO detect type of storage properly path = storage['path'] if path[-1] != '/': path += '/' path += '*' vdirs.append((storage['instance_name'], path, 'discover')) if vdirs == []: print("No calendars found from vdirsyncer.") return None else: return vdirs def find_vdir(): """Use one or more existing vdirs on the system. Tries to get data from vdirsyncer if it's installed and configured, and asks user to confirm it. If not, prompt the user for the path to a single vdir. """ print("The following calendars were found:") synced_vdirs = get_vdirs_from_vdirsyncer_config() if synced_vdirs: print(f"Found {len(synced_vdirs)} calendars from vdirsyncer") for name, path, _ in synced_vdirs: print(f' {name}: {compressuser(path)}') if confirm("Use these calendars for khal?", default=True): return synced_vdirs vdir_path = prompt("Enter the path to a vdir calendar") vdir_path = normpath(expanduser(expandvars(vdir_path))) return [('private', vdir_path, 'calendar')] def create_vdir(names=None): """create a new vdir, make sure the name doesn't collide with existing names :param names: names of existing vdirs """ names = names or [] name = 'private' while True: path = join(xdg.BaseDirectory.xdg_data_home, 'khal', 'calendars', name) path = normpath(expanduser(expandvars(path))) if name not in names and not exists(path): break else: name += '1' try: makedirs(path) except OSError as error: print(f"Could not create directory {path} because of {error}. Exiting") raise print(f"Created new vdir at {path}") return [(name, path, 'calendar')] # Parsing and then dumping config naively could lose comments and formatting. # Since we don't need to modify existing fields, we can simply append our new # config to the end of the file. VDS_CONFIG_START = """\ [general] status_path = "~/.local/share/vdirsyncer/status/" """ VDS_CONFIG_TEMPLATE = """ [pair khal_pair_{pairno}] a = "khal_pair_{pairno}_local" b = "khal_pair_{pairno}_remote" collections = ["from a", "from b"] [storage khal_pair_{pairno}_local] type = "filesystem" path = {local_path} fileext = ".ics" [storage khal_pair_{pairno}_remote] type = "caldav" url = {url} username = {username} password = {password} """ def vdirsyncer_config_path(): """Find where vdirsyncer will look for it's config. There may or may not already be a file at the returned path. """ fname = environ.get('VDIRSYNCER_CONFIG', None) if fname is None: fname = normpath(expanduser('~/.vdirsyncer/config')) if not exists(fname): xdg_config_dir = environ.get('XDG_CONFIG_HOME', normpath(expanduser('~/.config/'))) fname = join(xdg_config_dir, 'vdirsyncer/config') return fname def get_available_pairno(): """Find N so that 'khal_pair_N' is not already used in vdirsyncer config """ try: from vdirsyncer.cli import config except ImportError: raise FatalError("vdirsyncer config exists, but couldn't import vdirsyncer.") vdir_config = config.load_config() pairno = 1 while f'khal_pair_{pairno}' in vdir_config.pairs: pairno += 1 return pairno def create_synced_vdir(): """Create a new vdir, and set up vdirsyncer to sync it. """ name, path, _ = create_vdir()[0] caldav_url = prompt('CalDAV URL') username = prompt('Username') password = prompt('Password', hide_input=True) vds_config = vdirsyncer_config_path() if exists(vds_config): # We are adding a pair to vdirsyncer config mode = 'a' new_file = False pairno = get_available_pairno() else: # We're setting up vdirsyncer for the first time mode = 'w' new_file = True pairno = 1 with open(vds_config, mode) as f: if new_file: f.write(VDS_CONFIG_START) f.write(VDS_CONFIG_TEMPLATE.format( local_path=json.dumps(dirname(path)), url=json.dumps(caldav_url), username=json.dumps(username), password=json.dumps(password), pairno=pairno, )) start_syncing() return [(name, path, 'calendar')] def start_syncing(): """Run vdirsyncer to sync the newly created vdir with the remote.""" print("Syncing calendar...") try: exit_code = call(['vdirsyncer', 'discover']) except FileNotFoundError: print("Could not find vdirsyncer - please set it up manually") else: if exit_code == 0: exit_code = call(['vdirsyncer', 'sync']) if exit_code != 0: print("vdirsyncer failed - please set up sync manually") # Add code here to check platform and automatically set up cron or similar print("Please set up your system to run 'vdirsyncer sync' periodically, " "using cron or similar mechanisms.") def choose_vdir_calendar(): """query the user for their preferred calendar source""" choices = [ ("Create a new calendar on this computer", create_vdir), ("Use a calendar already on this computer (vdir format)", find_vdir), ("Sync a calendar from the internet (CalDAV format, requires vdirsyncer)", create_synced_vdir), ] validate = partial(validate_int, min_value=0, max_value=2) for i, (desc, _func) in enumerate(choices): print(f'[{i}] {desc}') choice_no = prompt("Please choose one of the above options", value_proc=validate) return choices[choice_no][1]() def create_config(vdirs, dateformat, timeformat, default_calendar=None): config = ['[calendars]'] for name, path, type_ in sorted(vdirs or ()): config.append(f'\n[[{name}]]') config.append(f'path = {path}') config.append(f'type = {type_}') config.append('\n[locale]') config.append(f'timeformat = {timeformat}\n' f'dateformat = {dateformat}\n' f'longdateformat = {dateformat}\n' f'datetimeformat = {dateformat} {timeformat}\n' f'longdatetimeformat = {dateformat} {timeformat}\n' ) if default_calendar: config.append('[default]') config.append(f'default_calendar = {default_calendar}\n') config = '\n'.join(config) return config def configwizard(): config_file = find_configuration_file() if config_file is not None: logger.fatal(f"Found an existing config file at {compressuser(config_file)}.") logger.fatal( "If you want to create a new configuration file, " "please remove the old one first. Exiting.") raise FatalError() dateformat = choose_datetime_format() print() timeformat = choose_time_format() print() try: vdirs = choose_vdir_calendar() except OSError as error: raise FatalError(error) if not vdirs: print("\nWARNING: no vdir configured, khal will not be usable like this!\n") print() if vdirs: default_calendar = choose_default_calendar(vdirs) else: default_calendar = None config = create_config( vdirs, dateformat=dateformat, timeformat=timeformat, default_calendar=default_calendar, ) config_path = join(xdg.BaseDirectory.xdg_config_home, 'khal', 'config') if not confirm( f"Do you want to write the config to {compressuser(config_path)}? " "(Choosing `No` will abort)", default=True): raise FatalError('User aborted...') config_dir = join(xdg.BaseDirectory.xdg_config_home, 'khal') if not exists(config_dir) and not isdir(config_dir): try: makedirs(config_dir) except OSError as error: print( f"Could not write config file at {compressuser(config_dir)} because of " f"{error}. Aborting" ) raise FatalError(error) else: print(f"created directory {compressuser(config_dir)}") with open(config_path, 'w') as config_file: config_file.write(config) print(f"Successfully wrote configuration to {compressuser(config_path)}") khal-0.11.4/khal/controllers.py000066400000000000000000000632571477603436700164110ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import datetime as dt import logging import os import re import textwrap from collections import OrderedDict, defaultdict from shutil import get_terminal_size from typing import Callable, List, Optional import pytz from click import confirm, echo, prompt, style from khal import __productname__, __version__, calendar_display, parse_datetime from khal.custom_types import ( EventCreationTypes, LocaleConfiguration, MonthDisplayType, WeekNumbersType, ) from khal.exceptions import DateTimeParseError, FatalError from khal.khalendar import CalendarCollection from khal.khalendar.event import Event from khal.khalendar.exceptions import DuplicateUid, ReadOnlyCalendarError from .exceptions import ConfigurationError from .icalendar import cal_from_ics, split_ics from .icalendar import sort_key as sort_vevent_key from .khalendar.vdir import Item from .parse_datetime import timedelta2str from .terminal import merge_columns from .utils import human_formatter, json_formatter logger = logging.getLogger('khal') def format_day(day: dt.date, format_string: str, locale, attributes=None): if attributes is None: attributes = {} attributes["date"] = day.strftime(locale['dateformat']) attributes["date-long"] = day.strftime(locale['longdateformat']) attributes["name"] = parse_datetime.construct_daynames(day) colors = {"reset": style("", reset=True), "bold": style("", bold=True, reset=False)} for c in ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]: colors[c] = style("", reset=False, fg=c) colors[c + "-bold"] = style("", reset=False, fg=c, bold=True) attributes.update(colors) try: return format_string.format(**attributes) + colors["reset"] except (KeyError, IndexError): raise KeyError("cannot format day with: %s" % format_string) def calendar( collection: CalendarCollection, agenda_format=None, notstarted: bool=False, once=False, daterange=None, day_format=None, locale=None, conf=None, firstweekday: int=0, weeknumber: WeekNumbersType=False, monthdisplay: MonthDisplayType='firstday', hmethod: str='fg', default_color: str='', multiple='', multiple_on_overflow: bool=False, color='', highlight_event_days=0, full=False, bold_for_light_color: bool=True, env=None, ): term_width, _ = get_terminal_size() lwidth = 27 if conf['locale']['weeknumbers'] == 'right' else 25 rwidth = term_width - lwidth - 4 try: start, end = start_end_from_daterange( daterange, locale, default_timedelta_date=conf['default']['timedelta'], default_timedelta_datetime=conf['default']['timedelta'], ) except ValueError as error: raise FatalError(error) event_column = khal_list( collection, daterange, conf=conf, agenda_format=agenda_format, day_format=day_format, once=once, notstarted=notstarted, width=rwidth, env=env, ) if not event_column: event_column = [style('No events', bold=True)] month_count = (end.year - start.year) * 12 + end.month - start.month + 1 calendar_column = calendar_display.vertical_month( month=start.month, year=start.year, count=max(conf['view']['min_calendar_display'], month_count), firstweekday=firstweekday, weeknumber=weeknumber, monthdisplay=monthdisplay, collection=collection, hmethod=hmethod, default_color=default_color, multiple=multiple, multiple_on_overflow=multiple_on_overflow, color=color, highlight_event_days=highlight_event_days, locale=locale, bold_for_light_color=bold_for_light_color) return merge_columns(calendar_column, event_column, width=lwidth) def start_end_from_daterange( daterange: List[str], locale: LocaleConfiguration, default_timedelta_date: dt.timedelta=dt.timedelta(days=1), default_timedelta_datetime: dt.timedelta=dt.timedelta(hours=1), ): """ convert a string description of a daterange into start and end datetime if no description is given, return (today, today + default_timedelta_date) :param daterange: an iterable of strings that describes `daterange` :param locale: locale settings """ if not daterange: start = dt.datetime(*dt.date.today().timetuple()[:3]) end = start + default_timedelta_date else: start, end, allday = parse_datetime.guessrangefstr( daterange, locale, default_timedelta_date=default_timedelta_date, default_timedelta_datetime=default_timedelta_datetime, ) return start, end def get_events_between( collection: CalendarCollection, locale: dict, start: dt.datetime, end: dt.datetime, formatter: Callable, notstarted: bool, env: dict, original_start: dt.datetime, seen=None, colors: bool = True, ) -> List[str]: """returns a list of events scheduled between start and end. Start and end are strings or datetimes (of some kind). :param collection: :param locale: :param start: the start datetime :param end: the end datetime :param formatter: the formatter (see :class:`.utils.human_formatter`) :param nostarted: True if each event should start after start (instead of be active between start and end) :param env: a collection of "static" values like calendar names and color :param original_start: start datetime to compare against of notstarted is set :param seen: :param colors: :returns: a list to be printed as the agenda for the given days """ assert not (notstarted and not original_start) event_list = [] if env is None: env = {} assert start assert end start_local = locale['local_timezone'].localize(start) end_local = locale['local_timezone'].localize(end) start = start_local.replace(tzinfo=None) end = end_local.replace(tzinfo=None) events = sorted(collection.get_localized(start_local, end_local)) events_float = sorted(collection.get_floating(start, end)) events = sorted(events + events_float) for event in events: # yes the logic could be simplified, but I believe it's easier # to understand what's going on here this way if notstarted: if event.allday and event.start < original_start.date(): continue elif not event.allday and event.start_local < original_start: continue if seen is not None and event.uid in seen: continue try: event_attributes = event.attributes(relative_to=(start, end), env=env, colors=colors) except KeyError as error: raise FatalError(error) event_list.append(event_attributes) if seen is not None: seen.add(event.uid) return formatter(event_list) def khal_list( collection, daterange: Optional[List[str]] = None, conf: Optional[dict] = None, agenda_format=None, day_format: Optional[str]=None, once=False, notstarted: bool = False, width: Optional[int] = None, env=None, datepoint=None, json: Optional[List] = None, ): """returns a list of all events in `daterange`""" assert daterange is not None or datepoint is not None assert conf is not None # because empty strings are also Falsish if agenda_format is None: agenda_format = conf['view']['agenda_event_format'] if json: formatter = json_formatter(json) colors = False else: formatter = human_formatter(agenda_format, width) colors = True if daterange is not None: if day_format is None: day_format = conf['view']['agenda_day_format'] start, end = start_end_from_daterange( daterange, conf['locale'], default_timedelta_date=conf['default']['timedelta'], default_timedelta_datetime=conf['default']['timedelta'], ) logger.debug(f'Getting all events between {start} and {end}') elif datepoint is not None: if not datepoint: datepoint = ['now'] try: # hand over a copy of the `datepoint` so error reporting works # (we pop from that list in guessdatetimefstr()) start, allday = parse_datetime.guessdatetimefstr( list(datepoint), conf['locale'], dt.date.today(), ) except (ValueError, IndexError): raise FatalError(f"Invalid value of {' '.join(datepoint)} for a datetime") if allday: logger.debug(f'Got date {start}') raise FatalError('Please supply a datetime, not a date.') end = start + dt.timedelta(seconds=1) if day_format is None: day_format = style( start.strftime(conf['locale']['longdatetimeformat']), bold=True, ) logger.debug(f'Getting all events between {start} and {end}') event_column: List[str] = [] once = set() if once else None if env is None: env = {} original_start = conf['locale']['local_timezone'].localize(start) while start < end: if start.date() == end.date(): day_end = end else: day_end = dt.datetime.combine(start.date(), dt.time.max) current_events = get_events_between( collection, locale=conf['locale'], formatter=formatter, start=start, end=day_end, notstarted=notstarted, original_start=original_start, env=env, seen=once, colors=colors, ) if day_format and (conf['default']['show_all_days'] or current_events) and not json: if len(event_column) != 0 and conf['view']['blank_line_before_day']: event_column.append('') event_column.append(format_day(start.date(), day_format, conf['locale'])) event_column.extend(current_events) start = dt.datetime(*start.date().timetuple()[:3]) + dt.timedelta(days=1) return event_column def new_interactive(collection, calendar_name, conf, info, location=None, categories=None, repeat=None, until=None, alarms=None, format=None, json=None, env=None, url=None): info: EventCreationTypes try: info = parse_datetime.eventinfofstr( info, conf['locale'], default_event_duration=conf['default']['default_event_duration'], default_dayevent_duration=conf['default']['default_dayevent_duration'], adjust_reasonably=True, ) except DateTimeParseError: info = {} while True: summary = info.get('summary') if not summary: summary = None info['summary'] = prompt('summary', default=summary) if info['summary']: break echo("a summary is required") while True: range_string = None if info.get('dtstart') and info.get('dtend'): start_string = info["dtstart"].strftime(conf['locale']['datetimeformat']) end_string = info["dtend"].strftime(conf['locale']['datetimeformat']) range_string = start_string + ' ' + end_string daterange = prompt("datetime range", default=range_string) start, end, allday = parse_datetime.guessrangefstr( daterange, conf['locale'], adjust_reasonably=True) info['dtstart'] = start info['dtend'] = end info['allday'] = allday if info['dtstart'] and info['dtend']: break echo("invalid datetime range") while True: tz = info.get('timezone') or conf['locale']['default_timezone'] timezone = prompt("timezone", default=str(tz)) try: tz = pytz.timezone(timezone) info['timezone'] = tz break except pytz.UnknownTimeZoneError: echo("unknown timezone") info['description'] = prompt("description (or 'None')", default=info.get('description')) if info['description'] == 'None': info['description'] = '' info.update({ 'location': location, 'categories': categories, 'repeat': repeat, 'until': until, 'alarms': alarms, 'url': url, }) event = new_from_dict( info, collection, conf, format=format, env=env, calendar_name=calendar_name, json=json, ) echo("event saved") term_width, _ = get_terminal_size() edit_event(event, collection, conf['locale'], width=term_width) def new_from_string(collection, calendar_name, conf, info, location=None, categories=None, repeat=None, until=None, alarms=None, url=None, format=None, json=None, env=None): """construct a new event from a string and add it""" info = parse_datetime.eventinfofstr( info, conf['locale'], conf['default']['default_event_duration'], conf['default']['default_dayevent_duration'], adjust_reasonably=True, ) if alarms is None: if info['allday']: alarms = timedelta2str(conf['default']['default_dayevent_alarm']) else: alarms = timedelta2str(conf['default']['default_event_alarm']) info.update({ 'location': location, 'categories': categories, 'repeat': repeat, 'until': until, 'alarms': alarms, 'url': url, }) new_from_dict( info, collection, conf=conf, format=format, env=env, calendar_name=calendar_name, json=json, ) def new_from_dict( event_args: EventCreationTypes, collection: CalendarCollection, conf, calendar_name: Optional[str]=None, format=None, env=None, json=None, ) -> Event: """Create a new event from arguments and save in vdirs This is a wrapper around CalendarCollection.create_event_from_dict() """ if isinstance(event_args['categories'], str): event_args['categories'] = [event_args['categories'].strip() for category in event_args['categories'].split(',')] try: event = collection.create_event_from_dict(event_args, calendar_name=calendar_name) except ValueError as error: raise FatalError(error) try: collection.insert(event) except ReadOnlyCalendarError: raise FatalError( f'ERROR: Cannot modify calendar `{calendar_name}` as it is read-only' ) if conf['default']['print_new'] == 'event': if json is None or len(json) == 0: if format is None: format = conf['view']['event_format'] formatter = human_formatter(format) else: formatter = json_formatter(json) echo(formatter(event.attributes(dt.datetime.now(), env=env))) elif conf['default']['print_new'] == 'path': assert event.href path = os.path.join(collection._calendars[event.calendar]['path'], event.href) echo(path) return event def present_options(options, prefix="", sep=" ", width=70): option_list = [prefix] if prefix else [] chars = {} for option in options: char = options[option]["short"] chars[char] = option option_list.append(option.replace(char, '[' + char + ']', 1)) option_string = sep.join(option_list) option_string = textwrap.fill(option_string, width) char = prompt(option_string) if char in chars: return chars[char] else: return None def edit_event(event, collection, locale, allow_quit=False, width=80): options = OrderedDict() if allow_quit: options["no"] = {"short": "n"} options["quit"] = {"short": "q"} else: options["done"] = {"short": "n"} options["summary"] = {"short": "s", "attr": "summary"} options["description"] = {"short": "d", "attr": "description", "none": True} options["datetime range"] = {"short": "t"} options["repeat"] = {"short": "p"} options["location"] = {"short": "l", "attr": "location", "none": True} options["categories"] = {"short": "c", "attr": "categories", "none": True} options["alarm"] = {"short": "a"} options["Delete"] = {"short": "D"} options["url"] = {"short": "u", "attr": "url", "none": True} # some output contains ansi escape sequences (probably only resets) # if hitting enter, the output (including the escape sequence) gets parsed # and fails the parsing. Therefore we remove ansi escape sequences before # parsing. ansi = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') now = dt.datetime.now() while True: choice = present_options(options, prefix="Edit?", width=width) if choice is None: echo("unknown choice") continue if choice == 'no': return True if choice in ['quit', 'done']: return False edited = False if choice == "Delete": if confirm("Delete all occurences of event?"): collection.delete(event.href, event.etag, event.calendar) return True elif choice == "datetime range": current = human_formatter("{start} {end}")(event.attributes(relative_to=now)) value = prompt("datetime range", default=current) try: start, end, allday = parse_datetime.guessrangefstr(ansi.sub('', value), locale) event.update_start_end(start, end) edited = True except: # noqa echo("error parsing range") elif choice == "repeat": recur = event.recurobject freq = recur["freq"] if "freq" in recur else "" until = recur["until"] if "until" in recur else "" if not freq: freq = 'None' freq = prompt('frequency (or "None")', freq) if freq == 'None': event.update_rrule(None) else: until = prompt('until (or "None")', until) if until == 'None': until = None rrule = parse_datetime.rrulefstr(freq, until, locale, event.start.tzinfo) event.update_rrule(rrule) edited = True elif choice == "alarm": default_alarms = [] for a in event.alarms: s = parse_datetime.timedelta2str(-1 * a[0]) default_alarms.append(s) default = ', '.join(default_alarms) if not default: default = 'None' alarm = prompt('alarm (or "None")', default) if alarm == "None": alarm = "" alarm_list = [] for a in alarm.split(","): alarm_trig = -1 * parse_datetime.guesstimedeltafstr(a.strip()) new_alarm = (alarm_trig, event.description) alarm_list += [new_alarm] event.update_alarms(alarm_list) edited = True else: attr = options[choice]["attr"] default = getattr(event, attr) question = choice allow_none = False if "none" in options[choice] and options[choice]["none"]: question += ' (or "None")' allow_none = True if not default: default = 'None' value = prompt(question, default) if allow_none and value == "None": value = "" if attr == 'categories': getattr(event, "update_" + attr)([cat.strip() for cat in value.split(',')]) else: getattr(event, "update_" + attr)(value) edited = True if edited: event.increment_sequence() collection.update(event) def edit(collection, search_string, locale, format=None, allow_past=False, conf=None): if conf is not None: if format is None: format = conf['view']['event_format'] term_width, _ = get_terminal_size() now = conf['locale']['local_timezone'].localize(dt.datetime.now()) events = sorted(collection.search(search_string)) for event in events: if not allow_past: if event.allday and event.end < now.date(): continue elif not event.allday and event.end_local < now: continue event_text = textwrap.wrap(human_formatter(format)( event.attributes(relative_to=now)), term_width) echo(''.join(event_text)) if not edit_event(event, collection, locale, allow_quit=True, width=term_width): return def interactive(collection, conf): """start the interactive user interface""" from . import ui pane = ui.ClassicView( collection, conf, title="select an event", description="do something") ui.start_pane( pane, pane.cleanup, program_info=f'{__productname__} v{__version__}', quit_keys=conf['keybindings']['quit'], ) def import_ics(collection, conf, ics, batch=False, random_uid=False, format=None, env=None): """ :param batch: setting this to True will insert without asking for approval, even when an event with the same uid already exists :type batch: bool :param random_uid: whether to assign a random UID to imported events or not :type random_uid: bool :param format: the format string to print events with :type format: str """ if format is None: format = conf['view']['event_format'] try: vevents = split_ics(ics, random_uid, conf['locale']['default_timezone']) except Exception as error: raise FatalError(error) for vevent in vevents: import_event(vevent, collection, conf['locale'], batch, format, env) def import_event(vevent, collection, locale, batch, format=None, env=None): """import one event into collection, let user choose the collection :type vevent: list of vevents, which can be more than one VEVENT, i.e., the same UID, i.e., one "master" event and (optionally) 1+ RECURRENCE-ID events :type vevent: list(str) """ # print all sub-events if not batch: for item in cal_from_ics(vevent).walk(): if item.name == 'VEVENT': event = Event.fromVEvents( [item], calendar=collection.default_calendar_name, locale=locale) echo(human_formatter(format)(event.attributes(dt.datetime.now(), env=env))) # get the calendar to insert into if not collection.writable_names: raise ConfigurationError('No writable calendars found, aborting import.') if len(collection.writable_names) == 1: calendar_name = collection.writable_names[0] elif batch: calendar_name = collection.default_calendar_name else: calendar_names = sorted(collection.writable_names) choices = ', '.join( [f'{name}({num})' for num, name in enumerate(calendar_names)]) while True: value = prompt( "Which calendar do you want to import to? (unique prefixes are fine)\n" f"{choices}", default=collection.default_calendar_name, ) try: calendar_name = calendar_names[int(value)] break except (ValueError, IndexError): matches = [x for x in collection.writable_names if x.startswith(value)] if len(matches) == 1: calendar_name = matches[0] break echo('invalid choice') assert calendar_name in collection.writable_names if batch or confirm(f"Do you want to import this event into `{calendar_name}`?"): try: collection.insert(Item(vevent), collection=calendar_name) except DuplicateUid: if batch or confirm( "An event with the same UID already exists. Do you want to update it?"): collection.force_update(Item(vevent), collection=calendar_name) else: logger.warning(f"Not importing event with UID `{event.uid}`") def print_ics(conf, name, ics, format): if format is None: format = conf['view']['event_format'] cal = cal_from_ics(ics) events = [item for item in cal.walk() if item.name == 'VEVENT'] events_grouped = defaultdict(list) for event in events: events_grouped[event['UID']].append(event) vevents = [] for uid in events_grouped: vevents.append(sorted(events_grouped[uid], key=sort_vevent_key)) echo(f'{len(vevents)} events found in {name}') for sub_event in vevents: event = Event.fromVEvents(sub_event, locale=conf['locale']) echo(human_formatter(format)(event.attributes(dt.datetime.now()))) khal-0.11.4/khal/custom_types.py000066400000000000000000000032451477603436700165700ustar00rootroot00000000000000import datetime as dt import os from typing import List, Literal, Optional, Protocol, Tuple, TypedDict, Union import pytz class CalendarConfiguration(TypedDict): name: str path: str readonly: bool color: str priority: int ctype: str addresses: str class LocaleConfiguration(TypedDict): local_timezone: pytz.BaseTzInfo default_timezone: pytz.BaseTzInfo timeformat: str dateformat: str longdateformat: str datetimeformat: str longdatetimeformat: str weeknumbers: Union[str, bool] firstweekday: int unicode_symbols: bool class SupportsRaw(Protocol): @property def uid(self) -> Optional[str]: ... @property def raw(self) -> str: ... # set this to TypeAlias once we support that python version (PEP613) EventTuple = Tuple[ str, str, Union[dt.date, dt.datetime], Union[dt.date, dt.datetime], str, str, str, ] # Only need for RRuleMapType class RRuleMapBase(TypedDict): freq: str class RRuleMapType(RRuleMapBase, total=False): # not required keys go in here # TODO remove if either `NotRequired` is supported by mypy or the oldest # python we support is 3.11 (see PEP 655) until: dt.datetime class EventCreationTypes(TypedDict): dtstart: dt.date dtend: dt.date summary: str description: str allday: bool location: Optional[str] categories: Optional[Union[str, List[str]]] repeat: Optional[str] until: str alarms: str timezone: pytz.BaseTzInfo url: str PathLike = Union[str, os.PathLike] WeekNumbersType = Literal['left', 'right', False] MonthDisplayType = Literal['firstday', 'firstfullweek'] khal-0.11.4/khal/exceptions.py000066400000000000000000000031041477603436700162050ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. class Error(Exception): """base class for all of khal's Exceptions""" pass class FatalError(Error): """execution cannot continue""" pass class DateTimeParseError(FatalError): pass class ConfigurationError(FatalError): pass class UnsupportedFeatureError(Error): """something Failed but we know why""" pass class UnsupportedRecurrence(Error): """raised if the RRULE is not understood by dateutil.rrule""" pass class InvalidDate(Error): pass khal-0.11.4/khal/icalendar.py000066400000000000000000000502051477603436700157520ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """collection of icalendar helper functions""" import datetime as dt import logging from collections import defaultdict from hashlib import sha256 from typing import List, Optional, Tuple, Union import dateutil.rrule import icalendar import pytz from .exceptions import UnsupportedRecurrence from .parse_datetime import rrulefstr from .utils import generate_random_uid, localize_strip_tz, str2alarm, to_unix_time logger = logging.getLogger('khal') def split_ics(ics: str, random_uid: bool=False, default_timezone=None) -> List: """split an ics string into several according to VEVENT's UIDs and sort the right VTIMEZONEs accordingly ignores all other ics components :param random_uid: assign random uids to all events """ cal = cal_from_ics(ics) tzs = {} events_grouped = defaultdict(list) for item in cal.walk(): # Since some events could have a Windows format timezone (e.g. 'New Zealand # Standard Time' for 'Pacific/Auckland' in Olson format), we convert any # Windows format timezones to Olson. if item.name == 'VTIMEZONE': if item['TZID'] in icalendar.windows_to_olson.WINDOWS_TO_OLSON: key = icalendar.windows_to_olson.WINDOWS_TO_OLSON[item['TZID']] else: key = item['TZID'] tzs[key] = item if item.name == 'VEVENT': if 'UID' not in item: logger.warning( f"Event with summary '{item['SUMMARY']}' doesn't have a unique ID." "A generated ID will be used instead." ) item['UID'] = sha256(item.to_ical()).hexdigest() events_grouped[item['UID']].append(item) else: continue out = [] saved_exception = None for uid, events in sorted(events_grouped.items()): try: ics = ics_from_list(events, tzs, random_uid, default_timezone) except Exception as exception: logger.warn(f'Error when trying to import the event {uid}') saved_exception = exception else: out.append(ics) if saved_exception: raise saved_exception return out def new_vevent(locale, dtstart: dt.date, dtend: dt.date, summary: str, timezone: Optional[pytz.BaseTzInfo]=None, allday: bool=False, description: Optional[str]=None, location: Optional[str]=None, categories: Optional[Union[List[str], str]]=None, repeat: Optional[str]=None, until=None, alarms: Optional[str]=None, url: Optional[str]=None, ) -> icalendar.Event: """create a new event :param dtstart: starttime of that event :param dtend: end time of that event, if this is a *date*, this value is interpreted as being the last date the event is scheduled on, i.e. the VEVENT DTEND will be *one day later* :param summary: description of the event, used in the SUMMARY property :param timezone: timezone of the event (start and end) :param allday: if set to True, we will not transform dtstart and dtend to datetime :param url: url of the event :returns: event """ if not allday and timezone is not None: assert isinstance(dtstart, dt.datetime) assert isinstance(dtend, dt.datetime) dtstart = timezone.localize(dtstart) dtend = timezone.localize(dtend) event = icalendar.Event() event.add('dtstart', dtstart) event.add('dtend', dtend) event.add('dtstamp', dt.datetime.now()) event.add('summary', summary) event.add('uid', generate_random_uid()) # event.add('sequence', 0) if description: event.add('description', description) if location: event.add('location', location) if categories: event.add('categories', categories) if url: event.add('url', icalendar.vUri(url)) if repeat and repeat != "none": rrule = rrulefstr(repeat, until, locale, getattr(dtstart, 'tzinfo', None)) event.add('rrule', rrule) if alarms: for alarm in str2alarm(alarms, description or ''): event.add_component(alarm) return event def ics_from_list( events: List[icalendar.Event], tzs, random_uid: bool=False, default_timezone=None ) -> str: """convert an iterable of icalendar.Events to an icalendar str :params events: list of events all with the same uid :param random_uid: assign random uids to all events :param tzs: collection of timezones :type tzs: dict(icalendar.cal.Vtimzone """ calendar = icalendar.Calendar() calendar.add('version', '2.0') calendar.add( 'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN' ) if random_uid: new_uid = generate_random_uid() needed_tz, missing_tz = set(), set() for sub_event in events: sub_event = sanitize(sub_event, default_timezone=default_timezone) if random_uid: sub_event['UID'] = new_uid # icalendar round-trip converts `TZID=a b` to `TZID="a b"` investigate, file bug XXX for prop in ['DTSTART', 'DTEND', 'DUE', 'EXDATE', 'RDATE', 'RECURRENCE-ID', 'DUE']: if isinstance(sub_event.get(prop), list): items = sub_event.get(prop) else: items = [sub_event.get(prop)] for item in items: if not (hasattr(item, 'dt') or hasattr(item, 'dts')): continue # if prop is a list, all items have the same parameters datetime_ = item.dts[0].dt if hasattr(item, 'dts') else item.dt if not hasattr(datetime_, 'tzinfo'): continue # check for datetimes' timezones which are not understood by # icalendar if datetime_.tzinfo is None and 'TZID' in item.params and \ item.params['TZID'] not in missing_tz: logger.warning( f"Cannot find timezone `{item.params['TZID']}` in .ics file, " "using default timezone. This can lead to erroneous time shifts" ) missing_tz.add(item.params['TZID']) elif datetime_.tzinfo and datetime_.tzinfo != pytz.UTC and \ datetime_.tzinfo not in needed_tz: needed_tz.add(datetime_.tzinfo) for tzid in needed_tz: if str(tzid) in tzs: calendar.add_component(tzs[str(tzid)]) else: logger.warning( f'Cannot find timezone `{tzid}` in .ics file, this could be a bug, ' 'please report this issue at http://github.com/pimutils/khal/.') for sub_event in events: calendar.add_component(sub_event) return calendar.to_ical().decode('utf-8') def expand( vevent: icalendar.Event, href: str='', ) -> Optional[List[Tuple[dt.datetime, dt.datetime]]]: """ Constructs a list of start and end dates for all recurring instances of the event defined in vevent. It considers RRULE as well as RDATE and EXDATE properties. In case of unsupported recursion rules an UnsupportedRecurrence exception is thrown. If the vevent contains a RECURRENCE-ID property, no expansion is done, the function still returns a tuple of start and end (date)times. :param vevent: vevent to be expanded :param href: the href of the vevent, used for more informative logging and nothing else :returns: list of start and end (date)times of the expanded event """ # we do this now and than never care about the "real" end time again if 'DURATION' in vevent: duration = vevent['DURATION'].dt else: duration = vevent['DTEND'].dt - vevent['DTSTART'].dt # if this vevent has a RECURRENCE_ID property, no expansion will be # performed expand = not bool(vevent.get('RECURRENCE-ID')) events_tz = getattr(vevent['DTSTART'].dt, 'tzinfo', None) allday = not isinstance(vevent['DTSTART'].dt, dt.datetime) def sanitize_datetime(date: dt.date) -> dt.date: if allday and isinstance(date, dt.datetime): date = date.date() if events_tz is not None: date = events_tz.localize(date) return date rrule_param = vevent.get('RRULE') if expand and rrule_param is not None: vevent = sanitize_rrule(vevent) # dst causes problem while expanding the rrule, therefore we transform # everything to naive datetime objects and transform back after # expanding # See https://github.com/dateutil/dateutil/issues/102 dtstart = vevent['DTSTART'].dt if events_tz: dtstart = dtstart.replace(tzinfo=None) rrule = dateutil.rrule.rrulestr( rrule_param.to_ical().decode(), dtstart=dtstart, ignoretz=True, ) # telling mypy, that _until exists # we are very sure (TM) that rrulestr always returns a rrule, not a # rruleset (which wouldn't have a _until attribute) if rrule._until is None: # type: ignore # rrule really doesn't like to calculate all recurrences until # eternity, so we only do it until 2037, because a) I'm not sure # if python can deal with larger datetime values yet and b) pytz # doesn't know any larger transition times rrule._until = dt.datetime(2037, 12, 31) # type: ignore else: if events_tz and 'Z' in rrule_param.to_ical().decode(): assert isinstance(rrule._until, dt.datetime) # type: ignore rrule._until = pytz.UTC.localize( # type: ignore rrule._until).astimezone(events_tz).replace(tzinfo=None) # type: ignore # rrule._until and dtstart could be dt.date or dt.datetime. They # need to be the same for comparison testuntil = rrule._until # type: ignore if (type(dtstart) == dt.date and type(testuntil) == dt.datetime): testuntil = testuntil.date() teststart = dtstart if (type(testuntil) == dt.date and type(teststart) == dt.datetime): teststart = teststart.date() if testuntil < teststart: logger.warning( f'{href}: Unsupported recurrence. UNTIL is before DTSTART.\n' 'This event will not be available in khal.') return None if rrule.count() == 0: logger.warning( f'{href}: Recurrence defined but will never occur.\n' 'This event will not be available in khal.') return None rrule = map(sanitize_datetime, rrule) # type: ignore logger.debug(f'calculating recurrence dates for {href}, this might take some time.') # RRULE and RDATE may specify the same date twice, it is recommended by # the RFC to consider this as only one instance dtstartl = set(rrule) if not dtstartl: raise UnsupportedRecurrence() else: dtstartl = {vevent['DTSTART'].dt} def get_dates(vevent, key): # TODO replace with get_all_properties dates = vevent.get(key) if dates is None: return if not isinstance(dates, list): dates = [dates] dates = (leaf.dt for tree in dates for leaf in tree.dts) dates = localize_strip_tz(dates, events_tz) return map(sanitize_datetime, dates) # include explicitly specified recursion dates if expand: dtstartl.update(get_dates(vevent, 'RDATE') or ()) # remove excluded dates if expand: for date in get_dates(vevent, 'EXDATE') or (): try: dtstartl.remove(date) except KeyError: logger.warning( f'In event {href}, excluded instance starting at {date} ' 'not found, event might be invalid.') dtstartend = [(start, start + duration) for start in dtstartl] # not necessary, but I prefer deterministic output dtstartend.sort() return dtstartend def assert_only_one_uid(cal: icalendar.Calendar): """assert that all VEVENTs in cal have the same UID""" uids = set() for item in cal.walk(): if item.name == 'VEVENT': uids.add(item['UID']) if len(uids) > 1: return False else: return True def sanitize( vevent: icalendar.Event, default_timezone: pytz.BaseTzInfo, href: str='', calendar: str='', ) -> icalendar.Event: """ clean up vevents we do not understand :param vevent: the vevent that needs to be cleaned :param default_timezone: timezone to apply to start and/or end dates which were supposed to be localized but which timezone was not understood by icalendar :param href: used for logging to inform user which .ics files are problematic :param calendar: used for logging to inform user which .ics files are problematic :returns: clean vevent """ # convert localized datetimes with timezone information we don't # understand to the default timezone # TODO do this for everything where a TZID can appear (RDATE, EXDATE) for prop in ['DTSTART', 'DTEND', 'DUE', 'RECURRENCE-ID']: if prop in vevent and invalid_timezone(vevent[prop]): timezone = vevent[prop].params.get('TZID') value = default_timezone.localize(vevent.pop(prop).dt) vevent.add(prop, value) logger.warning( f"{prop} localized in invalid or incomprehensible timezone " f"`{timezone}` in {calendar}/{href}. This could lead to this " "event being wrongly displayed." ) vdtstart = vevent.pop('DTSTART', None) vdtend = vevent.pop('DTEND', None) dtstart = getattr(vdtstart, 'dt', None) dtend = getattr(vdtend, 'dt', None) # event with missing DTSTART if dtstart is None: raise ValueError('Event has no start time (DTSTART).') dtstart, dtend = sanitize_timerange( dtstart, dtend, duration=vevent.get('DURATION', None)) vevent.add('DTSTART', dtstart) if dtend is not None: vevent.add('DTEND', dtend) return vevent def sanitize_timerange(dtstart, dtend, duration=None): '''return sensible dtstart and end for events that have an invalid or missing DTEND, assuming the event just lasts one hour.''' if isinstance(dtstart, dt.datetime) and isinstance(dtend, dt.datetime): if dtstart.tzinfo and not dtend.tzinfo: logger.warning( "Event end time has no timezone. " "Assuming it's the same timezone as the start time" ) dtend = dtstart.tzinfo.localize(dtend) if not dtstart.tzinfo and dtend.tzinfo: logger.warning( "Event start time has no timezone. " "Assuming it's the same timezone as the end time" ) dtstart = dtend.tzinfo.localize(dtstart) if dtend is not None and type(dtstart) != type(dtend): raise ValueError( 'The event\'s end time (DTEND) and start time (DTSTART) are not of the same type.') if dtend is None and duration is None: if isinstance(dtstart, dt.datetime): dtend = dtstart + dt.timedelta(hours=1) else: dtend = dtstart + dt.timedelta(days=1) elif dtend is not None: if dtend < dtstart: raise ValueError('The event\'s end time (DTEND) is older than ' 'the event\'s start time (DTSTART).') elif dtend == dtstart: logger.warning( "Event start time and end time are the same. " "Assuming the event's duration is one hour." ) if isinstance(dtstart, dt.datetime): dtend += dt.timedelta(hours=1) else: dtend += dt.timedelta(days=1) return dtstart, dtend def sanitize_rrule(vevent): """fix problems with RRULE:UNTIL""" if 'rrule' in vevent and 'UNTIL' in vevent['rrule']: until = vevent['rrule']['UNTIL'][0] dtstart = vevent['dtstart'].dt # DTSTART is date, UNTIL is datetime if not isinstance(dtstart, dt.datetime) and isinstance(until, dt.datetime): vevent['rrule']['until'] = until.date() return vevent def invalid_timezone(prop): """check if an icalendar property has a timezone attached we don't understand""" if hasattr(prop.dt, 'tzinfo') and prop.dt.tzinfo is None and 'TZID' in prop.params: return True else: return False def _get_all_properties(vevent, prop): """Get all properties from a vevent, even if there are several entries example input: EXDATE:1234,4567 EXDATE:7890 returns: [1234, 4567, 7890] :type vevent: icalendar.cal.Event :type prop: str """ if prop not in vevent: return [] if isinstance(vevent[prop], list): rdates = [leaf.dt for tree in vevent[prop] for leaf in tree.dts] else: rdates = [vddd.dt for vddd in vevent[prop].dts] return rdates def delete_instance(vevent: icalendar.Event, instance: dt.datetime) -> None: """remove a recurrence instance from a VEVENT's RRDATE list or add it to the EXDATE list """ # TODO check where this instance is coming from and only call the # appropriate function if 'RRULE' in vevent: exdates = _get_all_properties(vevent, 'EXDATE') exdates += [instance] vevent.pop('EXDATE') vevent.add('EXDATE', exdates) if 'RDATE' in vevent: rdates = [one for one in _get_all_properties(vevent, 'RDATE') if one != instance] vevent.pop('RDATE') if rdates != []: vevent.add('RDATE', rdates) def sort_key(vevent: icalendar.Event) -> Tuple[str, float]: """helper function to determine order of VEVENTS so that recurrence-id events come after the corresponding rrule event, etc :param vevent: icalendar.Event :rtype: tuple(str, int) """ assert isinstance(vevent, icalendar.Event) uid = str(vevent['UID']) rec_id = vevent.get('RECURRENCE-ID') if rec_id is None: return uid, 0 rrange = rec_id.params.get('RANGE') if rrange == 'THISANDFUTURE': return uid, to_unix_time(rec_id.dt) else: return uid, 1 def cal_from_ics(ics: str) -> icalendar.Calendar: """ :param ics: an icalendar formatted string """ try: cal = icalendar.Calendar.from_ical(ics) except ValueError as error: if (len(error.args) > 0 and isinstance(error.args[0], str) and error.args[0].startswith('Offset must be less than 24 hours')): logger.warning( 'Invalid timezone offset encountered, ' 'timezone information may be wrong: ' + str(error.args[0]) ) icalendar.vUTCOffset.ignore_exceptions = True cal = icalendar.Calendar.from_ical(ics) icalendar.vUTCOffset.ignore_exceptions = False else: raise return cal khal-0.11.4/khal/khalendar/000077500000000000000000000000001477603436700154055ustar00rootroot00000000000000khal-0.11.4/khal/khalendar/__init__.py000066400000000000000000000022301477603436700175130ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from .khalendar import CalendarCollection # noqa: F401 # type: ignore khal-0.11.4/khal/khalendar/backend.py000066400000000000000000000722101477603436700173500ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ The SQLite backend implementation. """ import contextlib import datetime as dt import logging import sqlite3 from enum import IntEnum from os import makedirs, path from typing import Any, Iterable, Iterator, List, Optional, Tuple, Union import icalendar import icalendar.cal import pytz from dateutil import parser from .. import utils from ..custom_types import EventTuple, LocaleConfiguration from ..icalendar import assert_only_one_uid, cal_from_ics from ..icalendar import expand as expand_vevent from ..icalendar import sanitize as sanitize_vevent from ..icalendar import sort_key as sort_vevent_key from .exceptions import CouldNotCreateDbDir, NonUniqueUID, OutdatedDbVersionError, UpdateFailed logger = logging.getLogger('khal') DB_VERSION = 5 # The current db layout version RECURRENCE_ID = 'RECURRENCE-ID' THISANDFUTURE = 'THISANDFUTURE' THISANDPRIOR = 'THISANDPRIOR' PROTO = 'PROTO' class EventType(IntEnum): DATE = 0 DATETIME = 1 class SQLiteDb: """ This class should provide a caching database for a calendar, keeping raw vevents in one table and allowing to retrieve them by dates (via the help of some auxiliary tables) :param calendar: names of calendars to select from, those are used as additional itentifiers together with event's uids. Each (uid, calendar) combination should be unique. :param db_path: path where this sqlite database will be saved, if this is None, a place according to the XDG specifications will be chosen """ def __init__(self, calendars: Iterable[str], db_path: Optional[str], locale: LocaleConfiguration, ) -> None: assert db_path is not None self.calendars: List[str] = list(calendars) self.db_path = path.expanduser(db_path) self._create_dbdir() self.locale = locale self._at_once: bool = False self.conn = sqlite3.connect(self.db_path) self.cursor = self.conn.cursor() self._create_default_tables() self._check_calendars_exists() self._check_table_version() @contextlib.contextmanager def at_once(self) -> Iterator['SQLiteDb']: assert not self._at_once self._at_once = True try: yield self except: # noqa raise else: self.conn.commit() finally: self._at_once = False def _create_dbdir(self) -> None: """create the dbdir if it doesn't exist""" if self.db_path == ':memory:': return None dbdir = self.db_path.rsplit('/', 1)[0] if not path.isdir(dbdir): try: logger.debug('trying to create the directory for the db') makedirs(dbdir, mode=0o770) logger.debug('success') except OSError as error: logger.critical(f'failed to create {dbdir}: {error}') raise CouldNotCreateDbDir() def _check_table_version(self) -> None: """tests for current db Version if the table is still empty, insert db_version """ self.cursor.execute('SELECT version FROM version') result = self.cursor.fetchone() if result is None: self.cursor.execute('INSERT INTO version (version) VALUES (?)', (DB_VERSION, )) self.conn.commit() elif not result[0] == DB_VERSION: raise OutdatedDbVersionError( str(self.db_path) + " is probably an invalid or outdated database.\n" "You should consider removing it and running khal again.") def _create_default_tables(self) -> None: """creates version and calendar tables and inserts table version number """ self.cursor.execute('CREATE TABLE IF NOT EXISTS ' 'version (version INTEGER)') logger.debug("created version table") self.cursor.execute('''CREATE TABLE IF NOT EXISTS calendars ( calendar TEXT NOT NULL UNIQUE, resource TEXT NOT NULL, ctag TEXT )''') self.cursor.execute('''CREATE TABLE IF NOT EXISTS events ( href TEXT NOT NULL, calendar TEXT NOT NULL, sequence INT, etag TEXT, item TEXT, primary key (href, calendar) );''') self.cursor.execute('''CREATE TABLE IF NOT EXISTS recs_loc ( dtstart INT NOT NULL, dtend INT NOT NULL, href TEXT NOT NULL REFERENCES events( href ), rec_inst TEXT NOT NULL, ref TEXT NOT NULL, dtype INT NOT NULL, calendar TEXT NOT NULL, primary key (href, rec_inst, calendar) );''') self.cursor.execute('''CREATE TABLE IF NOT EXISTS recs_float ( dtstart INT NOT NULL, dtend INT NOT NULL, href TEXT NOT NULL REFERENCES events( href ), rec_inst TEXT NOT NULL, ref TEXT NOT NULL, dtype INT NOT NULL, calendar TEXT NOT NULL, primary key (href, rec_inst, calendar) );''') self.conn.commit() def _check_calendars_exists(self) -> None: """make sure an entry for the current calendar exists in `calendar` table """ for cal in self.calendars: self.cursor.execute('''SELECT count(*) FROM calendars WHERE calendar = ?;''', (cal,)) result = self.cursor.fetchone() if result[0] != 0: logger.debug(f"tables for calendar {cal} exist") else: sql_s = 'INSERT INTO calendars (calendar, resource) VALUES (?, ?);' stuple = (cal, '') self.sql_ex(sql_s, stuple) def sql_ex(self, statement: str, stuple: tuple) -> List: """wrapper for sql statements, does a "fetchall" """ self.cursor.execute(statement, stuple) result = self.cursor.fetchall() if not self._at_once: self.conn.commit() return result def update(self, vevent_str: str, href: str, etag: str='', calendar: Optional[str]=None, ) -> None: """insert a new or update an existing event into the db This is mostly a wrapper around two SQL statements, doing some cleanup before. :param vevent_str: event to be inserted or updated. We assume that even if it contains more than one VEVENT, that they are all part of the same event and all have the same UID :param href: href of the card on the server, if this href already exists in the db the card gets updated. If no href is given, a random href is chosen and it is implied that this card does not yet exist on the server, but will be uploaded there on next sync. :param etag: the etag of the vcard, if this etag does not match the remote etag on next sync, this card will be updated from the server. For locally created vcards this should not be set """ assert calendar is not None assert href is not None ical = cal_from_ics(vevent_str) check_for_errors(ical, calendar, href) if not assert_only_one_uid(ical): logger.warning( f"The .ics file at {calendar}/{href} contains multiple UIDs.\n" "This should not occur in vdir .ics files.\n" "If you didn't edit the file by hand, please report a bug " "at https://github.com/pimutils/khal/issues .\n" "If you want to import it, please use `khal import FILE`." ) raise NonUniqueUID vevents = (sanitize_vevent(c, self.locale['default_timezone'], href, calendar) for c in ical.walk() if c.name == 'VEVENT') # Need to delete the whole event in case we are updating a # recurring event with an event which is either not recurring any # more or has EXDATEs, as those would be left in the recursion # tables. There are obviously better ways to achieve the same # result. self.delete(href, calendar=calendar) for vevent in sorted(vevents, key=sort_vevent_key): check_for_errors(vevent, calendar, href) check_support(vevent, href, calendar) self._update_impl(vevent, href, calendar) sql_s = ('INSERT INTO events (item, etag, href, calendar) VALUES (?, ?, ?, ?);') stuple = (vevent_str, etag, href, calendar) self.sql_ex(sql_s, stuple) def update_vcf_dates(self, vevent_str: str, href: str, etag: str='', calendar: Optional[str]=None) -> None: """insert events from a vcard into the db This is will parse BDAY, ANNIVERSARY, X-ANNIVERSARY and X-ABDATE fields. It will also look for any X-ABLABEL fields associated with an X-ABDATE and use that in the event description. :param vevent_str: contact (vcard) to be parsed. :param href: href of the card on the server, if this href already exists in the db the card gets updated. If no href is given, a random href is chosen and it is implied that this card does not yet exist on the server, but will be uploaded there on next sync. :param etag: the etag of the vcard, if this etag does not match the remote etag on next sync, this card will be updated from the server. For locally created vcards this should not be set """ assert calendar is not None assert href is not None # Delete all event entries for this contact self.deletelike(href + '%', calendar=calendar) ical = cal_from_ics(vevent_str) vcard = ical.walk()[0] for key in vcard.keys(): if key in ['BDAY', 'X-ANNIVERSARY', 'ANNIVERSARY'] or key.endswith('X-ABDATE'): date = vcard[key] uuid = vcard.get('UID') if isinstance(date, list): logger.warning( f'Vcard {href} in collection {calendar} has more than one ' f'{key}, will be skipped and not be available in khal.' ) continue try: if date[0:2] == '--' and date[3] != '-': date = '1900' + date[2:] orig_date = False else: orig_date = True date = parser.parse(date).date() except ValueError: logger.warning( f'cannot parse {key} in {href} in collection {calendar}') continue if 'FN' in vcard: name = vcard['FN'] else: n = vcard['N'].split(';') name = ' '.join([n[1], n[2], n[0]]) vevent = icalendar.Event() vevent.add('dtstart', date) vevent.add('dtend', date + dt.timedelta(days=1)) if date.month == 2 and date.day == 29: # leap year vevent.add('rrule', {'freq': 'YEARLY', 'BYYEARDAY': 60}) else: vevent.add('rrule', {'freq': 'YEARLY'}) description = get_vcard_event_description(vcard, key) if orig_date: if key == 'BDAY': xtag = 'x-birthday' elif key.endswith('ANNIVERSARY'): xtag = 'x-anniversary' else: xtag = 'x-abdate' vevent.add('x-ablabel', description) vevent.add(xtag, f'{date.year:04}{date.month:02}{date.day:02}') vevent.add('x-fname', name) vevent.add('summary', f'{name}\'s {description}') vevent.add('uid', href + key) vevent_str = vevent.to_ical().decode('utf-8') self._update_impl(vevent, href + key, calendar) sql_s = ('INSERT INTO events (item, etag, href, calendar)' ' VALUES (?, ?, ?, ?);') stuple = (vevent_str, etag, href + key, calendar) try: self.sql_ex(sql_s, stuple) except sqlite3.IntegrityError as error: raise UpdateFailed('Database integrity error creating birthday event ' f'on {date} for contact {name} (UID: {uuid}): ' f'{error}') def _update_impl(self, vevent: icalendar.cal.Event, href: str, calendar: str) -> None: """insert `vevent` into the database expand `vevent`'s recurrence rules (if needed) and insert all instance in the respective tables than insert non-recurring and original recurring (those with an RRULE property) events into table `events` """ # TODO FIXME this function is a steaming pile of shit rec_id = vevent.get(RECURRENCE_ID) if rec_id is None: rrange = None else: rrange = rec_id.params.get('RANGE') # testing on datetime.date won't work as datetime is a child of date if not isinstance(vevent['DTSTART'].dt, dt.datetime): dtype = EventType.DATE else: dtype = EventType.DATETIME if ('TZID' in vevent['DTSTART'].params and dtype == EventType.DATETIME) or \ getattr(vevent['DTSTART'].dt, 'tzinfo', None): recs_table = 'recs_loc' else: recs_table = 'recs_float' thisandfuture = (rrange == THISANDFUTURE) if thisandfuture: start_shift, duration = calc_shift_deltas(vevent) start_shift_seconds = start_shift.days * 3600 * 24 + start_shift.seconds duration_seconds = duration.days * 3600 * 24 + duration.seconds dtstartend = expand_vevent(vevent, href) if not dtstartend: # Does this event even have dates? Technically it is possible for # events to be empty/non-existent by deleting all their recurrences # through EXDATE. return for dtstart, dtend in dtstartend: if dtype == EventType.DATE: dbstart = utils.to_unix_time(dtstart) dbend = utils.to_unix_time(dtend) else: dbstart = utils.to_unix_time(dtstart) dbend = utils.to_unix_time(dtend) if rec_id is not None: ref = rec_inst = str(utils.to_unix_time(rec_id.dt)) else: rec_inst = str(dbstart) ref = PROTO if thisandfuture: recs_sql_s = ( f'UPDATE {recs_table} SET dtstart = rec_inst + ?, dtend = rec_inst + ?, ' 'ref = ? WHERE rec_inst >= ? AND href = ? AND calendar = ?;') stuple_f = ( start_shift_seconds, start_shift_seconds + duration_seconds, ref, rec_inst, href, calendar, ) self.sql_ex(recs_sql_s, stuple_f) else: recs_sql_s = ( f'INSERT OR REPLACE INTO {recs_table} ' '(dtstart, dtend, href, ref, dtype, rec_inst, calendar)' 'VALUES (?, ?, ?, ?, ?, ?, ?);') stuple_n = (dbstart, dbend, href, ref, dtype, rec_inst, calendar) self.sql_ex(recs_sql_s, stuple_n) def get_ctag(self, calendar: str) -> Optional[str]: stuple = (calendar, ) sql_s = 'SELECT ctag FROM calendars WHERE calendar = ?;' try: ctag = self.sql_ex(sql_s, stuple)[0][0] return ctag except IndexError: return None def set_ctag(self, ctag: str, calendar: str) -> None: stuple = (ctag, calendar, ) sql_s = 'UPDATE calendars SET ctag = ? WHERE calendar = ?;' self.sql_ex(sql_s, stuple) self.conn.commit() def get_etag(self, href: str, calendar: str) -> Optional[str]: """get etag for href return: etag """ sql_s = 'SELECT etag FROM events WHERE href = ? AND calendar = ?;' try: etag = self.sql_ex(sql_s, (href, calendar))[0][0] return etag except IndexError: return None def delete(self, href: str, etag: Any=None, calendar: str='') -> None: """ removes the event from the db, :param etag: only there for compatibility with vdirsyncer's Storage, we always delete """ assert calendar != '' for table in ['recs_loc', 'recs_float']: sql_s = f'DELETE FROM {table} WHERE href = ? AND calendar = ?;' self.sql_ex(sql_s, (href, calendar)) sql_s = 'DELETE FROM events WHERE href = ? AND calendar = ?;' self.sql_ex(sql_s, (href, calendar)) def deletelike(self, href: str, etag: Any=None, calendar: str='') -> None: """ removes events from the db that match an SQL 'like' statement, :param href: The pattern of hrefs to delete. May contain SQL wildcards like '%' :param etag: only there for compatibility with vdirsyncer's Storage, we always delete """ assert calendar != '' for table in ['recs_loc', 'recs_float']: sql_s = f'DELETE FROM {table} WHERE href LIKE ? AND calendar = ?;' self.sql_ex(sql_s, (href, calendar)) sql_s = 'DELETE FROM events WHERE href LIKE ? AND calendar = ?;' self.sql_ex(sql_s, (href, calendar)) def list(self, calendar: str) -> List[Tuple[str, str]]: """ list all events in `calendar` used for testing :returns: list of (href, etag) """ sql_s = 'SELECT href, etag FROM events WHERE calendar = ?;' return list(set(self.sql_ex(sql_s, (calendar, )))) def get_localized_calendars(self, start: dt.datetime, end: dt.datetime) -> Iterable[str]: assert start.tzinfo is not None assert end.tzinfo is not None start_u = utils.to_unix_time(start) end_u = utils.to_unix_time(end) sql_s = ( 'SELECT events.calendar FROM ' 'recs_loc JOIN events ON ' 'recs_loc.href = events.href AND ' 'recs_loc.calendar = events.calendar WHERE ' '(dtstart >= ? AND dtstart <= ? OR ' 'dtend > ? AND dtend <= ? OR ' 'dtstart <= ? AND dtend >= ?) AND events.calendar in ({0}) ' 'ORDER BY dtstart') stuple = tuple( [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars)) # type: ignore result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple) for calendar in result: yield calendar[0] # result is always an iterable, even if getting only one item def get_localized(self, start: dt.datetime, end: dt.datetime) -> Iterable[EventTuple]: assert start.tzinfo is not None assert end.tzinfo is not None start_timestamp = utils.to_unix_time(start) end_timestamp = utils.to_unix_time(end) sql_s = ( 'SELECT item, recs_loc.href, dtstart, dtend, ref, etag, dtype, events.calendar ' 'FROM recs_loc JOIN events ON ' 'recs_loc.href = events.href AND ' 'recs_loc.calendar = events.calendar WHERE ' '(dtstart >= ? AND dtstart <= ? OR ' 'dtend > ? AND dtend <= ? OR ' 'dtstart <= ? AND dtend >= ?) AND ' # insert as many "?" as we have configured calendars f'events.calendar in ({",".join("?" * len(self.calendars))}) ' 'ORDER BY dtstart') stuple = ( start_timestamp, end_timestamp, start_timestamp, end_timestamp, start_timestamp, end_timestamp, ) + tuple(self.calendars) result = self.sql_ex(sql_s, stuple) for item, href, start_timestamp, end_timestamp, ref, etag, _dtype, calendar in result: start = pytz.UTC.localize(dt.datetime.utcfromtimestamp(start_timestamp)) end = pytz.UTC.localize(dt.datetime.utcfromtimestamp(end_timestamp)) yield item, href, start, end, ref, etag, calendar def get_floating_calendars(self, start: dt.datetime, end: dt.datetime) -> Iterable[str]: assert start.tzinfo is None assert end.tzinfo is None start_u = utils.to_unix_time(start) end_u = utils.to_unix_time(end) sql_s = ( 'SELECT events.calendar FROM ' 'recs_float JOIN events ON ' 'recs_float.href = events.href AND ' 'recs_float.calendar = events.calendar WHERE ' '(dtstart >= ? AND dtstart < ? OR ' 'dtend > ? AND dtend <= ? OR ' 'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) ' 'ORDER BY dtstart') stuple = tuple( [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars)) # type: ignore result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple) for calendar in result: yield calendar[0] def get_floating(self, start: dt.datetime, end: dt.datetime) -> Iterable[EventTuple]: """return floating events between `start` and `end`""" assert start.tzinfo is None assert end.tzinfo is None start_dt: Union[dt.datetime, dt.date] end_dt: Union[dt.datetime, dt.date] start_u = utils.to_unix_time(start) end_u = utils.to_unix_time(end) sql_s = ( 'SELECT item, recs_float.href, dtstart, dtend, ref, etag, dtype, events.calendar ' 'FROM recs_float JOIN events ON ' 'recs_float.href = events.href AND ' 'recs_float.calendar = events.calendar WHERE ' '(dtstart >= ? AND dtstart < ? OR ' 'dtend > ? AND dtend <= ? OR ' 'dtstart <= ? AND dtend > ? ) AND events.calendar in ({0}) ' 'ORDER BY dtstart') stuple = tuple( [start_u, end_u, start_u, end_u, start_u, end_u] + list(self.calendars)) # type: ignore result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple) for item, href, start_s, end_s, ref, etag, dtype, calendar in result: start_dt = dt.datetime.utcfromtimestamp(start_s) end_dt = dt.datetime.utcfromtimestamp(end_s) if dtype == EventType.DATE: start_dt = start_dt.date() end_dt = end_dt.date() yield item, href, start_dt, end_dt, ref, etag, calendar def get(self, href: str, calendar: str) -> str: """returns the ical string matching href and calendar""" assert calendar is not None sql_s = 'SELECT item, etag FROM events WHERE href = ? AND calendar = ?;' item, etag = self.sql_ex(sql_s, (href, calendar))[0] return item def get_with_etag(self, href: str, calendar: str) -> Tuple[str, str]: """returns the ical string and its etag matching href and calendar""" assert calendar is not None sql_s = 'SELECT item, etag FROM events WHERE href = ? AND calendar = ?;' item, etag = self.sql_ex(sql_s, (href, calendar))[0] return item, etag def search(self, search_string: str) \ -> Iterable[Tuple[str, str, dt.date, dt.date, str, str, str]]: """search for events matching `search_string`""" sql_s = ( 'SELECT item, recs_loc.href, dtstart, dtend, ref, etag, dtype, events.calendar ' 'FROM recs_loc JOIN events ON ' 'recs_loc.href = events.href AND ' 'recs_loc.calendar = events.calendar ' 'WHERE item LIKE (?) and events.calendar in ({0});' ) stuple = tuple([f'%{search_string}%'] + list(self.calendars)) result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple) for item, href, start, end, ref, etag, dtype, calendar in result: start = pytz.UTC.localize(dt.datetime.utcfromtimestamp(start)) end = pytz.UTC.localize(dt.datetime.utcfromtimestamp(end)) if dtype == EventType.DATE: start = start.date() end = end.date() yield item, href, start, end, ref, etag, calendar sql_s = ( 'SELECT item, recs_float.href, dtstart, dtend, ref, etag, dtype, events.calendar ' 'FROM recs_float JOIN events ON ' 'recs_float.href = events.href AND ' 'recs_float.calendar = events.calendar ' 'WHERE item LIKE (?) and events.calendar in ({0});' ) stuple = tuple([f'%{search_string}%'] + list(self.calendars)) result = self.sql_ex(sql_s.format(','.join(["?"] * len(self.calendars))), stuple) for item, href, start, end, ref, etag, dtype, calendar in result: start = dt.datetime.utcfromtimestamp(start) end = dt.datetime.utcfromtimestamp(end) if dtype == EventType.DATE: start = start.date() end = end.date() yield item, href, start, end, ref, etag, calendar def check_support(vevent: icalendar.cal.Event, href: str, calendar: str) -> None: """test if all icalendar features used in this event are supported, raise `UpdateFailed` otherwise. :param vevent: event to test :param href: href of this event, only used for logging """ rec_id = vevent.get(RECURRENCE_ID) if rec_id is not None and rec_id.params.get('RANGE') == THISANDPRIOR: raise UpdateFailed( 'The parameter `THISANDPRIOR` is not (and will not be) ' 'supported by khal (as applications supporting the latest ' f'standard MUST NOT create those. Therefore event {href} from ' f'calendar {calendar} will not be shown in khal' ) rdate = vevent.get('RDATE') if rdate is not None and hasattr(rdate, 'params') and rdate.params.get('VALUE') == 'PERIOD': raise UpdateFailed( '`RDATE;VALUE=PERIOD` is currently not supported by khal. ' f'Therefore event {href} from calendar {calendar} will not be shown in khal.\n' 'Please post exemplary events (please remove any private data) ' 'to https://github.com/pimutils/khal/issues/152 .' ) def check_for_errors(component: icalendar.cal.Component, calendar: str, href: str) -> None: """checking if component.errors exists, is not empty and if so warn the user""" if hasattr(component, 'errors') and component.errors: logger.error( f'Errors occurred when parsing {calendar}/{href} for ' 'the following reasons:') for error in component.errors: logger.error(error) logger.error('This might lead to this event being shown wrongly or not at all.') def calc_shift_deltas(vevent: icalendar.Event) -> Tuple[dt.timedelta, dt.timedelta]: """calculate an event's duration and by how much its start time has shifted versus its recurrence-id time :param event: an event with a RECURRENCE-ID property """ assert isinstance(vevent, icalendar.Event) # REMOVE ME start_shift = vevent['DTSTART'].dt - vevent['RECURRENCE-ID'].dt try: duration = vevent['DTEND'].dt - vevent['DTSTART'].dt except KeyError: duration = vevent['DURATION'].dt return start_shift, duration def get_vcard_event_description(vcard: icalendar.cal.Component, key: str) -> str: if key == 'BDAY': return 'birthday' elif key.endswith('ANNIVERSARY'): return 'anniversary' elif key.endswith('X-ABDATE'): desc_key = key[:-8] + 'X-ABLABEL' if desc_key in vcard.keys(): return vcard[desc_key] else: desc_key = key[:-8] + 'X-ABLabel' if desc_key in vcard.keys(): return vcard[desc_key] else: return 'custom event from vcard' else: return 'unknown event from vcard' khal-0.11.4/khal/khalendar/event.py000066400000000000000000001146261477603436700171120ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """This module contains the event model with all relevant subclasses and some helper functions.""" import datetime as dt import logging import os from typing import Callable, Dict, List, Optional, Tuple, Type, Union import icalendar import icalendar.cal import icalendar.prop import pytz from click import style from pytz.tzinfo import StaticTzInfo from ..custom_types import LocaleConfiguration from ..exceptions import FatalError from ..icalendar import cal_from_ics, delete_instance, invalid_timezone from ..parse_datetime import timedelta2str from ..plugins import FORMATTERS from ..utils import generate_random_uid, is_aware, to_naive_utc, to_unix_time logger = logging.getLogger('khal') class Event: """base Event class for representing a *recurring instance* of an Event (in case of non-recurring events this distinction is irrelevant) We keep a copy of this instances start and end time around, because for recurring events it might be costly to expand the recursion rules important distinction for AllDayEvents: all end times are as presented to a user, i.e. an event scheduled for only one day will have the same start and end date (even though the icalendar standard would have the end date be one day later) """ allday: bool = False def __init__(self, vevents: Dict[str, icalendar.Event], locale: LocaleConfiguration, ref: Optional[str] = None, readonly: bool = False, href: Optional[str] = None, etag: Optional[str] = None, calendar: Optional[str] = None, color: Optional[str] = None, start: Optional[dt.datetime] = None, end: Optional[dt.datetime] = None, addresses: Optional[List[str]] =None, ): """ :param start: start datetime of this event instance :param end: end datetime of this event instance """ if self.__class__.__name__ == 'Event': raise ValueError('do not initialize this class directly') if ref is None: raise ValueError('ref should not be None') self._vevents = vevents self.ref = ref self._locale = locale self.readonly = readonly self.href = href self.etag = etag self.calendar = calendar if calendar else '' self.color = color self._start: dt.datetime self._end: dt.datetime self.addresses = addresses if addresses else [] if start is None: self._start = self._vevents[self.ref]['DTSTART'].dt else: self._start = start if end is None: try: self._end = self._vevents[self.ref]['DTEND'].dt except KeyError: try: self._end = self._start + self._vevents[self.ref]['DURATION'].dt except KeyError: self._end = self._start + dt.timedelta(days=1) else: self._end = end @classmethod def _get_type_from_vDDD(cls, start: icalendar.prop.vDDDTypes) -> type: """infere the type of the class from the START type of the event""" if not isinstance(start.dt, dt.datetime): return AllDayEvent if 'TZID' in start.params or start.dt.tzinfo is not None: return LocalizedEvent return FloatingEvent @classmethod def _get_type_from_date(cls, start: dt.datetime) -> Type['Event']: if hasattr(start, 'tzinfo') and start.tzinfo is not None: cls = LocalizedEvent elif isinstance(start, dt.datetime): cls = FloatingEvent elif isinstance(start, dt.date): cls = AllDayEvent return cls @classmethod def fromVEvents(cls, events_list: List[icalendar.Event], ref: Optional[str]=None, start: Optional[dt.datetime]=None, **kwargs) -> 'Event': assert isinstance(events_list, list) vevents = {} for event in events_list: if 'RECURRENCE-ID' in event: if invalid_timezone(event['RECURRENCE-ID']): default_timezone = kwargs['locale']['default_timezone'] recur_id = default_timezone.localize(event['RECURRENCE-ID'].dt) ident = str(to_unix_time(recur_id)) else: ident = str(to_unix_time(event['RECURRENCE-ID'].dt)) vevents[ident] = event else: vevents['PROTO'] = event if ref is None: ref = 'PROTO' if ref in vevents.keys() else list(vevents.keys())[0] try: if type(vevents[ref]['DTSTART'].dt) != type(vevents[ref]['DTEND'].dt): # noqa: E721 raise ValueError('DTSTART and DTEND should be of the same type (datetime or date)') except KeyError: pass if start: instcls = cls._get_type_from_date(start) else: instcls = cls._get_type_from_vDDD(vevents[ref]['DTSTART']) return instcls(vevents, ref=ref, start=start, **kwargs) @classmethod def fromString(cls, ics: str, ref=None, **kwargs) -> 'Event': calendar_collection = cal_from_ics(ics) events = [item for item in calendar_collection.walk() if item.name == 'VEVENT'] return cls.fromVEvents(events, ref, **kwargs) def __lt__(self, other: 'Event') -> bool: start = self.start_local other_start = other.start_local if isinstance(start, dt.date) and not isinstance(start, dt.datetime): start = dt.datetime.combine(start, dt.time.min) if isinstance(other_start, dt.date) and not isinstance(other_start, dt.datetime): other_start = dt.datetime.combine(other_start, dt.time.min) start = start.replace(tzinfo=None) other_start = other_start.replace(tzinfo=None) if start == other_start: end = self.end_local other_end = other.end_local if isinstance(end, dt.date) and not isinstance(end, dt.datetime): end = dt.datetime.combine(end, dt.time.min) if isinstance(other_end, dt.date) and not isinstance(other_end, dt.datetime): other_end = dt.datetime.combine(other_end, dt.time.min) end = end.replace(tzinfo=None) other_end = other_end.replace(tzinfo=None) if end == other_end: return self.summary < other.summary try: return end < other_end except TypeError: raise ValueError(f'Cannot compare events {end} and {other_end}') try: return start < other_start except TypeError: raise ValueError(f'Cannot compare events {start} and {other_start}') def update_start_end(self, start: dt.datetime, end: dt.datetime) -> None: """update start and end time of this event calling this on a recurring event will lead to the proto instance be set to the new start and end times beware, this methods performs some open heart surgery """ if type(start) != type(end): raise ValueError('DTSTART and DTEND should be of the same type (datetime or date)') self.__class__ = self._get_type_from_date(start) self._vevents[self.ref].pop('DTSTART') self._vevents[self.ref].add('DTSTART', start) self._start = start if not isinstance(end, dt.datetime): end = end + dt.timedelta(days=1) self._end = end if 'DTEND' in self._vevents[self.ref]: self._vevents[self.ref].pop('DTEND') self._vevents[self.ref].add('DTEND', end) else: self._vevents[self.ref].pop('DURATION') self._vevents[self.ref].add('DURATION', end - start) @property def recurring(self) -> bool: try: rval = 'RRULE' in self._vevents[self.ref] or \ 'RECURRENCE-ID' in self._vevents[self.ref] or \ 'RDATE' in self._vevents[self.ref] except KeyError: logger.fatal( f"The event at {self.href} might be broken. You might want to " "file an issue at https://github.com/pimutils/khal/issues" ) raise else: return rval @property def recurpattern(self) -> str: if 'RRULE' in self._vevents[self.ref]: return self._vevents[self.ref]['RRULE'].to_ical().decode('utf-8') else: return '' @property def recurobject(self) -> icalendar.vRecur: if 'RRULE' in self._vevents[self.ref]: return self._vevents[self.ref]['RRULE'] else: return icalendar.vRecur() def update_rrule(self, rrule: str) -> None: self._vevents['PROTO'].pop('RRULE') if rrule is not None: self._vevents['PROTO'].add('RRULE', rrule) @property def recurrence_id(self) -> Union[dt.datetime, str]: """return the "original" start date of this event (i.e. their recurrence-id) """ if self.ref == 'PROTO': return self.start else: return pytz.UTC.localize(dt.datetime.utcfromtimestamp(int(self.ref))) def increment_sequence(self) -> None: """update the SEQUENCE number, call before saving this event""" # TODO we might want to do this automatically in raw() everytime # the event has changed, this will f*ck up the tests though try: self._vevents[self.ref]['SEQUENCE'] += 1 except KeyError: self._vevents[self.ref]['SEQUENCE'] = 0 @property def symbol_strings(self) -> Dict[str, str]: if self._locale['unicode_symbols']: return { 'recurring': '\N{Clockwise gapped circle arrow}', 'alarming': '\N{Alarm clock}', 'range': '\N{Left right arrow}', 'range_end': '\N{Rightwards arrow to bar}', 'range_start': '\N{Rightwards arrow from bar}', 'right_arrow': '\N{Rightwards arrow}', 'cancelled': '\N{Cross mark}', 'confirmed': '\N{Heavy check mark}', 'tentative': '?', 'declined': '\N{Cross mark}', 'accepted': '\N{Heavy check mark}', } else: return { 'recurring': '(R)', 'alarming': '(A)', 'range': '<->', 'range_end': '->|', 'range_start': '|->', 'right_arrow': '->', 'cancelled': 'X', 'confirmed': 'V', 'tentative': '?', 'declined': 'X', 'accepted': 'V', } @property def start_local(self) -> dt.datetime: """self.start() localized to local timezone""" return self.start @property def end_local(self) -> dt.datetime: """self.end() localized to local timezone""" return self.end @property def start(self) -> dt.datetime: """this should return the start date(time) as saved in the event""" return self._start @property def end(self) -> dt.datetime: """this should return the end date(time) as saved in the event or implicitly defined by start and duration""" return self._end @property def duration(self) -> dt.timedelta: try: return self._vevents[self.ref]['DURATION'].dt except KeyError: return self.end - self.start @property def uid(self) -> str: return self._vevents[self.ref]['UID'] @property def organizer(self) -> str: if 'ORGANIZER' not in self._vevents[self.ref]: return '' organizer = self._vevents[self.ref]['ORGANIZER'] cn = organizer.params.get('CN', '') email = organizer.split(':')[-1] if cn: return f'{cn} ({email})' else: return email @property def url(self) -> str: if 'URL' not in self._vevents[self.ref]: return '' return self._vevents[self.ref]['URL'] def update_url(self, url: str) -> None: if url: self._vevents[self.ref]['URL'] = url else: self._vevents[self.ref].pop('URL') @staticmethod def _create_calendar() -> icalendar.Calendar: """create the calendar""" calendar = icalendar.Calendar() calendar.add('version', '2.0') calendar.add( 'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN' ) return calendar @property def raw(self) -> str: """Creates a VCALENDAR containing VTIMEZONEs """ calendar = self._create_calendar() tzs = [] for vevent in self._vevents.values(): if hasattr(vevent['DTSTART'].dt, 'tzinfo') and vevent['DTSTART'].dt.tzinfo is not None: tzs.append(vevent['DTSTART'].dt.tzinfo) if 'DTEND' in vevent and hasattr(vevent['DTEND'].dt, 'tzinfo') and \ vevent['DTEND'].dt.tzinfo is not None and \ vevent['DTEND'].dt.tzinfo not in tzs: tzs.append(vevent['DTEND'].dt.tzinfo) for tzinfo in tzs: if tzinfo == pytz.UTC: continue timezone = create_timezone(tzinfo, self.start) calendar.add_component(timezone) for vevent in self._vevents.values(): calendar.add_component(vevent) return calendar.to_ical().decode('utf-8') def export_ics(self, path: str) -> None: """export event as ICS """ export_path = os.path.expanduser(path) with open(export_path, 'w') as fh: fh.write(self.raw) @property def summary(self) -> str: description = None date = self._vevents[self.ref].get('x-birthday', None) if date: description = 'birthday' else: date = self._vevents[self.ref].get('x-anniversary', None) if date: description = 'anniversary' else: date = self._vevents[self.ref].get('x-abdate', None) if date: description = self._vevents[self.ref].get('x-ablabel', 'custom event') if date: number = self.start_local.year - int(date[:4]) name = self._vevents[self.ref].get('x-fname', None) if int(date[4:6]) == 2 and int(date[6:8]) == 29: leap = ' (29th of Feb.)' else: leap = '' if (number - 1) % 10 == 0 and number != 11: suffix = 'st' elif (number - 2) % 10 == 0 and number != 12: suffix = 'nd' elif (number - 3) % 10 == 0 and number != 13: suffix = 'rd' else: suffix = 'th' return f'{name}\'s {number}{suffix} {description}{leap}' else: return self._vevents[self.ref].get('SUMMARY', '') def update_summary(self, summary: str) -> None: self._vevents[self.ref]['SUMMARY'] = summary @staticmethod def _can_handle_alarm(alarm) -> bool: """ Decides whether we can handle a certain alarm. """ return alarm.get('ACTION') == 'DISPLAY' and \ isinstance(alarm.get('TRIGGER').dt, dt.timedelta) @property def alarms(self) -> List[Tuple[dt.timedelta, str]]: """ Returns a list of all alarms in th original event that we can handle. Unknown types of alarms are ignored. """ return [(a.get('TRIGGER').dt, a.get('DESCRIPTION')) for a in self._vevents[self.ref].subcomponents if a.name == 'VALARM' and self._can_handle_alarm(a)] def update_alarms(self, alarms: List[Tuple[dt.timedelta, str]]) -> None: """ Replaces all alarms in the event that can be handled with the ones provided. """ components = self._vevents[self.ref].subcomponents # remove all alarms that we can handle from the subcomponents components = [c for c in components if not (c.name == 'VALARM' and self._can_handle_alarm(c))] # add all alarms we could handle from the input for alarm in alarms: new = icalendar.Alarm() new.add('ACTION', 'DISPLAY') new.add('TRIGGER', alarm[0]) new.add('DESCRIPTION', alarm[1]) components.append(new) self._vevents[self.ref].subcomponents = components @property def location(self) -> str: return self._vevents[self.ref].get('LOCATION', '') def update_location(self, location: str) -> None: if location: self._vevents[self.ref]['LOCATION'] = location else: self._vevents[self.ref].pop('LOCATION') @property def attendees(self) -> str: addresses = self._vevents[self.ref].get('ATTENDEE', []) if not isinstance(addresses, list): addresses = [addresses, ] return ", ".join([address.split(':')[-1] for address in addresses]) def update_attendees(self, attendees: List[str]): assert isinstance(attendees, list) attendees = [a.strip().lower() for a in attendees if a != ""] if len(attendees) > 0: # first check for overlaps in existing attendees. # Existing vCalAddress objects will be copied, non-existing # vCalAddress objects will be created and appended. old_attendees = self._vevents[self.ref].get('ATTENDEE', []) unchanged_attendees = [] vCalAddresses = [] for attendee in attendees: for old_attendee in old_attendees: old_email = old_attendee.lstrip("MAILTO:").lower() if attendee == old_email: vCalAddresses.append(old_attendee) unchanged_attendees.append(attendee) for attendee in [a for a in attendees if a not in unchanged_attendees]: item = icalendar.prop.vCalAddress(f'MAILTO:{attendee}') item.params['ROLE'] = icalendar.prop.vText('REQ-PARTICIPANT') item.params['PARTSTAT'] = icalendar.prop.vText('NEEDS-ACTION') item.params['CUTYPE'] = icalendar.prop.vText('INDIVIDUAL') item.params['RSVP'] = icalendar.prop.vText('TRUE') # TODO use khard here to receive full information from email address vCalAddresses.append(item) self._vevents[self.ref]['ATTENDEE'] = vCalAddresses else: self._vevents[self.ref].pop('ATTENDEE') @property def categories(self) -> str: try: return self._vevents[self.ref].get('CATEGORIES', '').to_ical().decode('utf-8') except AttributeError: return '' def update_categories(self, categories: List[str]) -> None: assert isinstance(categories, list) categories = [c.strip() for c in categories if c != ""] self._vevents[self.ref].pop('CATEGORIES', False) if categories: self._vevents[self.ref].add('CATEGORIES', categories) @property def description(self) -> str: return self._vevents[self.ref].get('DESCRIPTION', '') def update_description(self, description: str): if description: self._vevents[self.ref]['DESCRIPTION'] = description else: self._vevents[self.ref].pop('DESCRIPTION') @property def _recur_str(self) -> str: if self.recurring: recurstr = ' ' + self.symbol_strings['recurring'] else: recurstr = '' return recurstr @property def _alarm_str(self) -> str: if self.alarms: alarmstr = ' ' + self.symbol_strings['alarming'] else: alarmstr = '' return alarmstr @property def _status_str(self) -> str: if self.status == 'CANCELLED': statusstr = self.symbol_strings['cancelled'] elif self.status == 'TENTATIVE': statusstr = self.symbol_strings['tentative'] elif self.status == 'CONFIRMED': statusstr = self.symbol_strings['confirmed'] else: statusstr = '' return statusstr @property def _partstat_str(self) -> str: partstat = self.partstat if partstat == 'ACCEPTED': partstatstr = self.symbol_strings['accepted'] elif partstat == 'TENTATIVE': partstatstr = self.symbol_strings['tentative'] elif partstat == 'DECLINED': partstatstr = self.symbol_strings['declined'] else: partstatstr = '' return partstatstr def attributes( self, relative_to: Union[Tuple[dt.date, dt.date], dt.date], env=None, colors: bool=True, ): """ :param colors: determines if colors codes should be printed or not """ env = env or {} attributes = {} if isinstance(relative_to, tuple): relative_to_start, relative_to_end = relative_to else: relative_to_start = relative_to_end = relative_to if isinstance(relative_to_end, dt.datetime): relative_to_end = relative_to_end.date() if isinstance(relative_to_start, dt.datetime): relative_to_start = relative_to_start.date() if isinstance(self.start_local, dt.datetime): start_local_datetime = self.start_local end_local_datetime = self.end_local else: start_local_datetime = self._locale['local_timezone'].localize( dt.datetime.combine(self.start, dt.time.min)) end_local_datetime = self._locale['local_timezone'].localize( dt.datetime.combine(self.end, dt.time.min)) day_start = self._locale['local_timezone'].localize( dt.datetime.combine(relative_to_start, dt.time.min), ) day_end = self._locale['local_timezone'].localize( dt.datetime.combine(relative_to_end, dt.time.max), ) next_day_start = day_start + dt.timedelta(days=1) allday = isinstance(self, AllDayEvent) attributes["start"] = self.start_local.strftime(self._locale['datetimeformat']) attributes["start-long"] = self.start_local.strftime(self._locale['longdatetimeformat']) attributes["start-date"] = self.start_local.strftime(self._locale['dateformat']) attributes["start-date-long"] = self.start_local.strftime(self._locale['longdateformat']) attributes["start-time"] = self.start_local.strftime(self._locale['timeformat']) attributes["end"] = self.end_local.strftime(self._locale['datetimeformat']) attributes["end-long"] = self.end_local.strftime(self._locale['longdatetimeformat']) attributes["end-date"] = self.end_local.strftime(self._locale['dateformat']) attributes["end-date-long"] = self.end_local.strftime(self._locale['longdateformat']) attributes["end-time"] = self.end_local.strftime(self._locale['timeformat']) attributes["duration"] = timedelta2str(self.duration) # should only have time attributes at this point (start/end) full = {} for attr in attributes: full[attr + "-full"] = attributes[attr] attributes.update(full) if allday: attributes["start"] = attributes["start-date"] attributes["start-long"] = attributes["start-date-long"] attributes["start-time"] = "" attributes["end"] = attributes["end-date"] attributes["end-long"] = attributes["end-date-long"] attributes["end-time"] = "" tostr = "" if self.start_local.timetuple() < relative_to_start.timetuple(): attributes["start-style"] = self.symbol_strings["right_arrow"] elif self.start_local.timetuple() == relative_to_start.timetuple(): attributes["start-style"] = self.symbol_strings['range_start'] else: attributes["start-style"] = attributes["start-time"] tostr = "-" if end_local_datetime in [day_end, next_day_start]: if self._locale["timeformat"] == '%H:%M': attributes["end-style"] = '24:00' tostr = '-' else: attributes["end-style"] = self.symbol_strings["range_end"] tostr = "" elif end_local_datetime > day_end: attributes["end-style"] = self.symbol_strings["right_arrow"] tostr = "" else: attributes["end-style"] = attributes["end-time"] if self.start < self.end: attributes["to-style"] = '-' else: attributes["to-style"] = '' if start_local_datetime < day_start and end_local_datetime > day_end: attributes["start-end-time-style"] = self.symbol_strings["range"] else: attributes["start-end-time-style"] = attributes["start-style"] + \ tostr + attributes["end-style"] if allday: if self.start == self.end: attributes['start-end-time-style'] = '' elif self.start == relative_to_start and self.end > relative_to_end: attributes['start-end-time-style'] = self.symbol_strings['range_start'] elif self.start < relative_to_start and self.end > relative_to_end: attributes['start-end-time-style'] = self.symbol_strings['range'] elif self.start < relative_to_start and self.end == relative_to_end: attributes['start-end-time-style'] = self.symbol_strings['range_end'] else: attributes['start-end-time-style'] = '' if allday: attributes['end-necessary'] = '' attributes['end-necessary-long'] = '' if self.start_local != self.end_local: attributes['end-necessary'] = attributes['end-date'] attributes['end-necessary-long'] = attributes['end-date-long'] else: attributes['end-necessary'] = attributes['end-time'] attributes['end-necessary-long'] = attributes['end-time'] if self.start_local.date() != self.end_local.date(): attributes['end-necessary'] = attributes['end'] attributes['end-necessary-long'] = attributes['end-long'] attributes["repeat-symbol"] = self._recur_str attributes["repeat-pattern"] = self.recurpattern attributes["alarm-symbol"] = self._alarm_str attributes["status-symbol"] = self._status_str attributes["partstat-symbol"] = self._partstat_str attributes["title"] = self.summary attributes["organizer"] = self.organizer.strip() formatters = FORMATTERS.values() if len(formatters) == 1: fmt: Callable[[str], str] = list(formatters)[0] else: def fmt(s: str) -> str: return s.strip() attributes["description"] = fmt(self.description) attributes["description-separator"] = "" if attributes["description"]: attributes["description-separator"] = " :: " attributes["location"] = self.location.strip() attributes["attendees"] = self.attendees attributes["all-day"] = str(allday) attributes["categories"] = self.categories attributes['uid'] = self.uid attributes['url'] = self.url attributes['url-separator'] = "" if attributes['url']: attributes['url-separator'] = " :: " if "calendars" in env and self.calendar in env["calendars"]: cal = env["calendars"][self.calendar] attributes["calendar-color"] = cal.get('color', '') attributes["calendar"] = cal.get("displayname", self.calendar) else: attributes["calendar-color"] = attributes["calendar"] = '' attributes["calendar"] = self.calendar if colors: attributes['reset'] = style('', reset=True) attributes['bold'] = style('', bold=True, reset=False) for c in ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]: attributes[c] = style("", reset=False, fg=c) attributes[c + "-bold"] = style("", reset=False, fg=c, bold=True) else: attributes['reset'] = attributes['bold'] = '' for c in ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]: attributes[c] = attributes[c + '-bold'] = '' attributes['nl'] = '\n' attributes['tab'] = '\t' attributes['bell'] = '\a' attributes['status'] = self.status + ' ' if self.status else '' attributes['cancelled'] = 'CANCELLED ' if self.status == 'CANCELLED' else '' return attributes def duplicate(self) -> 'Event': """duplicate this event's PROTO event""" new_uid = generate_random_uid() vevent = self._vevents['PROTO'].copy() vevent['SEQUENCE'] = 0 vevent['UID'] = icalendar.vText(new_uid) vevent['SUMMARY'] = icalendar.vText(vevent['SUMMARY'] + ' Copy') event = self.fromVEvents([vevent], locale=self._locale) event.calendar = self.calendar event._locale = self._locale return event def delete_instance(self, instance: dt.datetime) -> None: """delete an instance from this event we don't check, if that instance is an instance of the recurrence rules defined in the event """ assert self.recurring delete_instance(self._vevents['PROTO'], instance) # in case the instance we want to delete is specified as a RECURRENCE-ID # event, we should delete that as well to_pop = [] for key in self._vevents: if key == 'PROTO': continue try: if self._vevents[key].get('RECURRENCE-ID').dt == instance: to_pop.append(key) except TypeError: # localized/floating datetime mismatch continue for key in to_pop: self._vevents.pop(key) @property def status(self) -> str: return self._vevents[self.ref].get('STATUS', '') @property def partstat(self) -> Optional[str]: for attendee in self._vevents[self.ref].get('ATTENDEE', []): for address in self.addresses: if attendee == 'mailto:' + address: return attendee.params.get('PARTSTAT', '') return None class DatetimeEvent(Event): pass class LocalizedEvent(DatetimeEvent): """ see parent """ def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) try: starttz = getattr(self._vevents[self.ref]['DTSTART'].dt, 'tzinfo', None) except KeyError: msg = ( f"Cannot understand event {kwargs.get('href')} from " f"calendar {kwargs.get('calendar')}, you might want to file an issue at " "https://github.com/pimutils/khal/issues" ) logger.fatal(msg) raise FatalError( # because in ikhal you won't see the logger's output msg ) if starttz is None: starttz = self._locale['default_timezone'] try: endtz = getattr(self._vevents[self.ref]['DTEND'].dt, 'tzinfo', None) except KeyError: endtz = starttz if endtz is None: endtz = self._locale['default_timezone'] if is_aware(self._start): self._start = self._start.astimezone(starttz) else: self._start = starttz.localize(self._start) if is_aware(self._end): self._end = self._end.astimezone(endtz) else: self._end = endtz.localize(self._end) @property def start_local(self) -> dt.datetime: """ see parent """ return self.start.astimezone(self._locale['local_timezone']) @property def end_local(self) -> dt.datetime: """ see parent """ return self.end.astimezone(self._locale['local_timezone']) class FloatingEvent(DatetimeEvent): """ """ allday: bool = False @property def start_local(self) -> dt.datetime: return self._locale['local_timezone'].localize(self.start) @property def end_local(self) -> dt.datetime: return self._locale['local_timezone'].localize(self.end) class AllDayEvent(Event): allday: bool = True @property def end(self) -> dt.datetime: end = super().end if end == self.start: # https://github.com/pimutils/khal/issues/129 logger.warning(f'{self.href} ("{self.summary}"): The event\'s end ' 'date property contains the same value as the start ' 'date, which is invalid as per RFC 5545. Khal will ' 'assume this is meant to be a single-day event on ' f'{self.start}') end += dt.timedelta(days=1) return end - dt.timedelta(days=1) @property def duration(self) -> dt.timedelta: try: return self._vevents[self.ref]['DURATION'].dt except KeyError: return self.end - self.start + dt.timedelta(days=1) def create_timezone( tz: pytz.BaseTzInfo, first_date: Optional[dt.datetime]=None, last_date: Optional[dt.datetime]=None ) -> icalendar.Timezone: """ create an icalendar vtimezone from a pytz.tzinfo object :param tz: the timezone :param first_date: the very first datetime that needs to be included in the transition times, typically the DTSTART value of the (first recurring) event :param last_date: the last datetime that needs to included, typically the end of the (very last) event (of a recursion set) :returns: timezone information we currently have a problem here: pytz.timezones only carry the absolute dates of time zone transitions, not their RRULEs. This will a) make for rather bloated VTIMEZONE components, especially for long recurring events, b) we'll need to specify for which time range this VTIMEZONE should be generated and c) will not be valid for recurring events that go into eternity. Possible Solutions: As this information is not provided by pytz at all, there is no easy solution, we'd really need to ship another version of the OLSON DB. """ if isinstance(tz, StaticTzInfo): return _create_timezone_static(tz) # TODO last_date = None, recurring to infinity first_date = dt.datetime.today() if not first_date else to_naive_utc(first_date) last_date = first_date + dt.timedelta(days=1) if not last_date else to_naive_utc(last_date) timezone = icalendar.Timezone() timezone.add('TZID', tz) dst = { one[2]: 'DST' in two.__repr__() for one, two in iter(tz._tzinfos.items()) # type: ignore } bst = { one[2]: 'BST' in two.__repr__() for one, two in iter(tz._tzinfos.items()) # type: ignore } # looking for the first and last transition time we need to include first_num, last_num = 0, len(tz._utc_transition_times) - 1 # type: ignore first_tt = tz._utc_transition_times[0] # type: ignore last_tt = tz._utc_transition_times[-1] # type: ignore for num, transtime in enumerate(tz._utc_transition_times): # type: ignore if first_date > transtime > first_tt: first_num = num first_tt = transtime if last_tt > transtime > last_date: last_num = num last_tt = transtime timezones: Dict[str, icalendar.Component] = {} for num in range(first_num, last_num + 1): name = tz._transition_info[num][2] # type: ignore if name in timezones: ttime = tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None) # type: ignore if 'RDATE' in timezones[name]: timezones[name]['RDATE'].dts.append( icalendar.prop.vDDDTypes(ttime)) else: timezones[name].add('RDATE', ttime) continue if dst[name] or bst[name]: subcomp = icalendar.TimezoneDaylight() else: subcomp = icalendar.TimezoneStandard() subcomp.add('TZNAME', tz._transition_info[num][2]) # type: ignore subcomp.add( 'DTSTART', tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None)) # type: ignore subcomp.add('TZOFFSETTO', tz._transition_info[num][0]) # type: ignore subcomp.add('TZOFFSETFROM', tz._transition_info[num - 1][0]) # type: ignore timezones[name] = subcomp for subcomp in timezones.values(): timezone.add_component(subcomp) return timezone def _create_timezone_static(tz: StaticTzInfo) -> icalendar.Timezone: """create an icalendar vtimezone from a StaticTzInfo :param tz: the timezone :returns: timezone information """ timezone = icalendar.Timezone() timezone.add('TZID', tz) subcomp = icalendar.TimezoneStandard() subcomp.add('TZNAME', tz) subcomp.add('DTSTART', dt.datetime(1601, 1, 1)) subcomp.add('RDATE', dt.datetime(1601, 1, 1)) subcomp.add('TZOFFSETTO', tz._utcoffset) # type: ignore subcomp.add('TZOFFSETFROM', tz._utcoffset) # type: ignore timezone.add_component(subcomp) return timezone khal-0.11.4/khal/khalendar/exceptions.py000066400000000000000000000044261477603436700201460ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from typing import Optional # noqa from ..exceptions import Error, FatalError, UnsupportedFeatureError class UnsupportedRruleExceptionError(UnsupportedFeatureError): """we do not support exceptions that do not delete events yet""" def __init__(self, message='') -> None: x = 'This kind of recurrence exception is currently unsupported' if message: x += f': {message.strip()}' UnsupportedFeatureError.__init__(self, x) class ReadOnlyCalendarError(Error): """this calendar is readonly and should not be modifiable from within khal""" class EtagMissmatch(Error): """An event is trying to be modified from khal which has also been externally modified""" class OutdatedDbVersionError(FatalError): """the db file has an older version and needs to be deleted""" class CouldNotCreateDbDir(FatalError): """the db directory could not be created. Abort.""" class UpdateFailed(Error): """could not update the event in the database""" class DuplicateUid(Error): """an event with this UID already exists""" existing_href = None # type: Optional[str] class NonUniqueUID(Error): """the .ics file contains more than one UID""" khal-0.11.4/khal/khalendar/khalendar.py000066400000000000000000000436101477603436700177140ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ CalendarCollection should enable modifying and querying a collection of calendars. Each calendar is defined by the contents of a vdir, but uses an SQLite db for caching (see backend if you're interested). """ import datetime as dt import itertools import logging import os import os.path from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union # noqa from ..custom_types import CalendarConfiguration, EventCreationTypes, LocaleConfiguration from ..icalendar import new_vevent from . import backend from .event import Event from .exceptions import ( DuplicateUid, EtagMissmatch, NonUniqueUID, ReadOnlyCalendarError, UnsupportedFeatureError, UpdateFailed, ) from .vdir import ( AlreadyExistingError, CollectionNotFoundError, Vdir, WrongEtagError, get_etag_from_file, ) logger = logging.getLogger('khal') class CalendarCollection: """CalendarCollection allows access to various calendars stored in vdirs all calendars are cached in an sqlitedb for performance reasons""" def __init__(self, calendars: Dict[str, CalendarConfiguration], hmethod: str='fg', default_color: str='', multiple: str='', multiple_on_overflow: bool=False, color: str='', priority: int=10, highlight_event_days: bool=False, locale: Optional[LocaleConfiguration]=None, dbpath: Optional[str]=None, ) -> None: assert locale assert dbpath is not None assert calendars is not None self._calendars: Dict[str, CalendarConfiguration] = calendars self._default_calendar_name: Optional[str] = None self._storages: Dict[str, Vdir] = {} file_ext: str for name, calendar in self._calendars.items(): ctype = calendar.get('ctype', 'calendar') if ctype == 'calendar': file_ext = '.ics' elif ctype == 'birthdays': file_ext = '.vcf' else: raise ValueError('ctype must be either `calendar` or `birthdays`') try: self._storages[name] = Vdir(calendar['path'], file_ext) except CollectionNotFoundError: os.makedirs(calendar['path']) logger.info(f"created non-existing vdir {calendar['path']}") self._storages[name] = Vdir(calendar['path'], file_ext) self.hmethod = hmethod self.default_color = default_color self.multiple = multiple self.multiple_on_overflow = multiple_on_overflow self.color = color self.priority = priority self.highlight_event_days = highlight_event_days self._locale = locale self._backend = backend.SQLiteDb(self.names, dbpath, self._locale) self._last_ctags: Dict[str, str] = {} self.update_db() @property def writable_names(self) -> List[str]: return [c for c in self._calendars if not self._calendars[c].get('readonly', False)] @property def calendars(self) -> Iterable[CalendarConfiguration]: return self._calendars.values() @property def names(self) -> Iterable[str]: return self._calendars.keys() @property def default_calendar_name(self) -> Optional[str]: return self._default_calendar_name @default_calendar_name.setter def default_calendar_name(self, default: str) -> None: if default is None: self._default_calendar_name = default elif default not in self.names: raise ValueError(f'Unknown calendar: {default}') readonly = self._calendars[default].get('readonly', False) if not readonly: self._default_calendar_name = default else: raise ValueError( f'Calendar "{default}" is read-only and cannot be used as default') def _local_ctag(self, calendar: str) -> str: return get_etag_from_file(self._calendars[calendar]['path']) def get_floating(self, start: dt.datetime, end: dt.datetime) -> Iterable[Event]: for args in self._backend.get_floating(start, end): yield self._construct_event(*args) def get_localized(self, start: dt.datetime, end: dt.datetime) -> Iterable[Event]: for args in self._backend.get_localized(start, end): yield self._construct_event(*args) def get_events_on(self, day: dt.date) -> Iterable[Event]: """return all events on `day`""" start = dt.datetime.combine(day, dt.time.min) end = dt.datetime.combine(day, dt.time.max) floating_events = self.get_floating(start, end) localize = self._locale['local_timezone'].localize localized_events = self.get_localized(localize(start), localize(end)) return itertools.chain(localized_events, floating_events) def get_calendars_on(self, day: dt.date) -> List[str]: start = dt.datetime.combine(day, dt.time.min) end = dt.datetime.combine(day, dt.time.max) localize = self._locale['local_timezone'].localize calendars = itertools.chain( self._backend.get_floating_calendars(start, end), self._backend.get_localized_calendars(localize(start), localize(end)), ) return list(set(calendars)) def update(self, event: Event) -> None: """update `event` in vdir and db""" assert event.etag is not None assert event.calendar is not None assert event.href is not None assert event.raw is not None if self._calendars[event.calendar]['readonly']: raise ReadOnlyCalendarError() with self._backend.at_once(): event.etag = self._storages[event.calendar].update(event.href, event, event.etag) self._backend.update(event.raw, event.href, event.etag, calendar=event.calendar) self._backend.set_ctag(self._local_ctag(event.calendar), calendar=event.calendar) def force_update(self, event: Event, collection: Optional[str]=None) -> None: """update `event` even if an event with the same uid/href already exists""" href: str calendar = collection if collection is not None else event.calendar assert calendar is not None if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() with self._backend.at_once(): try: href, etag = self._storages[calendar].upload(event) except AlreadyExistingError as error: href = error.existing_href _, etag = self._storages[calendar].get(href) etag = self._storages[calendar].update(href, event, etag) self._backend.update(event.raw, href, etag, calendar=calendar) self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar) def insert(self, event: Event, collection: Optional[str]=None) -> None: """Insert a new event to the vdir and the database The event will get a new href and etag properties. If ``collection`` is ``None``, then ``event.calendar`` must be defined. :param event: the event to be inserted. """ # TODO FIXME not all `event`s are actually of type Event, we also uptade # with vdir.Items. Those don't have an .href or .etag property which we # than attach anyway. Works, but pretty ugly and any type checker will # complain. calendar = collection if collection is not None else event.calendar assert calendar is not None if hasattr(event, 'etag'): assert not event.etag if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() with self._backend.at_once(): try: event.href, event.etag = self._storages[calendar].upload(event) except AlreadyExistingError as Error: href = getattr(Error, 'existing_href', None) raise DuplicateUid(href) self._backend.update(event.raw, event.href, event.etag, calendar=calendar) self._backend.set_ctag(self._local_ctag(calendar), calendar=calendar) def delete(self, href: str, etag: Optional[str], calendar: str) -> None: """Delete an event specified by `href` from `calendar`""" if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() try: self._storages[calendar].delete(href, etag) except WrongEtagError: raise EtagMissmatch() self._backend.delete(href, calendar=calendar) def delete_instance(self, href: str, etag: Optional[str], calendar: str, rec_id: dt.datetime, ) -> Event: """Delete a recurrence instance from an event specified by `href` from `calendar` returns the updated event """ if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() event = self.get_event(href, calendar) if etag and etag != event.etag: raise EtagMissmatch() event.delete_instance(rec_id) self.update(event) return event def get_event(self, href: str, calendar: str) -> Event: """get an event by its href from the datatbase""" event_str, etag = self._backend.get_with_etag(href, calendar) return self._construct_event(event_str, etag=etag, href=href, calendar=calendar) def _construct_event(self, item: str, href: str, start: Optional[Union[dt.datetime, dt.date]] = None, end: Optional[Union[dt.datetime, dt.date]] = None, ref: str='PROTO', etag: Optional[str]=None, calendar: Optional[str]=None, ) -> Event: assert calendar is not None event = Event.fromString( item, locale=self._locale, href=href, calendar=calendar, etag=etag, start=start, end=end, ref=ref, color=self._calendars[calendar]['color'], readonly=self._calendars[calendar]['readonly'], addresses=self._calendars[calendar]['addresses'], ) return event def change_collection(self, event: Event, new_collection: str) -> None: """Moves `event` to a new collection (calendar)""" href, etag, calendar = event.href, event.etag, event.calendar event.etag = None self.insert(event, new_collection) assert href is not None assert calendar is not None self.delete(href, etag, calendar=calendar) def create_event_from_ics(self, ical: str, calendar_name: str, etag: Optional[str]=None, href: Optional[str]=None, ) -> Event: """creates and returns (but does not insert) a new event from ical string""" calendar = calendar_name or self.writable_names[0] return Event.fromString(ical, locale=self._locale, calendar=calendar, etag=etag, href=href) def create_event_from_dict(self, event_dict: EventCreationTypes, calendar_name: Optional[str] = None, ) -> Event: """Creates an Event from the method's arguments """ vevent = new_vevent(locale=self._locale, **event_dict) calendar_name = calendar_name or self.default_calendar_name or self.writable_names[0] assert calendar_name is not None return self.create_event_from_ics(vevent.to_ical(), calendar_name) def update_db(self) -> None: """update the db from the vdir, should be called after every change to the vdir """ for calendar in self._calendars: if self._needs_update(calendar, remember=True): self._db_update(calendar) def needs_update(self) -> bool: """Check if you need to call update_db. This could either be the case because the vdirs were changed externally, or another instance of khal updated the caching db already. """ # TODO is it a good idea to munch both use cases together? # in case another instance of khal has updated the db, we only need # to get new events, but # update_db() takes potentially a long time to return # but then the code (in ikhal's refresh code) would need to look like # this: # # update_ui = False # if collection.needs_update(): # collection.update_db() # update_ui = True # if collection.needs_refresh() or update_ui: # do_the_update() # # and the API would be made even uglier than it already is... for calendar in self._calendars: if self._needs_update(calendar) or \ self._last_ctags[calendar] != self._local_ctag(calendar): return True return False def _needs_update(self, calendar: str, remember: bool=False) -> bool: """checks if the db for the given calendar needs an update""" local_ctag = self._local_ctag(calendar) if remember: self._last_ctags[calendar] = local_ctag return local_ctag != self._backend.get_ctag(calendar) def _db_update(self, calendar: str) -> None: """implements the actual db update on a per calendar base""" local_ctag = self._local_ctag(calendar) db_hrefs = {href for href, etag in self._backend.list(calendar)} storage_hrefs: Set[str] = set() bdays = self._calendars[calendar].get('ctype') == 'birthdays' with self._backend.at_once(): for href, etag in self._storages[calendar].list(): storage_hrefs.add(href) db_etag = self._backend.get_etag(href, calendar=calendar) if etag != db_etag: logger.debug(f'Updating {href} because {etag} != {db_etag}') self._update_vevent(href, calendar=calendar) for href in db_hrefs - storage_hrefs: if bdays: for sh in storage_hrefs: if href.startswith(sh): break else: self._backend.delete(href, calendar=calendar) else: self._backend.delete(href, calendar=calendar) self._backend.set_ctag(local_ctag, calendar=calendar) self._last_ctags[calendar] = local_ctag def _update_vevent(self, href: str, calendar: str) -> bool: """should only be called during db_update, only updates the db, does not check for readonly""" event, etag = self._storages[calendar].get(href) try: if self._calendars[calendar].get('ctype') == 'birthdays': update = self._backend.update_vcf_dates else: update = self._backend.update update(event.raw, href=href, etag=etag, calendar=calendar) return True except Exception as e: if not isinstance(e, (UpdateFailed, UnsupportedFeatureError, NonUniqueUID)): logger.exception('Unknown exception happened.') logger.warning( f'Skipping {calendar}/{href}: {str(e)}\n' 'This event will not be available in khal.') return False def search(self, search_string: str) -> Iterable[Event]: """search for the db for events matching `search_string`""" return (self._construct_event(*args) for args in self._backend.search(search_string)) def get_day_styles(self, day: dt.date, focus: bool) -> Optional[Union[str, Tuple[str, str]]]: calendars = self.get_calendars_on(day) if len(calendars) == 0: return None if self.color != '': return 'highlight_days_color' if len(calendars) == 1: return 'calendar ' + calendars[0] if self.multiple != '' and not (self.multiple_on_overflow and len(calendars) == 2): return 'highlight_days_multiple' return ('calendar ' + calendars[0], 'calendar ' + calendars[1]) def get_styles(self, date: dt.date, focus: bool) -> Optional[Union[str, Tuple[str, str]]]: if focus: if date == date.today(): return 'today focus' else: return 'reveal focus' else: if date == date.today(): return 'today' else: if self.highlight_event_days: return self.get_day_styles(date, focus) else: return None khal-0.11.4/khal/khalendar/typing.py000066400000000000000000000006631477603436700172760ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from khal.khalendar.event import Event # There should be `khal.khalendar.Event` instead of `Any` # here and in `Postprocess` below but that results in recursive typing # which mypy doesn't support until # https://github.com/python/mypy/issues/731 is implemented. Render = Callable[ ["Event", Any], str, ] Postprocess = Callable[[str, "Event", Any], str] khal-0.11.4/khal/khalendar/vdir.py000066400000000000000000000235501477603436700167300ustar00rootroot00000000000000''' Based off https://github.com/pimutils/python-vdir, which is itself based off vdirsyncer. ''' import contextlib import errno import os import tempfile import uuid from hashlib import sha1 from typing import IO, Callable, Dict, Iterable, Optional, Protocol, Tuple, Type from ..custom_types import PathLike, SupportsRaw class HasMetaProtocol(Protocol): color_type: Callable def get_meta(self, key: str) -> str: ... def set_meta(self, key: str, value: str) -> None: ... class cached_property: '''A read-only @property that is only evaluated once. Only usable on class instances' methods. ''' def __init__(self, fget, doc=None) -> None: self.__name__ = fget.__name__ self.__module__ = fget.__module__ self.__doc__ = doc or fget.__doc__ self.fget = fget def __get__(self, obj, cls): if obj is None: # pragma: no cover return self obj.__dict__[self.__name__] = result = self.fget(obj) return result SAFE_UID_CHARS = ('abcdefghijklmnopqrstuvwxyz' 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' '0123456789_.-+@') def _href_safe(uid: str, safe: str=SAFE_UID_CHARS) -> bool: return not bool(set(uid) - set(safe)) def _generate_href(uid: Optional[str]=None, safe: str=SAFE_UID_CHARS) -> str: if not uid: return str(uuid.uuid4().hex) elif not _href_safe(uid, safe): return sha1(uid.encode()).hexdigest() else: return uid def get_etag_from_file(f) -> str: '''Get mtime-based etag from a filepath, file-like object or raw file descriptor. This function will flush/sync the file as much as necessary to obtain a correct mtime. ''' close_f = False if hasattr(f, 'read'): f.flush() f = f.fileno() elif isinstance(f, str): flags = 0 if os.path.isdir(f): flags = os.O_DIRECTORY f = os.open(f, flags) close_f = True # assure that all internal buffers associated with this file are # written to disk try: os.fsync(f) stat = os.fstat(f) finally: if close_f: os.close(f) mtime = getattr(stat, 'st_mtime_ns', None) if mtime is None: mtime = stat.st_mtime return f'{mtime:.9f}' class VdirError(IOError): def __init__(self, *args, **kwargs) -> None: for key, value in kwargs.items(): if getattr(self, key, object()) not in [None, '']: # pragma: no cover raise TypeError(f'Invalid argument: {key}') setattr(self, key, value) super().__init__(*args) class NotFoundError(VdirError): pass class CollectionNotFoundError(VdirError): pass class WrongEtagError(VdirError): pass class AlreadyExistingError(VdirError): existing_href: str = '' class Item: def __init__(self, raw: str) -> None: assert isinstance(raw, str) self.raw = raw @cached_property def uid(self) -> Optional[str]: uid = '' lines = iter(self.raw.splitlines()) for line in lines: if line.startswith('UID:'): uid += line[4:].strip() break for line in lines: if not line.startswith(' '): break uid += line[1:] return uid or None @contextlib.contextmanager def atomic_write(dest, overwrite=False): fd, src = tempfile.mkstemp(prefix=os.path.basename(dest), dir=os.path.dirname(dest)) file = os.fdopen(fd, mode='wb') try: yield file except Exception: os.unlink(src) raise else: file.flush() file.close() if overwrite: os.rename(src, dest) else: os.link(src, dest) os.unlink(src) class VdirBase: item_class = Item default_mode = 0o750 def __init__(self, path: str, fileext: str, encoding: str='utf-8') -> None: if not os.path.isdir(path): raise CollectionNotFoundError(path) self.path = path self.encoding = encoding self.fileext = fileext @classmethod def discover(cls, path: str, **kwargs) -> Iterable['VdirBase']: try: collections = os.listdir(path) except OSError as e: if e.errno != errno.ENOENT: raise return for collection in collections: collection_path = os.path.join(path, collection) if os.path.isdir(collection_path): yield cls(path=collection_path, **kwargs) @classmethod def create(cls, collection_name: PathLike, **kwargs: PathLike) -> Dict[str, PathLike]: kwargs = dict(kwargs) path = kwargs['path'] pathn = os.path.join(path, collection_name) if not os.path.exists(pathn): os.makedirs(pathn, mode=cls.default_mode) elif not os.path.isdir(pathn): raise OSError(f'{repr(pathn)} is not a directory.') kwargs['path'] = pathn return kwargs def _get_filepath(self, href: str) -> str: return os.path.join(self.path, href) def _get_href(self, uid: Optional[str]) -> str: return _generate_href(uid) + self.fileext def list(self) -> Iterable[Tuple[str, str]]: for fname in os.listdir(self.path): fpath = os.path.join(self.path, fname) if os.path.isfile(fpath) and fname.endswith(self.fileext): yield fname, get_etag_from_file(fpath) def get(self, href: str) -> Tuple[Item, str]: fpath = self._get_filepath(href) try: with open(fpath, 'rb') as f: return ( Item(f.read().decode(self.encoding)), get_etag_from_file(fpath) ) except OSError as e: if e.errno == errno.ENOENT: raise NotFoundError(href) else: raise def upload(self, item: SupportsRaw) -> Tuple[str, str]: if not isinstance(item.raw, str): raise TypeError('item.raw must be a unicode string.') try: href = self._get_href(item.uid) _, etag = self._upload_impl(item, href) except OSError as e: if e.errno in ( errno.ENAMETOOLONG, # Unix errno.ENOENT # Windows ): # random href instead of UID-based href = self._get_href(None) _, etag = self._upload_impl(item, href) else: raise return href, etag def _upload_impl(self, item: SupportsRaw, href: str) -> Tuple[str, str]: fpath = self._get_filepath(href) try: f: IO with atomic_write(fpath, overwrite=False) as f: f.write(item.raw.encode(self.encoding)) return fpath, get_etag_from_file(f) except OSError as e: if e.errno == errno.EEXIST: raise AlreadyExistingError(existing_href=href) else: raise def update(self, href: str, item: SupportsRaw, etag: str) -> str: fpath = self._get_filepath(href) if not os.path.exists(fpath): raise NotFoundError(item.uid) actual_etag = get_etag_from_file(fpath) if etag != actual_etag: raise WrongEtagError(etag, actual_etag) if not isinstance(item.raw, str): raise TypeError('item.raw must be a unicode string.') with atomic_write(fpath, overwrite=True) as f: f.write(item.raw.encode(self.encoding)) etag = get_etag_from_file(f) return etag def delete(self, href: str, etag: Optional[str]) -> None: fpath = self._get_filepath(href) if not os.path.isfile(fpath): raise NotFoundError(href) actual_etag = get_etag_from_file(fpath) if etag != actual_etag: raise WrongEtagError(etag, actual_etag) os.remove(fpath) def get_meta(self, key: str) -> Optional[str]: fpath = os.path.join(self.path, key) try: with open(fpath, 'rb') as f: return f.read().decode(self.encoding).strip() or None except OSError as e: if e.errno == errno.ENOENT: return None else: raise def set_meta(self, key: str, value: str) -> None: value = value or '' assert isinstance(value, str) fpath = os.path.join(self.path, key) with atomic_write(fpath, overwrite=True) as f: f.write(value.encode(self.encoding)) class Color: def __init__(self, x: str) -> None: if not x: raise ValueError('Color is false-ish.') if not x.startswith('#'): raise ValueError('Color must start with a #.') if len(x) != 7: raise ValueError('Color must not have shortcuts. ' '#ffffff instead of #fff') self.raw: str = x.upper() @cached_property def rgb(self) -> Tuple[int, int, int]: x = self.raw r = x[1:3] g = x[3:5] b = x[5:8] if len(r) == len(g) == len(b) == 2: return int(r, 16), int(g, 16), int(b, 16) else: raise ValueError(f'Unable to parse color value: {self.raw}') class ColorMixin: color_type: Type[Color] = Color def get_color(self: HasMetaProtocol) -> Optional[str]: try: return self.color_type(self.get_meta('color')) except ValueError: return None def set_color(self: HasMetaProtocol, value: str) -> None: self.set_meta('color', self.color_type(value).raw) class DisplayNameMixin: def get_displayname(self: HasMetaProtocol) -> str: return self.get_meta('displayname') def set_displayname(self: HasMetaProtocol, value: str) -> None: self.set_meta('displayname', value) class Vdir(VdirBase, ColorMixin, DisplayNameMixin): pass khal-0.11.4/khal/parse_datetime.py000066400000000000000000000450671477603436700170300ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """this module contains some helper functions converting strings or list of strings to date(time) or event objects""" import datetime as dt import logging import re from calendar import isleap from time import strptime from typing import Any, Callable, Dict, List, Optional, Tuple, Union import pytz from khal.exceptions import DateTimeParseError, FatalError from .custom_types import LocaleConfiguration, RRuleMapType logger = logging.getLogger('khal') def timefstr(dtime_list: List[str], timeformat: str) -> dt.datetime: """converts the first item of a list (a time as a string) to a datetimeobject where the date is today and the time is given by a string removes "used" elements of list. """ if len(dtime_list) == 0: raise ValueError() datetime_start = dt.datetime.strptime(dtime_list[0], timeformat) time_start = dt.time(*datetime_start.timetuple()[3:5]) day_start = dt.date.today() dtstart = dt.datetime.combine(day_start, time_start) dtime_list.pop(0) return dtstart def datetimefstr( dtime_list: List[str], dateformat: str, default_day: Optional[dt.date]=None, infer_year: bool=True, in_future: bool=True, ) -> dt.datetime: """converts a datetime (as one or several string elements of a list) to a datetimeobject, if infer_year is True, use the `default_day`'s year as the year of the return datetimeobject, removes "used" elements of list example: dtime_list = ['17.03.', 'description'] dateformat = '%d.%m.' or : dtime_list = ['17.03.', '16:00', 'description'] dateformat = '%d.%m. %H:%M' """ # if now() is called as default param, mocking with freezegun won't work now = dt.datetime.now() if default_day is None: default_day = now.date() parts = dateformat.count(' ') + 1 dtstring = ' '.join(dtime_list[0:parts]) # only time.strptime can parse the 29th of Feb. if no year is given dtstart_struct = strptime(dtstring, dateformat) if infer_year and dtstart_struct.tm_mon == 2 and dtstart_struct.tm_mday == 29 and \ not isleap(default_day.year): raise ValueError for _ in range(parts): item = dtime_list.pop(0) if ' ' in item: logger.warn('detected a space in datetime specification, this can lead to errors.') logger.warn('Make sure not to quote your datetime specification.') if infer_year: dtstart = dt.datetime(*(default_day.timetuple()[:1] + dtstart_struct[1:5])) if in_future and dtstart < now: dtstart = dtstart.replace(year=dtstart.year + 1) if dtstart.date() < default_day: dtstart = dtstart.replace(year=default_day.year + 1) return dtstart else: return dt.datetime(*dtstart_struct[:5]) def weekdaypstr(dayname: str) -> int: """converts an (abbreviated) dayname to a number (mon=0, sun=6) :param dayname: name of abbreviation of the day :return: number of the day in a week """ if dayname in ['monday', 'mon']: return 0 if dayname in ['tuesday', 'tue']: return 1 if dayname in ['wednesday', 'wed']: return 2 if dayname in ['thursday', 'thu']: return 3 if dayname in ['friday', 'fri']: return 4 if dayname in ['saturday', 'sat']: return 5 if dayname in ['sunday', 'sun']: return 6 raise ValueError('invalid weekday name `%s`' % dayname) def construct_daynames(date_: dt.date) -> str: """converts datetime.date into a string description either `Today`, `Tomorrow` or name of weekday. """ if date_ == dt.date.today(): return 'Today' elif date_ == dt.date.today() + dt.timedelta(days=1): return 'Tomorrow' else: return date_.strftime('%A') def calc_day(dayname: str) -> dt.datetime: """converts a relative date's description to a datetime object :param dayname: relative day name (like 'today' or 'monday') :returns: date """ today = dt.datetime.combine(dt.date.today(), dt.time.min) dayname = dayname.lower() if dayname == 'today': return today if dayname == 'tomorrow': return today + dt.timedelta(days=1) if dayname == 'yesterday': return today - dt.timedelta(days=1) wday = weekdaypstr(dayname) days = (wday - today.weekday()) % 7 days = 7 if days == 0 else days day = today + dt.timedelta(days=days) return day def datefstr_weekday(dtime_list: List[str], timeformat: str, infer_year: bool) -> dt.datetime: """interprets first element of a list as a relative date and removes that element :param dtime_list: event description in list form :param timeformat: only here for compat reasons (having the same function signature) :returns: date """ if len(dtime_list) == 0: raise ValueError() day = calc_day(dtime_list[0]) dtime_list.pop(0) return day def datetimefstr_weekday(dtime_list: List[str], timeformat: str, infer_year: bool) -> dt.datetime: """ :param infer_year: only here for compat reasons (having the same function signature) """ if len(dtime_list) == 0: raise ValueError() day = calc_day(dtime_list[0]) this_time = timefstr(dtime_list[1:], timeformat) dtime_list.pop(0) dtime_list.pop(0) # we need to pop twice as timefstr gets a copy dtime = dt.datetime.combine(day, this_time.time()) return dtime def guessdatetimefstr( dtime_list: List[str], locale: LocaleConfiguration, default_day: Optional[dt.date]=None, in_future=True, ) -> Tuple[dt.datetime, bool]: """ :param in_future: if set, shortdate(time) events will be set in the future """ # if now() is called as default param, mocking with freezegun won't work day = default_day or dt.datetime.now().date() # TODO rename in guessdatetimefstrLIST or something saner altogether def timefstr_day(dtime_list: List[str], timeformat: str, infer_year: bool) -> dt.datetime: if locale['timeformat'] == '%H:%M' and dtime_list[0] == '24:00': a_date = dt.datetime.combine(day, dt.time(0)) dtime_list.pop(0) else: a_date = timefstr(dtime_list, timeformat) a_date = dt.datetime(*(day.timetuple()[:3] + a_date.timetuple()[3:5])) return a_date def datetimefwords(dtime_list: List[str], _: str, infer_year: bool) -> dt.datetime: if len(dtime_list) > 0 and dtime_list[0].lower() == 'now': dtime_list.pop(0) return dt.datetime.now() raise ValueError def datefstr_year(dtime_list: List[str], dtformat: str, infer_year: bool) -> dt.datetime: return datetimefstr(dtime_list, dtformat, day, infer_year, in_future) dtstart = None fun: Callable[[List[str], str, bool], dt.datetime] dtformat: str all_day: bool infer_year: bool for fun, dtformat, all_day, infer_year in [ (datefstr_year, locale['datetimeformat'], False, True), (datefstr_year, locale['longdatetimeformat'], False, False), (timefstr_day, locale['timeformat'], False, False), (datetimefstr_weekday, locale['timeformat'], False, False), (datefstr_year, locale['dateformat'], True, True), (datefstr_year, locale['longdateformat'], True, False), (datefstr_weekday, '', True, False), (datetimefwords, '', False, False), ]: # if a `short` format contains a year, treat it as a `long` format if infer_year and '97' in dt.datetime(1997, 10, 11).strftime(dtformat): infer_year = False try: dtstart = fun(dtime_list, dtformat, infer_year=infer_year) except (ValueError, DateTimeParseError): pass else: return dtstart, all_day raise DateTimeParseError( f"Could not parse \"{dtime_list}\".\nPlease check your configuration " "or run `khal printformats` to see if this does match your configured " "[long](date|time|datetime)format.\nIf you suspect a bug, please " "file an issue at https://github.com/pimutils/khal/issues/ " ) def timedelta2str(delta: dt.timedelta) -> str: # we deliberately ignore any subsecond deltas total_seconds = int(abs(delta).total_seconds()) seconds = total_seconds % 60 total_seconds -= seconds total_minutes = total_seconds // 60 minutes = total_minutes % 60 total_minutes -= minutes total_hours = total_minutes // 60 hours = total_hours % 24 total_hours -= hours days = total_hours // 24 s = [] if days: s.append(str(days) + "d") if hours: s.append(str(hours) + "h") if minutes: s.append(str(minutes) + "m") if seconds: s.append(str(seconds) + "s") if delta != abs(delta): s = ["-" + part for part in s] return ' '.join(s) def guesstimedeltafstr(delta_string: str) -> dt.timedelta: """parses a timedelta from a string :param delta_string: string encoding time-delta, e.g. '1h 15m' """ tups = re.split(r'(-?\+?\d+)', delta_string) if not re.match(r'^\s*$', tups[0]): raise ValueError(f'Invalid beginning of timedelta string "{delta_string}": "{tups[0]}"') tups = tups[1:] res = dt.timedelta() for num, unit in zip(tups[0::2], tups[1::2]): try: if num[0] == '+': num = num[1:] numint = int(num) except ValueError: raise DateTimeParseError( f'Invalid number in timedelta string "{delta_string}": "{num}"') ulower = unit.lower().strip() if ulower == 'd' or ulower == 'day' or ulower == 'days': res += dt.timedelta(days=numint) elif ulower == 'h' or ulower == 'hour' or ulower == 'hours': res += dt.timedelta(hours=numint) elif (ulower == 'm' or ulower == 'minute' or ulower == 'minutes' or ulower == 'min'): res += dt.timedelta(minutes=numint) elif (ulower == 's' or ulower == 'second' or ulower == 'seconds' or ulower == 'sec'): res += dt.timedelta(seconds=numint) else: raise ValueError(f'Invalid unit in timedelta string "{delta_string}": "{unit}"') return res def guessrangefstr(daterange: Union[str, List[str]], locale: LocaleConfiguration, default_timedelta_date: dt.timedelta=dt.timedelta(days=1), default_timedelta_datetime: dt.timedelta=dt.timedelta(hours=1), adjust_reasonably: bool=False, ) -> Tuple[dt.datetime, dt.datetime, bool]: """parses a range string :param daterange: date1 [date2 | timedelta] :param locale: :returns: start and end of the date(time) range and if this is an all-day time range or not, **NOTE**: the end is *exclusive* if this is an allday event """ range_list = daterange if isinstance(daterange, str): range_list = daterange.split(' ') assert isinstance(range_list, list) if range_list == ['week']: today_weekday = dt.datetime.today().weekday() startdt = dt.datetime.today() - dt.timedelta(days=(today_weekday - locale['firstweekday'])) enddt = startdt + dt.timedelta(days=8) return startdt, enddt, True for i in reversed(range(1, len(range_list) + 1)): startstr = ' '.join(range_list[:i]) endstr = ' '.join(range_list[i:]) allday = False try: # figuring out start split = startstr.split(" ") start, allday = guessdatetimefstr(split, locale) if len(split) != 0: continue # and end if len(endstr) == 0: if allday: end = start + default_timedelta_date else: end = start + default_timedelta_datetime elif endstr.lower() == 'eod': end = dt.datetime.combine(start.date(), dt.time.max) elif endstr.lower() == 'week': start -= dt.timedelta(days=(start.weekday() - locale['firstweekday'])) end = start + dt.timedelta(days=8) else: try: delta = guesstimedeltafstr(endstr) if allday and delta.total_seconds() % (3600 * 24): # TODO better error class, no logging in here logger.fatal( "Cannot give delta containing anything but whole days for allday events" ) raise FatalError() elif delta.total_seconds() == 0: logger.fatal( "Events that last no time are not allowed" ) raise FatalError() end = start + delta except (ValueError, DateTimeParseError): split = endstr.split(" ") end, end_allday = guessdatetimefstr( split, locale, default_day=start.date(), in_future=False) if len(split) != 0: continue if allday: end += dt.timedelta(days=1) if adjust_reasonably: if allday: # test if end's year is this year, but start's year is not today = dt.datetime.today() if end.year == today.year and start.year != today.year: end = dt.datetime(start.year, *end.timetuple()[1:6]) if end < start: end = dt.datetime(end.year + 1, *end.timetuple()[1:6]) if end < start: end = dt.datetime(*start.timetuple()[0:3] + end.timetuple()[3:5]) if end < start: end = end + dt.timedelta(days=1) return start, end, allday except (ValueError, DateTimeParseError): pass raise DateTimeParseError( f"Could not parse \"{daterange}\".\nPlease check your configuration or " "run `khal printformats` to see if this does match your configured " "[long](date|time|datetime)format.\nIf you suspect a bug, please " "file an issue at https://github.com/pimutils/khal/issues/ " ) def rrulefstr(repeat: str, until: str, locale: LocaleConfiguration, timezone: Optional[dt.tzinfo], ) -> RRuleMapType: if repeat in ["daily", "weekly", "monthly", "yearly"]: rrule_settings: RRuleMapType = {'freq': repeat} if until: until_dt, _ = guessdatetimefstr(until.split(' '), locale) if timezone: rrule_settings['until'] = until_dt.\ replace(tzinfo=timezone).\ astimezone(pytz.UTC) else: rrule_settings['until'] = until_dt return rrule_settings else: logger.fatal("Invalid value for the repeat option. \ Possible values are: daily, weekly, monthly or yearly") raise FatalError() def eventinfofstr(info_string: str, locale: LocaleConfiguration, default_event_duration: dt.timedelta, default_dayevent_duration: dt.timedelta, adjust_reasonably: bool=False, ) -> Dict[str, Any]: """parses a string of the form START [END | DELTA] [TIMEZONE] [SUMMARY] [:: DESCRIPTION] into a dictionary with keys: dtstart, dtend, timezone, allday, summary, description """ description = None if " :: " in info_string: info_string, description = info_string.split(' :: ') parts = info_string.split(' ') summary = None start: Optional[Union[dt.datetime, dt.date]] = None end: Optional[Union[dt.datetime, dt.date]] = None tz: Optional[pytz.BaseTzInfo] = None allday: bool = False for i in reversed(range(1, len(parts) + 1)): try: start, end, allday = guessrangefstr( ' '.join(parts[0:i]), locale, default_timedelta_datetime=default_event_duration, default_timedelta_date=default_dayevent_duration, adjust_reasonably=adjust_reasonably, ) except (ValueError, DateTimeParseError): continue if start is not None and end is not None: try: # next element is a valid Olson db timezone string tz = pytz.timezone(parts[i]) i += 1 except (pytz.UnknownTimeZoneError, UnicodeDecodeError, IndexError): tz = None summary = ' '.join(parts[i:]) break if start is None or end is None: raise DateTimeParseError( f"Could not parse \"{info_string}\".\nPlease check your " "configuration or run `khal printformats` to see if this does " "match your configured [long](date|time|datetime)format.\nIf you " "suspect a bug, please file an issue at " "https://github.com/pimutils/khal/issues/ " ) if tz is None: tz = locale['default_timezone'] if allday: assert isinstance(start, dt.datetime) assert isinstance(end, dt.datetime) start = start.date() end = end.date() info: Dict[str, Any] = {} info["dtstart"] = start info["dtend"] = end info["summary"] = summary if summary else None info["description"] = description info["timezone"] = tz if not allday else None info["allday"] = allday return info khal-0.11.4/khal/plugins.py000066400000000000000000000021501477603436700155050ustar00rootroot00000000000000from typing import Callable, Dict, List, Mapping, Tuple from khal._compat import importlib_metadata # This is a shameless ripoff of mdformat's plugin extension API. # see: # https://github.com/executablebooks/mdformat/blob/master/src/mdformat/plugins.py # https://setuptools.pypa.io/en/latest/userguide/entry_point.html def _load_formatters() -> Dict[str, Callable[[str], str]]: formatter_entrypoints = importlib_metadata.entry_points(group="khal.formatter") return {ep.name: ep.load() for ep in formatter_entrypoints} FORMATTERS: Mapping[str, Callable[[str], str]] = _load_formatters() def _load_color_themes() -> Dict[str, List[Tuple[str, ...]]]: color_theme_entrypoints = importlib_metadata.entry_points(group="khal.color_theme") return {ep.name: ep.load() for ep in color_theme_entrypoints} THEMES: Dict[str, List[Tuple[str, ...]],] = _load_color_themes() def _load_commands() -> Dict[str, Callable]: command_entrypoints = importlib_metadata.entry_points(group="khal.commands") return {ep.name: ep.load() for ep in command_entrypoints} COMMANDS: Dict[str, Callable] = _load_commands() khal-0.11.4/khal/settings/000077500000000000000000000000001477603436700153145ustar00rootroot00000000000000khal-0.11.4/khal/settings/__init__.py000066400000000000000000000004011477603436700174200ustar00rootroot00000000000000from .exceptions import InvalidSettingsError # noqa # type: ignore from .exceptions import NoConfigFile # noqa # type: ignore from .settings import find_configuration_file # noqa # type: ignore from .settings import get_config # noqa # type: ignore khal-0.11.4/khal/settings/exceptions.py000066400000000000000000000024711477603436700200530ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from ..exceptions import Error class InvalidSettingsError(Error): """Invalid Settings detected""" pass class CannotParseConfigFileError(InvalidSettingsError): pass class NoConfigFile(InvalidSettingsError): pass khal-0.11.4/khal/settings/khal.spec000066400000000000000000000367031477603436700171200ustar00rootroot00000000000000[calendars] # The *[calendars]* section is mandatory and must contain at least one subsection. # Every subsection must have a unique name (enclosed by two square brackets). # Each subsection needs exactly one *path* setting, everything else is optional. # Here is a small example: # # .. literalinclude:: ../../tests/configs/small.conf # :language: ini [[__many__]] # The path to an existing directory where this calendar is saved as a *vdir*. # The directory is searched for events or birthdays (see ``type``). The path # also accepts glob expansion via `*` or `?` when type is set to discover. # `**` means arbitrary depths of directories. # This allows for paths such as `~/accounts/*/calendars/*`, where the # calendars directory contains vdir directories. In addition, `~/calendars/*` # and `~/calendars/default` are valid paths if there exists a vdir in the # `default` directory. (The previous behavior of recursively searching # directories has been replaced with globbing). path = expand_path(default=None) # khal will use this color for coloring this calendar's event. # The following color names are supported: *black*, *white*, *brown*, *yellow*, # *dark gray*, *dark green*, *dark blue*, *light gray*, *light green*, *light # blue*, *dark magenta*, *dark cyan*, *dark red*, *light magenta*, *light # cyan*, *light red*. # Depending on your terminal emulator's settings, they might look different # than what their name implies. # In addition to the 16 named colors an index from the 256-color palette or a # 24-bit color code can be used, if your terminal supports this. # The 256-color palette index is simply a number between 0 and 255. # The 24-bit color must be given as #RRGGBB, where RR, GG, BB is the # hexadecimal value of the red, green and blue component, respectively. # When using a 24-bit color, make sure to enclose the color value in ' or "! # If `color` is set to *auto* (the default), khal looks for a color value in a # *color* file in this calendar's vdir. If the *color* file does not exist, the # default_color (see below) is used. If color is set to '', the default_color is # always used. Note that you can use `vdirsyncer metasync` to synchronize colors # with your caldav server. color = color(default='auto') # When coloring days, the color will be determined based on the calendar with # the highest priority. If the priorities are equal, then the "multiple" color # will be used. priority = integer(default=10) # setting this to *True*, will keep khal from making any changes to this # calendar readonly = boolean(default=False) # Setting the type of this collection (default ``calendar``). # # If set to ``calendar`` (the default), this collection will be used as a # standard calendar, that is, only files with the ``.ics`` extension will be # considered, all other files are ignored (except for a possible `color` file). # # If set to ``birthdays`` khal will expect a VCARD collection and extract # birthdays from those VCARDS, that is only files with ``.vcf`` extension will # be considered, all other files will be ignored. ``birthdays`` also implies # ``readonly=True``. # # If set to ``discover``, khal will use # `globbing `_ to expand this # calendar's `path` to (possibly) several paths and use those as individual # calendars (this cannot be used with `birthday` collections`). See `Exemplary # discover usage`_ for an example. # # If an individual calendar vdir has a `color` file, the calendar's color will # be set to the one specified in the `color` file, otherwise the color from the # *calendars* subsection will be used. type = option('calendar', 'birthdays', 'discover', default='calendar') # All email addresses associated with this account, separated by commas. # For now it is only used to check what participation status ("PARTSTAT") # belongs to the user. addresses = force_list(default='') [sqlite] # khal stores its internal caching database here, by default this will be in the *$XDG_DATA_HOME/khal/khal.db* (this will most likely be *~/.local/share/khal/khal.db*). path = expand_db_path(default=None) # It is mandatory to set (long)date-, time-, and datetimeformat options, all others options in the **[locale]** section are optional and have (sensible) defaults. [locale] # the first day of the week, where Monday is 0 and Sunday is 6 firstweekday = integer(0, 6, default=0) # by default khal uses some unicode symbols (as in 'non-ascii') as indicators for things like repeating events, # if your font, encoding etc. does not support those symbols, set this to *False* (this will enable ascii based replacements). unicode_symbols = boolean(default=True) # this timezone will be used for new events (when no timezone is specified) and # when khal does not understand the timezone specified in the icalendar file. # If no timezone is set, the timezone your computer is set to will be used. default_timezone = timezone(default=None) # khal will show all times in this timezone # If no timezone is set, the timezone your computer is set to will be used. local_timezone = timezone(default=None) # khal will display and understand all times in this format. # The formatting string is interpreted as defined by Python's `strftime # `_, which is # similar to the format specified in ``man strftime``. # In the configuration file it may be necessary to enclose the format in # quotation marks to force it to be loaded as a string. timeformat = string(default='%X') # khal will display and understand all dates in this format, see :ref:`timeformat ` for the format dateformat = string(default='%x') # khal will display and understand all dates in this format, it should # contain a year (e.g. *%Y*) see :ref:`timeformat ` for the format. longdateformat = string(default='%x') # khal will display and understand all datetimes in this format, see # :ref:`timeformat ` for the format. datetimeformat = string(default='%c') # khal will display and understand all datetimes in this format, it should # contain a year (e.g. *%Y*) see :ref:`timeformat ` for the format. longdatetimeformat = string(default='%c') # Enable weeknumbers in `calendar` and `interactive` (ikhal) mode by specifying # whether they should be displayed on the 'left' or 'right'. These are iso # weeknumbers, so will only work properly if `firstweekday` is set to 0 weeknumbers = weeknumbers(default='off') # Keybindings for :command:`ikhal` are set here. You can bind more than one key # (combination) to a command by supplying a comma-separated list of keys. # For binding key combinations concatenate them keys (with a space in # between), e.g. **ctrl n**. [keybindings] # move the cursor up (in the calendar browser) up = force_list(default=list('up', 'k')) # move the cursor down (in the calendar browser) down = force_list(default=list('down', 'j')) # move the cursor right (in the calendar browser) right = force_list(default=list('right', 'l', ' ')) # move the cursor left (in the calendar browser) left = force_list(default=list('left', 'h', 'backspace')) # create a new event on the selected date new = force_list(default=list('n')) # delete the currently selected event delete = force_list(default=list('d')) # show details or edit (if details are already shown) the currently selected event view = force_list(default=list('enter')) # edit the currently selected events' raw .ics file with $EDITOR # Only use this, if you know what you are doing, the icalendar library we use # doesn't do a lot of validation, it silently disregards most invalid data. external_edit = force_list(default=list('meta E')) # focus the calendar browser on today today = force_list(default=list('t')) # save the currently edited event and leave the event editor save = force_list(default=list('meta enter')) # duplicate the currently selected event duplicate = force_list(default=list('p')) # export event as a .ics file export = force_list(default=list('e')) # go into highlight (visual) mode to choose a date range mark = force_list(default=list('v')) # in highlight mode go to the other end of the highlighted date range other = force_list(default=list('o')) # open a text field to start a search for events search = force_list(default=list('/')) # show logged messages log = force_list(default=list('L')) # quit quit = force_list(default=list('q', 'Q')) # Some default values and behaviors are set here. [default] # The calendar to use if none is specified for some operation (e.g. if adding a # new event). If this is not set, such operations require an explicit value. default_calendar = string(default=None) # By default, khal displays only dates with events in `list` or `calendar` # view. Setting this to *True* will show all days, even when there is no event # scheduled on that day. show_all_days = boolean(default=False) # After adding a new event, what should be printed to standard out? The whole # event in text form, the path to where the event is now saved or nothing? print_new = option('event', 'path', 'False', default=False) # If true, khal will highlight days with events. Options for # highlighting are in [highlight_days] section. highlight_event_days = boolean(default=False) # Controls for how many days into the future we show events (for example, in # `khal list`) by default. timedelta = timedelta(default='2d') # Define the default duration for an event ('khal new' only) default_event_duration = timedelta(default='1h') # Define the defaut duration for a day-long event ('khal new' only) default_dayevent_duration = timedelta(default='1d') # Define the default alarm for new events, e.g. '15m' default_event_alarm = timedelta(default='') # Define the default alarm for new all dayevents, e.g. '12h' default_dayevent_alarm = timedelta(default='') # Whether the mouse should be enabled in interactive mode ('khal interactive' and # 'ikhal' only) enable_mouse = boolean(default=True) # The view section contains configuration options that effect the visual appearance # when using khal and ikhal. [view] # Defines the behaviour of ikhal's right column. If `True`, the right column # will show events for as many days as fit, moving the cursor through the list # will also select the appropriate day in the calendar column on the left. If # `False`, only a fixed ([default] timedelta) amount of days' events will be # shown, moving through events will not change the focus in the left column. dynamic_days = boolean(default=True) # weighting that is applied to the event view window event_view_weighting = integer(default=1) # Set to true to always show the event view window when looking at the event list event_view_always_visible = boolean(default=False) # Add a blank line before the name of the day (khal only) blank_line_before_day = boolean(default=False) # Choose a color theme for khal. # # Khal ships with two color themes, *dark* and *light*. Additionally, plugins # might supply different color schemes. # You can also define your own color theme in the [palette] section. theme = string(default='dark') # Whether to show a visible frame (with *box drawing* characters) around some # (groups of) elements or not. There are currently several different frame # options available, that should visually differentiate whether an element is # in focus or not. Some of them will probably be removed in future releases of # khal, so please try them out and give feedback on which style you prefer # (the color of all variants can be defined in the color themes). frame = option('False', 'width', 'color', 'top', default='False') # Whether to use bold text for light colors or not. Non-bold light colors may # not work on all terminals but allow using light background colors. bold_for_light_color = boolean(default=True) # Default formatting for events used when the user asks for all events in a # given time range, used for :command:`list`, :command:`calendar` and in # :command:`interactive` (ikhal). Please note, that any color styling will be # ignored in `ikhal`, where events will always be shown in the color of the # calendar they belong to. # The syntax is the same as for :option:`--format`. agenda_event_format = string(default='{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}') # Specifies how each *day header* is formatted. agenda_day_format = string(default='{bold}{name}, {date-long}{reset}') # Display month name on row when the week contains the first day # of the month ('firstday') or when the first day of the week is in the # month ('firstfullweek') monthdisplay = monthdisplay(default='firstday') # Default formatting for events used when the start- and end-date are not # clear through context, e.g. for :command:`search`, used almost everywhere # but :command:`list` and :command:`calendar`. It is therefore probably a # sensible choice to include the start- and end-date. # The syntax is the same as for :option:`--format`. event_format = string(default='{calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}') # Minimum number of months displayed by calendar command # default is 3 months min_calendar_display = integer(default=3) # When highlight_event_days is enabled, this section specifies how # the highlighting/coloring of days is handled. [highlight_days] # Highlighting method to use -- foreground or background method = option('foreground', 'fg', 'background', 'bg', default='fg') # What color to use when highlighting -- explicit color or use calendar # color when set to '' color = color(default='') # How to color days with events from multiple calendars -- either # explicit color or use calendars' colors when set to '' multiple = color(default='') # When `multiple` is set to a specific color, setting this to *True* will # cause khal to use that color only for days with events from 3 or more # calendars (hence preserving the two-color-highlight for days where all # calendar colors can be displayed) multiple_on_overflow = boolean(default=False) # Default color for calendars without color -- when set to '' it # actually disables highlighting for events that should use the # default color. default_color = color(default='') # Override ikhal's color theme with a custom palette. This is useful to style # certain elements of ikhal individually. # Palette entries take the form of `key = foreground, background, mono, # foreground_high, background_high` where foreground and background are used in # "low color mode" and foreground_high and background_high are used in "high # color mode" and mono if only monocolor is supported. If you don't want to set # a value for a certain color, use an empty string (`''`). # Valid entries for low color mode are listed on the `urwid website # `_. For # high color mode you can use any valid 24-bit color value, e.g. `'#ff0000'`. # # .. note:: # 24-bit colors must be enclosed in single quotes to be parsed correctly, # otherwise the `#` will be interpreted as a comment. # # Most modern terminals should support high color mode. # # Example entry (particular ugly): # # .. highlight:: ini # # :: # # [palette] # header = light red, default, default, '#ff0000', default # edit = '', '', 'bold', '#FF00FF', '#12FF14' # footer = '', '', '', '#121233', '#656599' # # See the default palettes in `khal/ui/colors.py` for all available keys. # If you can't theme an element in ikhal, please open an issue on `github # `_. [palette] khal-0.11.4/khal/settings/settings.py000066400000000000000000000134301477603436700175270ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import logging import os import xdg.BaseDirectory from configobj import ConfigObj, ConfigObjError, flatten_errors, get_extra_values from khal import __productname__ try: # Available from configobj 5.1.0 from configobj.validate import Validator except ModuleNotFoundError: from validate import Validator from typing import Callable, List, Optional from .exceptions import CannotParseConfigFileError, InvalidSettingsError, NoConfigFile from .utils import ( config_checks, expand_db_path, expand_path, get_color_from_vdir, get_vdir_type, is_color, is_timedelta, is_timezone, monthdisplay_option, weeknumber_option, ) logger = logging.getLogger('khal') SPECPATH = os.path.join(os.path.dirname(__file__), 'khal.spec') def find_configuration_file() -> Optional[str]: """Return the configuration filename. Check all the paths for configuration files defined in the XDG Base Directory Standard, and return the first one that exists, if any. For the common case, this will return ~/.config/khal/config, assuming that it exists. """ for dir in xdg.BaseDirectory.xdg_config_dirs: path = os.path.join(dir, __productname__, 'config') if os.path.exists(path): return path return None def get_config( config_path: Optional[str]=None, _get_color_from_vdir: Callable=get_color_from_vdir, _get_vdir_type: Callable=get_vdir_type) -> ConfigObj: """reads the config file, validates it and return a config dict :param config_path: path to a custom config file, if none is given the default locations will be searched :param _get_color_from_vdir: override get_color_from_vdir for testing purposes :param _get_vdir_type: override get_vdir_type for testing purposes :returns: configuration """ if config_path is None: config_path = find_configuration_file() if config_path is None or not os.path.exists(config_path): raise NoConfigFile() logger.debug(f'using the config file at {config_path}') try: user_config = ConfigObj(config_path, configspec=SPECPATH, interpolation=False, file_error=True, ) except ConfigObjError as error: logger.fatal('parsing the config file with the following error: ' f'{error}') logger.fatal('if you recently updated khal, the config file format ' 'might have changed, in that case please consult the ' 'CHANGELOG or other documentation') raise CannotParseConfigFileError() fdict = {'timezone': is_timezone, 'timedelta': is_timedelta, 'expand_path': expand_path, 'expand_db_path': expand_db_path, 'weeknumbers': weeknumber_option, 'monthdisplay': monthdisplay_option, 'color': is_color, } validator = Validator(fdict) results = user_config.validate(validator, preserve_errors=True) abort = False for section, subsection, config_error in flatten_errors(user_config, results): abort = True if isinstance(config_error, Exception): logger.fatal( f'config error:\n' f'in [{section[0]}] {subsection}: {config_error}') else: for key in config_error: if isinstance(config_error[key], Exception): logger.fatal( 'config error:\n' f'in {sectionize(section + [subsection])} {key}: ' f'{str(config_error[key])}' ) if abort or not results: raise InvalidSettingsError() config_checks(user_config, _get_color_from_vdir, _get_vdir_type) extras = get_extra_values(user_config) for section, value in extras: if section == (): logger.warning(f'unknown section "{value}" in config file') elif section == ('palette',): # we don't validate the palette section, because there is no way to # automatically extract valid attributes from the ui module continue else: section = sectionize(section) logger.warning( f'unknown key or subsection "{value}" in section "{section}"') return user_config def sectionize(sections: List[str], depth: int=1) -> str: """converts list of string into [list][[of]][[[strings]]]""" this_part = depth * '[' + sections[0] + depth * ']' if len(sections) > 1: return this_part + sectionize(sections[1:], depth=depth + 1) else: return this_part khal-0.11.4/khal/settings/utils.py000066400000000000000000000247341477603436700170400ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # import datetime as dt import glob import logging import os import pathlib from os.path import expanduser, expandvars, join from typing import Callable, Iterable, Literal, Optional, Union import pytz import xdg from tzlocal import get_localzone try: # Available from configobj 5.1.0 from configobj.validate import VdtValueError except ModuleNotFoundError: from validate import VdtValueError from ..khalendar.vdir import CollectionNotFoundError, Vdir from ..parse_datetime import guesstimedeltafstr from ..terminal import COLORS from .exceptions import InvalidSettingsError logger = logging.getLogger('khal') def is_timezone(tzstring: Optional[str]) -> dt.tzinfo: """tries to convert tzstring into a pytz timezone or return local timezone raises a VdtvalueError if tzstring is not valid """ if not tzstring: # later version of tzlocal return zoneinfo (not pytz) timezones # as a lot of our other code can't deal with this yet, we need to force # pytz timezones for the time being return pytz.timezone(str(get_localzone())) try: return pytz.timezone(tzstring) except pytz.UnknownTimeZoneError: raise VdtValueError(f"Unknown timezone {tzstring}") def is_timedelta(string: str) -> dt.timedelta: try: return guesstimedeltafstr(string) except ValueError: raise VdtValueError(f"Invalid timedelta: {string}") def weeknumber_option(option: str) -> Union[Literal['left', 'right'], Literal[False]]: """checks if *option* is a valid value :param option: the option the user set in the config file :returns: 'off', 'left', 'right' or False """ option = option.lower() if option == 'left': return 'left' elif option == 'right': return 'right' elif option in ['off', 'false', '0', 'no', 'none']: return False else: raise VdtValueError( f"Invalid value '{option}' for option 'weeknumber', must be one of " "'off', 'left' or 'right'") def monthdisplay_option(option: str) -> Literal['firstday', 'firstfullweek']: """checks if *option* is a valid value :param option: the option the user set in the config file """ option = option.lower() if option == 'firstday': return 'firstday' elif option == 'firstfullweek': return 'firstfullweek' else: raise VdtValueError( f"Invalid value '{option}' for option 'monthdisplay', must be one " "of 'firstday' or 'firstfullweek'" ) def expand_path(path: str) -> str: """expands `~` as well as variable names""" return expanduser(expandvars(path)) def expand_db_path(path: str) -> str: """expands `~` as well as variable names, defaults to $XDG_DATA_HOME""" if path is None: path = join(xdg.BaseDirectory.xdg_data_home, 'khal', 'khal.db') return expanduser(expandvars(path)) def is_color(color: str) -> str: """checks if color represents a valid color raises a VdtValueError if color is not valid """ # check if color is # 1) the default empty value # 2) auto # 3) a color name from the 16 color palette # 4) a color index from the 256 color palette # 5) an HTML-style color code if (color in ['', 'auto'] or color in COLORS.keys() or (color.isdigit() and int(color) >= 0 and int(color) <= 255) or (color.startswith('#') and (len(color) in [4, 7, 9]) and all(c in '01234567890abcdefABCDEF' for c in color[1:]))): return color raise VdtValueError(color) def test_default_calendar(config) -> None: """test if config['default']['default_calendar'] is set to a sensible value """ if config['default']['default_calendar'] is None: pass elif config['default']['default_calendar'] not in config['calendars']: logger.fatal( f"in section [default] {config['default']['default_calendar']} is " "not valid for 'default_calendar', must be one of " f"{config['calendars'].keys()}" ) raise InvalidSettingsError() elif config['calendars'][config['default']['default_calendar']]['readonly']: logger.fatal('default_calendar may not be read_only!') raise InvalidSettingsError() def get_color_from_vdir(path: str) -> Optional[str]: try: color = Vdir(path, '.ics').get_meta('color') except CollectionNotFoundError: color = None if color is None or color == '': logger.debug(f'Found no or empty file `color` in {path}') return None color = color.strip() try: is_color(color) except VdtValueError: logger.warning(f"Found invalid color `{color}` in {path}color") color = None return color def get_unique_name(path: str, names: Iterable[str]) -> str: # TODO take care of edge cases, make unique name finding less brain-dead try: name = Vdir(path, '.ics').get_meta('displayname') except CollectionNotFoundError: logger.fatal(f'The calendar at `{path}` is not a directory.') raise if name is None or name == '': logger.debug(f'Found no or empty file `displayname` in {path}') name = os.path.split(path)[-1] if name in names: while name in names: name = name + '1' return name def get_all_vdirs(expand_path: str) -> Iterable[str]: """returns a list of paths, expanded using glob """ # FIXME currently returns a list of all directories in path # we add an additional / at the end to make sure we are only getting # directories items = glob.glob(f'{expand_path}/', recursive=True) paths = [pathlib.Path(item) for item in sorted(items, key=len, reverse=True)] leaves = set() parents = set() for path in paths: if path in parents: # we have already seen the current directory as the parent of # another directory, so this directory can't be a vdir continue parents.add(path.parent) leaves.add(path) # sort to make sure that auto generated names are always identical return sorted(os.fspath(path) for path in leaves) def get_vdir_type(_: str) -> str: # TODO implement return 'calendar' def validate_palette_entry(attr, definition: str) -> bool: if len(definition) not in (2, 3, 5): logging.error('Invalid color definition for %s: %s, must be of length, 2, 3, or 5', attr, definition) return False if (definition[0] not in COLORS and definition[0] != '') or \ (definition[1] not in COLORS and definition[1] != ''): logging.error('Invalid color definition for %s: %s, must be one of %s', attr, definition, COLORS.keys()) return False return True def config_checks( config, _get_color_from_vdir: Callable=get_color_from_vdir, _get_vdir_type: Callable=get_vdir_type, ) -> None: """do some tests on the config we cannot do with configobj's validator""" # TODO rename or split up, we are also expanding vdirs of type discover if len(config['calendars'].keys()) < 1: logger.fatal('Found no calendar section in the config file') raise InvalidSettingsError() config['sqlite']['path'] = expand_db_path(config['sqlite']['path']) if not config['locale']['default_timezone']: config['locale']['default_timezone'] = is_timezone( config['locale']['default_timezone']) if not config['locale']['local_timezone']: config['locale']['local_timezone'] = is_timezone( config['locale']['local_timezone']) # expand calendars with type = discover # we need a copy of config['calendars'], because we modify config in the body of the loop for cname, cconfig in sorted(config['calendars'].items()): if not isinstance(config['calendars'][cname], dict): logger.fatal('Invalid config file, probably missing calendar sections') raise InvalidSettingsError if config['calendars'][cname]['type'] == 'discover': logger.debug(f"discovering calendars in {cconfig['path']}") vdirs_discovered = get_all_vdirs(cconfig['path']) logger.debug(f"found the following vdirs: {vdirs_discovered}") for vdir in vdirs_discovered: vdir_config = { 'path': vdir, 'color': _get_color_from_vdir(vdir) or cconfig.get('color', None), 'type': _get_vdir_type(vdir), 'readonly': cconfig.get('readonly', False), 'priority': 10, } unique_vdir_name = get_unique_name(vdir, config['calendars'].keys()) config['calendars'][unique_vdir_name] = vdir_config config['calendars'].pop(cname) test_default_calendar(config) for calendar in config['calendars']: if config['calendars'][calendar]['type'] == 'birthdays': config['calendars'][calendar]['readonly'] = True if config['calendars'][calendar]['color'] == 'auto': config['calendars'][calendar]['color'] = \ _get_color_from_vdir(config['calendars'][calendar]['path']) # check palette settings valid_palette = True for attr in config.get('palette', []): valid_palette = valid_palette and validate_palette_entry(attr, config['palette'][attr]) if not valid_palette: logger.fatal('Invalid palette entry') raise InvalidSettingsError() khal-0.11.4/khal/terminal.py000066400000000000000000000132201477603436700156370ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # """all functions related to terminal display are collected here""" from itertools import zip_longest from typing import Dict, List, NamedTuple, Optional class NamedColor(NamedTuple): color_index: int light: bool RTEXT = '\x1b[7m' # reverse NTEXT = '\x1b[0m' # normal BTEXT = '\x1b[1m' # bold RESET = '\33[0m' COLORS: Dict[str, NamedColor] = { 'black': NamedColor(color_index=0, light=False), 'dark red': NamedColor(color_index=1, light=False), 'dark green': NamedColor(color_index=2, light=False), 'brown': NamedColor(color_index=3, light=False), 'dark blue': NamedColor(color_index=4, light=False), 'dark magenta': NamedColor(color_index=5, light=False), 'dark cyan': NamedColor(color_index=6, light=False), 'white': NamedColor(color_index=7, light=False), 'light gray': NamedColor(color_index=7, light=True), 'dark gray': NamedColor(color_index=0, light=True), # actually light black 'light red': NamedColor(color_index=1, light=True), 'light green': NamedColor(color_index=2, light=True), 'yellow': NamedColor(color_index=3, light=True), 'light blue': NamedColor(color_index=4, light=True), 'light magenta': NamedColor(color_index=5, light=True), 'light cyan': NamedColor(color_index=6, light=True) } def get_color( fg: Optional[str]=None, bg: Optional[str]=None, bold_for_light_color: bool=False, ) -> str: """convert foreground and/or background color in ANSI color codes colors can be a color name from the ANSI color palette (e.g. 'dark green'), a number between 0 and 255 (still pass them as a string) or an HTML color in the style `#00FF00` or `#ABC` :param fg: foreground color :param bg: background color :returns: ANSI color code """ result = '' for colorstring, is_bg in ((fg, False), (bg, True)): if colorstring: color = '\33[' if colorstring in COLORS: # 16 color palette if not is_bg: # foreground color c = 30 + COLORS[colorstring].color_index if COLORS[colorstring].light: if bold_for_light_color: color += '1;' else: c += 60 else: # background color c = 40 + COLORS[colorstring].color_index if COLORS[colorstring].light: if not bold_for_light_color: c += 60 color += str(c) elif colorstring.isdigit(): # 256 color palette if not is_bg: color += '38;5;' + colorstring else: color += '48;5;' + colorstring else: # HTML-style 24-bit color if len(colorstring) == 4: # e.g. #ABC, equivalent to #AABBCC r = int(colorstring[1] * 2, 16) g = int(colorstring[2] * 2, 16) b = int(colorstring[3] * 2, 16) else: # e.g. #AABBCC r = int(colorstring[1:3], 16) g = int(colorstring[3:5], 16) b = int(colorstring[5:7], 16) if not is_bg: color += f'38;2;{r!s};{g!s};{b!s}' else: color += f'48;2;{r!s};{g!s};{b!s}' color += 'm' result += color return result def colored( string: str, fg: Optional[str]=None, bg: Optional[str]=None, bold_for_light_color: bool=True, ) -> str: """colorize `string` with ANSI color codes see get_color for description of `fg`, `bg` and `bold_for_light_color` :param string: string to be colorized :returns: colorized string """ result = get_color(fg, bg, bold_for_light_color) result += string if fg or bg: result += RESET return result def merge_columns(lcolumn: List[str], rcolumn: List[str], width: int=25) -> List[str]: """merge two lists elementwise together Wrap right columns to terminal width. If the right list(column) is longer, first lengthen the left one. We assume that the left column has width `width`, we cannot find out its (real) width automatically since it might contain ANSI escape sequences. """ missing = len(rcolumn) - len(lcolumn) if missing > 0: lcolumn = lcolumn + missing * [width * ' '] return [' '.join(one) for one in zip_longest(lcolumn, rcolumn, fillvalue='')] khal-0.11.4/khal/ui/000077500000000000000000000000001477603436700140715ustar00rootroot00000000000000khal-0.11.4/khal/ui/__init__.py000066400000000000000000001607541477603436700162170ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt import logging import signal import sys from enum import IntEnum from typing import Dict, List, Literal, Optional, Tuple import click import urwid from .. import plugins, utils from ..khalendar import CalendarCollection from ..khalendar.exceptions import FatalError, ReadOnlyCalendarError from ..parse_datetime import timedelta2str from . import colors from .base import Pane, Window from .editor import EventEditor, ExportDialog from .widgets import CalendarWidget, CAttrMap, NColumns, NPile, button, linebox from .widgets import ExtendedEdit as Edit logger = logging.getLogger('khal') # Overview of how this all meant to fit together: # # ┌──ClassicView(Pane)────────────────────────────────────────┐ # │ │ # │ ┌─CalendarWidget──┐ ┌────EventColumn───────────────────┐ │ # │ │ │ │ │ │ # │ │ │ │ ┌─DListBox─────────────────────┐ │ │ # │ │ │ │ │ │ │ │ # │ │ │ │ │ ┌─DayWalker────────────────┐ │ │ │ # │ │ │ │ │ │ │ │ │ │ # │ │ │ │ │ │ ┌─BoxAdapter───────────┐ │ │ │ │ # │ │ │ │ │ │ │ │ │ │ │ │ # │ │ │ │ │ │ │ ┌─DateListBox──────┐ │ │ │ │ │ # │ │ │ │ │ │ │ │ DateHeader │ │ │ │ │ │ # │ │ │ │ │ │ │ │ U_Event │ │ │ │ │ │ # │ │ │ │ │ │ │ │ ... │ │ │ │ │ │ # │ │ │ │ │ │ │ │ U_Event │ │ │ │ │ │ # │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ # │ │ │ │ │ │ └──────────────────────┘ │ │ │ │ # │ │ │ │ │ │ ... │ │ │ │ # │ │ │ │ │ │ ┌─BoxAdapter───────────┐ │ │ │ │ # │ │ │ │ │ │ │ │ │ │ │ │ # │ │ │ │ │ │ │ ┌─DateListBox──────┐ │ │ │ │ │ # │ │ │ │ │ │ │ │ DateHeader │ │ │ │ │ │ # │ │ │ │ │ │ │ │ U_Event │ │ │ │ │ │ # │ │ │ │ │ │ │ │ ... │ │ │ │ │ │ # │ │ │ │ │ │ │ │ U_Event │ │ │ │ │ │ # │ │ │ │ │ │ │ └──────────────────┘ │ │ │ │ │ # │ │ │ │ │ │ └──────────────────────┘ │ │ │ │ # │ │ │ │ │ └──────────────────────────┘ │ │ │ # │ │ │ │ └──────────────────────────────┘ │ │ # │ └─────────────────┘ └──────────────────────────────────┘ │ # └───────────────────────────────────────────────────────────┘ class DeletionType(IntEnum): ALL = 0 INSTANCES = 1 class DateConversionError(Exception): pass class SelectableText(urwid.Text): def selectable(self) -> bool: return True def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: return key def get_cursor_coords(self, size: Tuple[int]) -> Tuple[int, int]: return 0, 0 def render(self, size, focus=False): canv = super().render(size, focus) if focus: canv = urwid.CompositeCanvas(canv) canv.cursor = 0, 0 return canv class DateHeader(SelectableText): def __init__(self, day: dt.date, dateformat: str, conf) -> None: """ :param day: the date that is represented by this DateHeader instance :param dateformat: format to print `day` in """ self._day = day self._dateformat = dateformat self._conf = conf super().__init__('', wrap='clip') self.update_date_line() def update_date_line(self) -> None: """update self, so that the timedelta is accurate to be called after a date change """ self.set_text(self.relative_day(self._day, self._dateformat)) def relative_day(self, day: dt.date, dtformat: str) -> str: """convert day into a string with its weekday and relative distance to today :param day: day to be converted :param dtformat: the format day is to be printed in, passed to strftime """ weekday = day.strftime('%A') daystr = day.strftime(dtformat) if day == dt.date.today(): return f'Today ({weekday}, {daystr})' elif day == dt.date.today() + dt.timedelta(days=1): return f'Tomorrow ({weekday}, {daystr})' elif day == dt.date.today() - dt.timedelta(days=1): return f'Yesterday ({weekday}, {daystr})' approx_delta = utils.relative_timedelta_str(day) return f'{weekday}, {daystr} ({approx_delta})' def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' elif key in binds['up']: key = 'up' elif key in binds['right']: key = 'right' elif key in binds['down']: key = 'down' return key class U_Event(urwid.Text): def __init__(self, event, conf, delete_status, this_date=None, relative=True) -> None: """representation of an event in EventList :param event: the encapsulated event :type event: khal.event.Event """ if relative: if isinstance(this_date, dt.datetime) or not isinstance(this_date, dt.date): raise ValueError(f'`this_date` is of type `{type(this_date)}`, ' 'should be `datetime.date`') self.event = event self.delete_status = delete_status self.this_date = this_date self._conf = conf self.relative = relative super().__init__('', wrap='clip') self.set_title() def get_cursor_coords(self, size) -> Tuple[int, int]: return 0, 0 def render(self, size, focus=False): canv = super().render(size, focus) if focus: canv = urwid.CompositeCanvas(canv) canv.cursor = 0, 0 return canv @classmethod def selectable(cls) -> bool: return True @property def uid(self) -> str: return self.event.calendar + '\n' + \ str(self.event.href) + '\n' + str(self.event.etag) @property def recuid(self) -> Tuple[str, str]: return (self.uid, self.event.recurrence_id) def set_title(self, mark: str=' ') -> None: mark = { DeletionType.ALL: 'D', DeletionType.INSTANCES: 'd', None: '', }[self.delete_status(self.recuid)] if self.relative: format_ = self._conf['view']['agenda_event_format'] else: format_ = self._conf['view']['event_format'] formatter_ = utils.human_formatter(format_, colors=False) if self.this_date: date_ = self.this_date elif self.event.allday: date_ = self.event.start else: date_ = self.event.start.date() text = formatter_(self.event.attributes(date_, colors=False)) if self._conf['locale']['unicode_symbols']: newline = ' \N{LEFTWARDS ARROW WITH HOOK} ' else: newline = ' -- ' self.set_text(mark + ' ' + text.replace('\n', newline)) def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' elif key in binds['up']: key = 'up' elif key in binds['right']: key = 'right' elif key in binds['down']: key = 'down' return key class EventListBox(urwid.ListBox): """Container for list of U_Events""" def __init__( self, *args, parent, conf, delete_status, toggle_delete_instance, toggle_delete_all, set_focus_date_callback=None, **kwargs) -> None: self._init: bool = True self.parent: 'ClassicView' = parent self.delete_status = delete_status self.toggle_delete_instance = toggle_delete_instance self.toggle_delete_all = toggle_delete_all self._conf = conf self._old_focus = None self.set_focus_date_callback = set_focus_date_callback super().__init__(*args, **kwargs) def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: return super().keypress(size, key) @property def focus_event(self) -> Optional[U_Event]: if self.focus is None: return None else: return self.focus.original_widget def refresh_titles(self, min_date, max_date, everything): """Refresh only the currently focused event's title as we currently only use `EventListBox` in search and there we can only modify the currently focused event, no real implementation is needad at this time ignores all arguments """ self.focus.original_widget.set_title() class DListBox(EventListBox): """Container for a DayWalker""" # XXX unfortunate naming, there is also DateListBox def __init__(self, *args, **kwargs) -> None: dynamic_days = kwargs.pop('dynamic_days', True) super().__init__(*args, **kwargs) self._init = dynamic_days def render(self, size, focus=False): if self._init: self._init = False while not isinstance(self.body, StaticDayWalker) and 'bottom' in self.ends_visible(size): self.body._autoextend() return super().render(size, focus) def clean(self): """reset event most recently in focus""" if self._old_focus is not None: try: self.body[self._old_focus].body[0].set_attr_map({None: 'date'}) except IndexError: # after reseting the EventList, the old focus might not exist pass def ensure_date(self, day: dt.date) -> None: """ensure an entry for `day` exists and bring it into focus""" try: self._old_focus = self.focus_position except IndexError: pass self.body.ensure_date(day) self.clean() def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key in self._conf['keybindings']['up']: key = 'up' if key in self._conf['keybindings']['down']: key = 'down' if key in self._conf['keybindings']['today']: self.parent.calendar.base_widget.set_focus_date(dt.date.today()) rval = super().keypress(size, key) self.clean() if key in ['up', 'down']: try: self._old_focus = self.focus_position except IndexError: pass day = self.body[self.body.focus].date # we need to save DateListBox.selected_date and reset it later, because # calling CalendarWalker.set_focus_date() calls back into # DayWalker().update_by_date() which actually sets selected_date # that's why it's called callback hell... currently_selected_date = DateListBox.selected_date self.set_focus_date_callback(day) # TODO convert to callback DateListBox.selected_date = currently_selected_date return rval @property def focus_event(self): return self.body.focus_event @property def current_date(self) -> dt.date: return self.body.current_day def refresh_titles(self, start, end, recurring): self.body.refresh_titles(start, end, recurring) def update_date_line(self): self.body.update_date_line() class DayWalker(urwid.SimpleFocusListWalker): """A list Walker that contains a list of DateListBox objects, each representing one day and associated events""" def __init__(self, this_date, eventcolumn, conf, collection, delete_status) -> None: self.eventcolumn = eventcolumn self._conf = conf self.delete_status = delete_status self._init = True self._last_day = this_date self._first_day = this_date self._collection = collection super().__init__([]) self.ensure_date(this_date) def reset(self): """delete all events contained in this DayWalker""" self.clear() self._last_day = None self._first_day = None def ensure_date(self, day: dt.date) -> None: """make sure a DateListBox for `day` exists, update it and bring it into focus""" # TODO this function gets called twice on every date change, not necessary but # isn't very costly either if self.days_to_next_already_loaded(day) > 200: # arbitrary number self.reset() item_no = None if len(self) == 0: pile = self._get_events(day) self.append(pile) self._last_day = day self._first_day = day item_no = 0 while day < self[0].date: self._autoprepend() item_no = 0 while day > self[-1].date: self._autoextend() item_no = len(self) - 1 if item_no is None: item_no = (day - self[0].date).days assert self[item_no].date == day self[item_no].set_selected_date(day) self.set_focus(item_no) def days_to_next_already_loaded(self, day: dt.date) -> int: """return number of days until `day` is already loaded into the CalendarWidget""" if len(self) == 0: return 0 elif self[0].date <= day <= self[-1].date: return 0 elif day <= self[0].date: return (self[0].date - day).days elif self[-1].date <= day: return (day - self[-1].date).days else: raise ValueError("This should not happen") def update_events_ondate(self, day): """refresh the contents of the day's DateListBox""" offset = (day - self[0].date).days assert self[offset].date == day self[offset] = self._get_events(day) def refresh_titles(self, start: dt.date, end: dt.date, everything: bool): """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` """ start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end if everything: start = self[0].date end = self[-1].date else: start = max(self[0].date, start) end = min(self[-1].date, end) offset = (start - self[0].date).days length = (end - start).days for index in range(offset, offset + length + 1): self[index].refresh_titles() def update_range(self, start: dt.date, end: dt.date, everything: bool=False): """refresh contents of all days between start and end (inclusive)""" start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end if everything: start = self[0].date end = self[-1].date else: start = max(self[0].date, start) end = min(self[-1].date, end) day = start while day <= end: self.update_events_ondate(day) day += dt.timedelta(days=1) def update_date_line(self): for one in self: one.update_date_line() def set_focus(self, position): """set focus by item number""" while position >= len(self) - 1: self._autoextend() while position <= 0: self._autoprepend() position += 1 return super().set_focus(position) def _autoextend(self): self._last_day += dt.timedelta(days=1) pile = self._get_events(self._last_day) self.append(pile) def _autoprepend(self): """prepend the day before the first day to ourself""" # we need to actively reset the last element's attribute, as their # render() method does not get called otherwise, and they would # be indicated as the currently selected date self[self.focus or 0].reset_style() self._first_day -= dt.timedelta(days=1) pile = self._get_events(self._first_day) self.insert(0, pile) def _get_events(self, day: dt.date) -> urwid.Widget: """get all events on day, return a DateListBox of `U_Event()`s """ event_list = [] date_header = DateHeader( day=day, dateformat=self._conf['locale']['longdateformat'], conf=self._conf, ) event_list.append(urwid.AttrMap(date_header, 'date')) self.events = sorted(self._collection.get_events_on(day)) event_list.extend([ urwid.AttrMap( U_Event(event, conf=self._conf, this_date=day, delete_status=self.delete_status), 'calendar ' + event.calendar, 'reveal focus') for event in self.events]) return urwid.BoxAdapter( DateListBox(urwid.SimpleFocusListWalker(event_list), date=day), (len(event_list) + 1) if self.events else 1 ) def selectable(self) -> bool: """mark this widget as selectable""" return True @property def focus_event(self) -> U_Event: return self[self.focus].original_widget.focus_event @property def current_day(self) -> dt.date: return self[self.focus].original_widget.date @property def first_date(self) -> dt.date: return self[0].original_widget.date @property def last_date(self) -> dt.date: return self[-1].original_widget.date class StaticDayWalker(DayWalker): """Only show events for a fixed number of days.""" def ensure_date(self, day: dt.date) -> None: """make sure a DateListBox for `day` exists, update it and bring it into focus""" # TODO cache events for each day and update as needed num_days = max(1, self._conf['default']['timedelta'].days) for delta in range(num_days): pile = self._get_events(day + dt.timedelta(days=delta)) if len(self) <= delta: self.append(pile) else: self[delta] = pile assert self[0].date == day def update_events_ondate(self, day): """refresh the contents of the day's DateListBox""" self[0] = self._get_events(day) def refresh_titles(self, start: dt.date, end: dt.date, everything: bool) -> None: """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` """ # TODO: why are we not using the arguments? for one in self: one.refresh_titles() def update_range(self, start: dt.date, end: dt.date, everything: bool=False): """refresh contents of all days between start and end (inclusive)""" start = start.date() if isinstance(start, dt.datetime) else start end = end.date() if isinstance(end, dt.datetime) else end update = everything for one in self: if (start <= one.date <= end): update = True if update: self.ensure_date(self[0].date) def set_focus(self, position): """set focus by item number""" return urwid.SimpleFocusListWalker.set_focus(self, position) class DateListBox(urwid.ListBox): """A ListBox container containing all events for one specific date used with a SimpleFocusListWalker """ selected_date = None def __init__(self, content, date) -> None: self.date = date super().__init__(content) def __repr__(self) -> str: return f'' __str__ = __repr__ def render(self, size, focus): if focus: self.body[0].set_attr_map({None: 'date header focused'}) elif DateListBox.selected_date == self.date: self.body[0].set_attr_map({None: 'date header selected'}) else: self.reset_style() return super().render(size, focus) def reset_style(self): self.body[0].set_attr_map({None: 'date header'}) def set_selected_date(self, day: dt.date) -> None: """Mark `day` as selected :param day: day to mark as selected """ DateListBox.selected_date = day # we need to touch the title's content to make sure # that urwid re-renders the title title = self.body[0].original_widget title.set_text(title.get_text()[0]) @property def focus_event(self) -> Optional[U_Event]: if self.body.focus == 0: return None else: return self.focus.original_widget def refresh_titles(self): """refresh the titles of all events""" for uevent in self.body[1:]: if isinstance(uevent._original_widget, U_Event): uevent.original_widget.set_title() def update_date_line(self): """update the date text in the first line, e.g., if the current date changed""" self.body[0].original_widget.update_date_line() class EventColumn(urwid.WidgetWrap): """Container for list of events Handles modifying events, showing events' details and editing them """ def __init__(self, elistbox, pane) -> None: self.pane = pane self._conf = pane._conf self.divider = urwid.Divider('─') self.editor = False self._last_focused_date: Optional[dt.date] = None self._eventshown: Optional[Tuple[str, str]] = None self.event_width = int(self.pane._conf['view']['event_view_weighting']) self.delete_status = pane.delete_status self.toggle_delete_all = pane.toggle_delete_all self.toggle_delete_instance = pane.toggle_delete_instance self.dlistbox: DListBox = elistbox self.container = urwid.Pile([self.dlistbox]) urwid.WidgetWrap.__init__(self, self.container) @property def focus_event(self) -> Optional[U_Event]: """returns the event currently in focus""" return self.dlistbox.focus_event def view(self, event): """show event in the lower part of this column""" self.container.contents.append((self.divider, ('pack', None))) self.container.contents.append( (EventDisplay(self.pane._conf, event, collection=self.pane.collection), ('weight', self.event_width))) def clear_event_view(self): while len(self.container.contents) > 1: self.container.contents.pop() def set_focus_date(self, date): """We need this, so we can use it as a callback""" self.focus_date = date @property def focus_date(self) -> dt.date: return self.dlistbox.current_date @focus_date.setter def focus_date(self, date: dt.date) -> None: self._last_focused_date = date self.dlistbox.ensure_date(date) def update(self, min_date, max_date: dt.date, everything: bool): """update DateListBox if `everything` is True, reset all displayed dates, else only those between min_date and max_date """ if everything: min_date = self.pane.calendar.base_widget.walker.earliest_date max_date = self.pane.calendar.base_widget.walker.latest_date self.pane.base_widget.calendar.base_widget.reset_styles_range(min_date, max_date) if everything: min_date = self.dlistbox.body.first_date max_date = self.dlistbox.body.last_date self.dlistbox.body.update_range(min_date, max_date) def refresh_titles(self, min_date: dt.date, max_date: dt.date, everything: bool) -> None: """refresh titles in DateListBoxes if `everything` is True, reset all displayed dates, else only those between min_date and max_date """ self.dlistbox.refresh_titles(min_date, max_date, everything) def update_date_line(self) -> None: """refresh titles in DateListBoxes""" self.dlistbox.update_date_line() def edit(self, event, always_save: bool=False, external_edit: bool=False) -> None: """create an EventEditor and display it :param event: event to edit :type event: khal.event.Event :param always_save: even save the event if it hasn't changed """ if event.readonly: self.pane.window.alert( ('alert', f'Calendar `{event.calendar}` is read-only.')) return if isinstance(event.start_local, dt.datetime): original_start = event.start_local.date() else: original_start = event.start_local if isinstance(event.end_local, dt.datetime): original_end = event.end_local.date() else: original_end = event.end_local def update_colors(new_start: dt.date, new_end: dt.date, everything: bool=False): """reset colors in the calendar widget and dates in DayWalker between min(new_start, original_start) :param everything: set to True if event is a recurring one, than everything gets reseted """ # TODO cleverer support for recurring events, where more than start and # end dates are affected (complicated) if isinstance(new_start, dt.datetime): new_start = new_start.date() if isinstance(new_end, dt.datetime): new_end = new_end.date() start = min(original_start, new_start) end = max(original_end, new_end) self.pane.eventscolumn.base_widget.update(start, end, everything) # set original focus date self.pane.calendar.original_widget.set_focus_date(new_start) self.pane.eventscolumn.original_widget.set_focus_date(new_start) if self.editor: self.pane.window.backtrack() assert not self.editor if external_edit: self.pane.window.loop.stop() ics = click.edit(event.raw, extension=".ics") self.pane.window.loop.start() if ics is None: return # KeyErrors can occur here when we destroy DTSTART, # otherwise, even broken .ics files seem to be no problem new_event = self.pane.collection.create_event_from_ics( ics, etag=event.etag, calendar_name=event.calendar, href=event.href, ) self.pane.collection.update(new_event) update_colors( new_event.start_local, new_event.end_local, (event.recurring or new_event.recurring) ) else: self.editor = True editor = EventEditor(self.pane, event, update_colors, always_save=always_save) ContainerWidget = linebox[self.pane._conf['view']['frame']] new_pane = urwid.Columns([ ('weight', 2, CAttrMap(ContainerWidget(editor), 'editor', 'editor focus')), ('weight', 1, CAttrMap(ContainerWidget(self.dlistbox), 'reveal focus')), ], dividechars=0, focus_column=0) new_pane.title = editor.title def teardown(data): self.editor = False self.pane.window.open(new_pane, callback=teardown) def export_event(self): """export the event in focus as an ICS file""" def export_this(_, user_data): try: self.focus_event.event.export_ics(user_data.get_edit_text()) except Exception as error: self.pane.window.backtrack() self.pane.window.alert(('alert', 'Failed to save event: %s' % error)) else: self.pane.window.backtrack() self.pane.window.alert('Event successfully exported') overlay = urwid.Overlay( ExportDialog( export_this, self.pane.window.backtrack, self.focus_event.event, ), self.pane, 'center', ('relative', 50), ('relative', 50), None) self.pane.window.open(overlay) def toggle_delete(self): """toggle the delete status of the event in focus""" event = self.focus_event def delete_this(_): self.toggle_delete_instance(event.recuid) self.pane.window.backtrack() self.refresh_titles( event.event.start_local, event.event.end_local, event.event.recurring) def delete_all(_): self.toggle_delete_all(event.recuid) self.pane.window.backtrack() self.refresh_titles( event.event.start_local, event.event.end_local, event.event.recurring) if event.event.readonly: self.pane.window.alert( ('alert', f'Calendar {event.event.calendar} is read-only.'), ) return status = self.delete_status(event.recuid) refresh = True if status == DeletionType.ALL: self.toggle_delete_all(event.recuid) elif status == DeletionType.INSTANCES: self.toggle_delete_instance(event.recuid) elif event.event.recurring: # FIXME if in search results, original pane is used for overlay, not search results # also see issue of reseting titles below, probably related self.pane.dialog( text='This is a recurring event.\nWhich instances do you want to delete?', buttons=[ ('Only this', delete_this), ('All (past and future)', delete_all), ('Abort', self.pane.window.backtrack), ] ) refresh = False else: self.toggle_delete_all(event.recuid) if refresh: self.refresh_titles( event.event.start_local, event.event.end_local, event.event.recurring) event.set_title() # if we are in search results, refresh_titles doesn't work properly def duplicate(self) -> None: """duplicate the event in focus""" # TODO copying from birthday calendars is currently problematic # because their title is determined by X-BIRTHDAY and X-FNAME properties # which are also copied. If the events' summary is edited it will show # up on disk but not be displayed in khal if self.focus_event is None: return None event = self.focus_event.event.duplicate() try: self.pane.collection.insert(event) except ReadOnlyCalendarError: event.calendar = self.pane.collection.default_calendar_name or \ self.pane.collection.writable_names[0] self.edit(event, always_save=True) start_date, end_date = event.start_local, event.end_local if isinstance(start_date, dt.datetime): start_date = start_date.date() if isinstance(end_date, dt.datetime): end_date = end_date.date() self.pane.eventscolumn.base_widget.update(start_date, end_date, event.recurring) try: self._old_focus = self.focus_position except IndexError: pass def new(self, date: dt.date, end: Optional[dt.date]=None) -> None: """create a new event on `date` at the next full hour and edit it :param date: default date for new event :param end: date the event ends on (inclusive) """ dtstart: dt.date dtend: dt.date if not self.pane.collection.writable_names: self.pane.window.alert(('alert', 'No writable calendar.')) return if date is None: date = dt.datetime.now() if end is None: dtstart = dt.datetime.combine(date, dt.time(dt.datetime.now().hour)) dtend = dtstart + dt.timedelta(minutes=60) allday = False else: # TODO XXX why do we use allday if end is not set??? dtstart = date dtend = end + dt.timedelta(days=1) allday = True event = self.pane.collection.create_event_from_dict({ 'dtstart': dtstart, 'dtend': dtend, 'summary': '', 'timezone': self._conf['locale']['default_timezone'], 'allday': allday, 'alarms': timedelta2str(self._conf['default']['default_event_alarm']), }) self.edit(event) def selectable(self): return True def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: prev_shown = self._eventshown self._eventshown = None self.clear_event_view() if key in self._conf['keybindings']['new']: self.new(self.focus_date, None) key = None if self.focus_event: if key in self._conf['keybindings']['delete']: self.toggle_delete() key = 'down' elif key in self._conf['keybindings']['duplicate']: self.duplicate() key = None elif key in self._conf['keybindings']['export']: self.export_event() key = None rval = super().keypress(size, key) if self.focus_event: if key in self._conf['keybindings']['view'] and \ prev_shown == self.focus_event.recuid: # the event in focus is already viewed -> edit if self.delete_status(self.focus_event.recuid): self.pane.window.alert(('alert', 'This event is marked as deleted')) self.edit(self.focus_event.event) elif key in self._conf['keybindings']['external_edit']: self.edit(self.focus_event.event, external_edit=True) elif key in self._conf['keybindings']['view'] or \ self._conf['view']['event_view_always_visible']: self._eventshown = self.focus_event.recuid self.view(self.focus_event.event) return rval def render(self, a, focus): if focus: DateListBox.selected_date = None return super().render(a, focus) class EventDisplay(urwid.WidgetWrap): """A widget showing one Event()'s details """ def __init__(self, conf, event, collection=None) -> None: self._conf = conf self.collection = collection self.event = event divider = urwid.Divider(' ') lines = [] lines.append(urwid.Text('Title: ' + event.summary)) # show organizer if event.organizer != '': lines.append(urwid.Text('Organizer: ' + event.organizer)) if event.location != '': lines.append(urwid.Text('Location: ' + event.location)) if event.categories != '': lines.append(urwid.Text('Categories: ' + event.categories)) if event.url != '': lines.append(urwid.Text('URL: ' + event.url)) if event.attendees != '': lines.append(urwid.Text('Attendees:')) for attendee in event.attendees.split(', '): lines.append(urwid.Text(f' - {attendee}')) # start and end time/date if event.allday: startstr = event.start_local.strftime(self._conf['locale']['dateformat']) endstr = event.end_local.strftime(self._conf['locale']['dateformat']) else: startstr = event.start_local.strftime( f"{self._conf['locale']['dateformat']} " f"{self._conf['locale']['timeformat']}" ) if event.start_local.date == event.end_local.date: endstr = event.end_local.strftime(self._conf['locale']['timeformat']) else: endstr = event.end_local.strftime( f"{self._conf['locale']['dateformat']} " f"{self._conf['locale']['timeformat']}" ) if startstr == endstr: lines.append(urwid.Text('Date: ' + startstr)) else: lines.append(urwid.Text('Date: ' + startstr + ' - ' + endstr)) lines.append(urwid.Text('Calendar: ' + event.calendar)) lines.append(divider) if event.description != '': lines.append(urwid.Text(event.description)) pile = urwid.Pile(lines) urwid.WidgetWrap.__init__(self, urwid.Filler(pile, valign='top')) class SearchDialog(urwid.WidgetWrap): """A Search Dialog Widget""" def __init__(self, search_func, abort_func) -> None: class Search(Edit): def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key == 'enter': search_func(self.text) return None else: return super().keypress(size, key) search_field = Search('') def this_func(_): search_func(search_field.text) lines = [] lines.append(urwid.Text('Please enter a search term (Escape cancels):')) lines.append(urwid.AttrMap(search_field, 'edit', 'edit focused')) lines.append(urwid.Text('')) buttons = NColumns([ button('Search', on_press=this_func, padding_left=0), button('Abort', on_press=abort_func, padding_right=0), ]) lines.append(buttons) content = NPile(lines, outermost=True) urwid.WidgetWrap.__init__(self, urwid.LineBox(content)) class ClassicView(Pane): """default Pane for khal showing a CalendarWalker on the left and the eventList + eventviewer/editor on the right """ def __init__(self, collection, conf=None, title: str='', description: str='') -> None: self.init = True # Will be set when opening the view inside a Window self.window = None self._conf = conf self.collection = collection self._deleted: Dict[int, List[str]] = {DeletionType.ALL: [], DeletionType.INSTANCES: []} ContainerWidget = linebox[self._conf['view']['frame']] if self._conf['view']['dynamic_days']: Walker = DayWalker else: Walker = StaticDayWalker daywalker = Walker( dt.date.today(), eventcolumn=self, conf=self._conf, delete_status=self.delete_status, collection=self.collection, ) elistbox = DListBox( daywalker, parent=self, conf=self._conf, delete_status=self.delete_status, toggle_delete_all=self.toggle_delete_all, toggle_delete_instance=self.toggle_delete_instance, dynamic_days=self._conf['view']['dynamic_days'], ) self.eventscolumn = ContainerWidget( CAttrMap(EventColumn(pane=self, elistbox=elistbox), 'eventcolumn', 'eventcolumn focus', ), ) calendar = CAttrMap(CalendarWidget( on_date_change=self.eventscolumn.original_widget.set_focus_date, keybindings=self._conf['keybindings'], on_press={key: self.new_event for key in self._conf['keybindings']['new']}, firstweekday=self._conf['locale']['firstweekday'], weeknumbers=self._conf['locale']['weeknumbers'], monthdisplay=self._conf['view']['monthdisplay'], get_styles=collection.get_styles ), 'calendar', 'calendar focus') if self._conf['view']['dynamic_days']: elistbox.set_focus_date_callback = calendar.set_focus_date else: elistbox.set_focus_date_callback = lambda _: None self.calendar = ContainerWidget(calendar) self.lwidth = 31 if self._conf['locale']['weeknumbers'] == 'right' else 28 if self._conf['view']['frame'] in ["width", "color"]: self.lwidth += 2 columns = NColumns( [(self.lwidth, self.calendar), self.eventscolumn], dividechars=0, box_columns=[0, 1], outermost=True, ) Pane.__init__(self, columns, title=title, description=description) def delete_status(self, uid: str) -> Optional[DeletionType]: if uid[0] in self._deleted[DeletionType.ALL]: return DeletionType.ALL elif uid in self._deleted[DeletionType.INSTANCES]: return DeletionType.INSTANCES else: return None def toggle_delete_all(self, recuid: Tuple[str, str]) -> None: uid, _ = recuid if uid in self._deleted[DeletionType.ALL]: self._deleted[DeletionType.ALL].remove(uid) else: self._deleted[DeletionType.ALL].append(uid) def toggle_delete_instance(self, uid: str) -> None: if uid in self._deleted[DeletionType.INSTANCES]: self._deleted[DeletionType.INSTANCES].remove(uid) else: self._deleted[DeletionType.INSTANCES].append(uid) def cleanup(self, data): """delete all events marked for deletion""" # If we delete several recuids from the same vevent, the etag will # change after each deletion. As we check for matching etags before # deleting, deleting any subsequent recuids will fail. # We therefore keep track of the etags of the events we already # deleted. updated_etags = {} for part in self._deleted[DeletionType.ALL]: account, href, etag = part.split('\n', 2) self.collection.delete(href, etag, account) for part, rec_id in self._deleted[DeletionType.INSTANCES]: account, href, etag = part.split('\n', 2) etag = updated_etags.get(href) or etag event = self.collection.delete_instance(href, etag, account, rec_id) updated_etags[event.href] = event.etag def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['search']: self.search() return super().keypress(size, key) def search(self): """create a search dialog and display it""" overlay = urwid.Overlay( SearchDialog(self._search, self.window.backtrack), self, align='center', width=('relative', 70), valign=('relative', 50), height=None) self.window.open(overlay) def _search(self, search_term: str) -> None: """search for events matching `search_term""" assert self.window is not None self.window.backtrack() events = sorted(self.collection.search(search_term)) event_list = [] event_list.extend([ urwid.AttrMap( U_Event(event, relative=False, conf=self._conf, delete_status=self.delete_status), 'calendar ' + event.calendar, 'reveal focus') for event in events]) events = EventListBox( urwid.SimpleFocusListWalker(event_list), parent=self.eventscolumn, conf=self._conf, delete_status=self.delete_status, toggle_delete_all=self.toggle_delete_all, toggle_delete_instance=self.toggle_delete_instance ) events = EventColumn(pane=self, elistbox=events) ContainerWidget = linebox[self._conf['view']['frame']] columns = NColumns( [(self.lwidth, self.calendar), ContainerWidget(events)], dividechars=0, box_columns=[0, 0], outermost=True, ) pane = Pane( columns, title=f"Search results for \"{search_term}\" (Esc for backtrack)", ) pane._conf = self._conf columns.set_focus_column(1) self.window.open(pane) def render(self, size, focus=False): rval = super().render(size, focus) if self.init: # starting with today's events self.eventscolumn.current_date = dt.date.today() self.init = False return rval def new_event(self, date, end): """create a new event starting on date and ending on end (if given)""" self.eventscolumn.original_widget.new(date, end) def _urwid_palette_entry( name: str, color: str, hmethod: str, color_mode: Literal['256colors', 'rgb'], foreground: str = '', background: str = '', ) -> Tuple[str, str, str, str, str, str]: """Create an urwid compatible palette entry. :param name: name of the new attribute in the palette :param color: color for the new attribute :param hmethod: which highlighting mode to use, foreground or background :param color_mode: which color mode we are in, if we are in 256-color mode, we transform 24-bit/RGB colors to a (somewhat) matching 256-color set color :param foreground: the foreground color to apply if we use background highlighting method :param background: the background color to apply if we use foreground highlighting method :returns: an urwid palette entry """ from ..terminal import COLORS if color == '' or color in COLORS or color is None: # Named colors already use urwid names, no need to change anything. pass elif color.isdigit(): # Colors from the 256 color palette need to be prefixed with h in # urwid. color = 'h' + color elif color_mode == '256color': # Convert to some color on the 256 color palette that might resemble # the 24-bit color. # First, generate the palette (indices 16-255 only). This assumes, that # the terminal actually uses the same palette, which may or may not be # the case. colors = {} # Colorcube colorlevels = (0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff) for r in range(0, 6): for g in range(0, 6): for b in range(0, 6): colors[r * 36 + g * 6 + b + 16] = \ (colorlevels[r], colorlevels[g], colorlevels[b]) # Grayscale graylevels = [0x08 + 10 * i for i in range(0, 24)] for c in range(0, 24): colors[232 + c] = (graylevels[c], ) * 3 # Parse the HTML-style color into the variables r, g, b. if len(color) == 4: # e.g. #ABC, equivalent to #AABBCC r = int(color[1] * 2, 16) g = int(color[2] * 2, 16) b = int(color[3] * 2, 16) else: # e.g. #AABBCC r = int(color[1:3], 16) g = int(color[3:5], 16) b = int(color[5:7], 16) # Now, find the color with the least distance to the requested color. best = None bestdist = 0.0 for index, rgb in colors.items(): # This is the euclidean distance metric. It is quick, simple and # wrong (in the sense of human color perception). However, any # serious color distance metric would be way more complicated. dist = (r - rgb[0]) ** 2 + (g - rgb[1]) ** 2 + (b - rgb[2]) ** 2 if best is None or dist < bestdist: best = index bestdist = dist color = 'h' + str(best) # We unconditionally add the color to the high color slot. It seems to work # in lower color terminals as well. if hmethod in ['fg', 'foreground']: return (name, '', '', '', color, background) else: return (name, '', '', '', foreground, color) def _add_calendar_colors( palette: List[Tuple[str, ...]], collection: 'CalendarCollection', color_mode: Literal['256colors', 'rgb'], base: Optional[str] = None, attr_template: str = 'calendar {}', ) -> List[Tuple[str, ...]]: """Add the colors for the defined calendars to the palette. We support setting a fixed background or foreground color that we extract from a giving attribute :param palette: the base palette :param collection: :param color_mode: which color mode we are in :param base: the attribute to extract the background and foreground color from :param attr_template: the template to use for the attribute name :returns: the modified palette """ bg_color, fg_color = '', '' for attr in palette: if base and attr[0] == base: if color_mode == 'rgb' and len(attr) >= 5: bg_color = attr[5] fg_color = attr[4] else: bg_color = attr[2] fg_color = attr[1] for cal in collection.calendars: if cal['color'] == '': # No color set for this calendar, use default_color instead. color = collection.default_color else: color = cal['color'] # In case the color contains an alpha value, remove it for urwid. # eg '#RRGGBBAA' -> '#RRGGBB' and '#RGBA' -> '#RGB'. if color and len(color) == 9 and color[0] == '#': color = color[0:7] elif color and len(color) == 5 and color[0] == '#': color = color[0:4] entry = _urwid_palette_entry( attr_template.format(cal['name']), color, collection.hmethod, color_mode=color_mode, foreground=fg_color, background=bg_color, ) palette.append(entry) entry = _urwid_palette_entry( 'highlight_days_color', collection.color, collection.hmethod, color_mode=color_mode, foreground=fg_color, background=bg_color, ) palette.append(entry) entry = _urwid_palette_entry('highlight_days_multiple', collection.multiple, collection.hmethod, color_mode=color_mode, foreground=fg_color, background=bg_color) palette.append(entry) return palette def start_pane( pane, callback, program_info='', quit_keys=None, color_mode: Literal['rgb', '256colors']='rgb', ): """Open the user interface with the given initial pane.""" # We don't validate the themes in settings.spec but instead here # first try to load built-in themes, then try to load themes from # plugins theme = colors.themes.get(pane._conf['view']['theme']) if theme is None: theme = plugins.THEMES.get(pane._conf['view']['theme']) if theme is None: logger.fatal(f'Invalid theme {pane._conf["view"]["theme"]} configured') logger.fatal(f'Available themes are: {", ".join(colors.themes.keys())}') raise FatalError quit_keys = quit_keys or ['q'] frame = Window( footer=program_info + f' | {quit_keys[0]}: quit, ?: help', quit_keys=quit_keys, ) class LogPaneFormatter(logging.Formatter): def get_prefix(self, level): if level >= 50: return 'CRITICAL' if level >= 40: return 'ERROR' if level >= 30: return 'WARNING' if level >= 20: return 'INFO' else: return 'DEBUG' def format(self, record) -> str: return f'{self.get_prefix(record.levelno)}: {record.msg}' class HeaderFormatter(LogPaneFormatter): def format(self, record): return ( super().format(record)[:30] + '... ' f"[Press `{pane._conf['keybindings']['log'][0]}` to view log]" ) class LogPaneHandler(logging.Handler): def emit(self, record): frame.log(self.format(record)) class LogHeaderHandler(logging.Handler): def emit(self, record): frame.alert(self.format(record)) if len(logger.handlers) > 0 and not isinstance(logger.handlers[-1], logging.FileHandler): logger.handlers.pop() pane_handler = LogPaneHandler() pane_handler.setFormatter(LogPaneFormatter()) logger.addHandler(pane_handler) header_handler = LogHeaderHandler() header_handler.setFormatter(HeaderFormatter()) logger.addHandler(header_handler) frame.open(pane, callback) palette = _add_calendar_colors( theme, pane.collection, color_mode=color_mode, base='calendar', attr_template='calendar {}', ) palette = _add_calendar_colors( palette, pane.collection, color_mode=color_mode, base='popupbg', attr_template='calendar {} popup', ) def merge_palettes(pallete_a, pallete_b) -> List[Tuple[str, ...]]: """Merge two palettes together, with the second palette taking priority.""" merged = {} for entry in pallete_a: merged[entry[0]] = entry for entry in pallete_b: merged[entry[0]] = entry return list(merged.values()) overwrite = [(key, *values) for key, values in pane._conf['palette'].items()] palette = merge_palettes(palette, overwrite) loop = urwid.MainLoop( widget=frame, palette=palette, unhandled_input=frame.on_key_press, pop_ups=True, handle_mouse=pane._conf['default']['enable_mouse'], ) frame.loop = loop def redraw_today(loop, pane, meta=None): meta = meta or {'last_today': None} # XXX TODO this currently assumes, today moves forward by exactly one # day, but it could either move forward more (suspend-to-disk/ram) or # even move backwards today = dt.date.today() if meta['last_today'] != today: meta['last_today'] = today pane.calendar.original_widget.reset_styles_range(today - dt.timedelta(days=1), today) pane.eventscolumn.original_widget.update_date_line() loop.set_alarm_in(60, redraw_today, pane) loop.set_alarm_in(60, redraw_today, pane) def check_for_updates(loop, pane): if pane.collection.needs_update(): pane.window.alert('detected external vdir modification, updating...') pane.collection.update_db() pane.eventscolumn.base_widget.update(None, None, everything=True) pane.window.alert('detected external vdir modification, updated.') loop.set_alarm_in(60, check_for_updates, pane) loop.set_alarm_in(60, check_for_updates, pane) colors_ = 2**24 if color_mode == 'rgb' else 256 loop.screen.set_terminal_properties( colors=colors_, bright_is_bold=pane._conf['view']['bold_for_light_color'], ) def ctrl_c(signum, f): raise urwid.ExitMainLoop() signal.signal(signal.SIGINT, ctrl_c) try: loop.run() except Exception: import traceback tb = traceback.format_exc() try: # Try to leave terminal in usable state loop.stop() except Exception: pass print(tb) sys.exit(1) khal-0.11.4/khal/ui/base.py000066400000000000000000000224611477603436700153620ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """this module should contain classes that are specific to ikhal, more general widgets should go in widgets.py""" from __future__ import annotations import logging import threading import time from typing import Callable import urwid from .widgets import NColumns logger = logging.getLogger('khal') class Pane(urwid.WidgetWrap): """An abstract Pane to be used in a Window object.""" def __init__(self, widget, title=None, description=None) -> None: self.widget = widget urwid.WidgetWrap.__init__(self, widget) self._title = title or '' self._description = description or '' self.window = None @property def title(self): return self._title def selectable(self): """mark this widget as selectable""" return True @property def description(self): return self._description def dialog(self, text: str, buttons: list[tuple[str, Callable]]) -> None: """Open a dialog box. :param text: Text to appear as the body of the Dialog box :param buttons: list of tuples button labels and functions to call when the button is pressed """ lines = [urwid.Text(line) for line in text.splitlines()] buttons = NColumns( [urwid.Button(label, on_press=func) for label, func in buttons], outermost=True, ) lines.append(buttons) content = urwid.LineBox(urwid.Pile(lines)) overlay = urwid.Overlay(content, self, 'center', ('relative', 70), ('relative', 70), None) assert self.window is not None self.window.open(overlay) def scrollable_dialog(self, text: str | list[urwid.Text], buttons: list[tuple[str, Callable]] | None = None, title: str = "Press `ESC` to close this window", ) -> None: """Open a scrollable dialog box. :param text: Text to appear as the body of the Dialog box :param buttons: button labels and functions to call when the button is pressed """ if isinstance(text, str): body = urwid.ListBox([urwid.Text(line) for line in text.splitlines()]) else: body = urwid.ListBox(text) if buttons: buttons = NColumns( [urwid.Button(label, on_press=func) for label, func in buttons], outermost=True, ) content = urwid.LineBox(urwid.Pile([body, ('pack', buttons)])) else: content = urwid.LineBox(urwid.Pile([body])) # put the title on the upper line over = urwid.Overlay( urwid.Text(" " + title + " "), content, 'center', len(title) + 2, 'top', None, ) overlay = urwid.Overlay( over, self, 'center', ('relative', 70), 'middle', ('relative', 70), None) assert self.window is not None self.window.open(overlay) def keypress(self, size, key): """Handle application-wide key strokes.""" if key in ['f1', '?']: self.show_keybindings() elif key in ['L']: self.show_log() else: return super().keypress(size, key) def show_keybindings(self): lines = [] lines.append(urwid.AttrMap(urwid.Text(' Command Keys'), 'alt header')) for command, keys in self._conf['keybindings'].items(): lines.append(urwid.Text(f' {command:20} {", ".join(keys)}')) self.scrollable_dialog( lines, title="Press `ESC` to close this window, arrows to scroll", ) def show_log(self): self.scrollable_dialog( '\n'.join(self.window._log), title="Press `ESC` to close this window, arrows to scroll", ) class Window(urwid.Frame): """The main user interface frame. A window is a frame which displays a header, a footer and a body. The header and the footer are handled by this object, and the body is the space where Panes can be displayed. Each Pane is an interface to interact with the database in one way: list the VCards, edit one VCard, and so on. The Window provides a mechanism allowing the panes to chain themselves, and to carry data between them. """ def __init__(self, footer='', quit_keys=None) -> None: quit_keys = quit_keys or ['q'] self._track: list[urwid.Overlay] = [] header = urwid.AttrWrap(urwid.Text(''), 'header') footer = urwid.AttrWrap(urwid.Text(footer), 'footer') urwid.Frame.__init__( self, urwid.Text(''), header=header, footer=footer, ) self.update_header() self._original_w = None self.quit_keys = quit_keys def alert(message): self.update_header(message, warn=True) self._alert_daemon = AlertDaemon(alert) self._alert_daemon.start() self.alert = self._alert_daemon.alert self.loop = None self._log: list[str] = [] self._header_is_warning = False def open(self, pane, callback=None): """Open a new pane. The given pane is added to the track and opened. If the given callback is not None, it will be called when this new pane will be closed. """ pane.window = self self._track.append((pane, callback)) self._update(pane) def backtrack(self, data=None): """Unstack the displayed pane. The current pane is discarded, and the previous one is displayed. If the current pane was opened with a callback, this callback is called with the given data (if any) before the previous pane gets redrawn. """ old_pane, cb = self._track.pop() if cb: cb(data) if self._track: self._update(self._get_current_pane()) else: raise urwid.ExitMainLoop() def is_top_level(self): """Is the current pane the top-level one? """ return len(self._track) == 1 def on_key_press(self, key): """Handle application-wide key strokes.""" if key in self.quit_keys: self.backtrack() elif key == 'esc' and not self.is_top_level(): self.backtrack() return key def _update(self, pane): self.set_body(pane) self.clear_header() def log(self, record: str): self._log.append(record) def _get_current_pane(self): return self._track[-1][0] if self._track else None def clear_header(self): """clears header if we are not currently showing a warning""" if not self._header_is_warning: pane_title = getattr(self._get_current_pane(), 'title', '') self.header.w.set_text(pane_title) def update_header(self, alert=None, warn=False): """Update the Windows header line. :param alert: additional text to show in header, additionally to the current title. If `alert` is a tuple, the first entry must be a valid palette entry :type alert: str or (palette_entry, str) """ self._header_is_warning = warn pane_title = getattr(self._get_current_pane(), 'title', None) text = [] for part in (pane_title, alert): if part: text.append(part) text.append(('black', ' | ')) self.header.w.set_text(text[:-1] or '') class AlertDaemon(threading.Thread): def __init__(self, set_msg_func) -> None: threading.Thread.__init__(self) self._set_msg_func = set_msg_func self.daemon = True self._start_countdown = threading.Event() def alert(self, msg): self._set_msg_func(msg) self._start_countdown.set() def run(self): # This is a daemon thread. Since the global namespace is going to # vanish on interpreter shutdown, redefine everything from the global # namespace here. _sleep = time.sleep _exception = Exception _event = self._start_countdown _set_msg = self._set_msg_func while True: _event.wait() _sleep(3) try: _set_msg(None) except _exception: pass _event.clear() khal-0.11.4/khal/ui/calendarwidget.py000066400000000000000000000671721477603436700174350ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """Contains a re-usable CalendarWidget for urwid. if anything doesn't work as expected, please open an issue for khal """ import calendar import datetime as dt from locale import LC_ALL, LC_TIME, getlocale, setlocale from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, Union import urwid from khal.utils import get_month_abbr_len # Some custom types class MarkType(TypedDict): date: dt.date pos: Tuple[int, int] OnPressType = Dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]] GetStylesSignature = Callable[[dt.date, bool], Optional[Union[str, Tuple[str, str]]]] setlocale(LC_ALL, '') def getweeknumber(day: dt.date) -> int: """return iso week number for datetime.date object :param day: date :return: weeknumber """ return dt.date.isocalendar(day)[1] class DatePart(urwid.Text): """used in the Date widget (single digit)""" def __init__(self, digit: str) -> None: super().__init__(digit) @classmethod def selectable(cls: type) -> bool: return True def keypress(self, size: Tuple[int], key: str) -> str: return key def get_cursor_coords(self, size: Tuple[int]) -> Tuple[int, int]: return 1, 0 def render(self, size: Tuple[int], focus: bool=False) -> urwid.Canvas: canv = super().render(size, focus) if focus: canv = urwid.CompositeCanvas(canv) canv.cursor = 1, 0 return canv class Date(urwid.WidgetWrap): """used in the main calendar for dates (a number)""" def __init__(self, date: dt.date, get_styles: GetStylesSignature) -> None: dstr = str(date.day).rjust(2) self.halves = [urwid.AttrMap(DatePart(dstr[:1]), None, None), urwid.AttrMap(DatePart(dstr[1:]), None, None)] self.date = date self._get_styles = get_styles super().__init__(urwid.Columns(self.halves)) def set_styles(self, styles: Union[None, str, Tuple[str, str]]) -> None: """If single string, sets the same style for both halves, if two strings, sets different style for each half. """ if type(styles) is tuple: self.halves[0].set_attr_map({None: styles[0]}) self.halves[1].set_attr_map({None: styles[1]}) self.halves[0].set_focus_map({None: styles[0]}) self.halves[1].set_focus_map({None: styles[1]}) else: self.halves[0].set_attr_map({None: styles}) self.halves[1].set_attr_map({None: styles}) self.halves[0].set_focus_map({None: styles}) self.halves[1].set_focus_map({None: styles}) def reset_styles(self, focus: bool=False) -> None: self.set_styles(self._get_styles(self.date, focus)) @property def marked(self) -> bool: if 'mark' in [self.halves[0].attr_map[None], self.halves[1].attr_map[None]]: return True else: return False @classmethod def selectable(cls) -> bool: return True def keypress(self, _: Any, key: str) -> str: return key class DateCColumns(urwid.Columns): """container for one week worth of dates which are horizontally aligned TODO: rename, awful name focus can only move away by pressing 'TAB', calls 'on_date_change' on every focus change (see below for details) """ # TODO only call on_date_change when we change our date ourselves, # not if it gets changed by an (external) call to set_focus_date() def __init__(self, widget_list, on_date_change: Callable[[dt.date], None], on_press: OnPressType, keybindings: Dict[str, List[str]], get_styles: GetStylesSignature, **kwargs) -> None: self.on_date_change = on_date_change self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles self._init: bool = True super().__init__(widget_list, **kwargs) def __repr__(self) -> str: return f'' def _clear_cursor(self) -> None: old_pos: int = self.focus_position self.contents[old_pos][0].set_styles( self.get_styles(self.contents[old_pos][0].date, False)) @property def focus_position(self) -> int: """returns the current focus position""" return urwid.Columns.focus_position.fget(self) @focus_position.setter def focus_position(self, position: int) -> None: """calls on_date_change before setting super().focus_position""" # do not call when building up the interface, lots of potentially # expensive calls made here if self._init: self._init = False else: self._clear_cursor() self.contents[position][0].set_styles( self.get_styles(self.contents[position][0].date, True)) self.on_date_change(self.contents[position][0].date) urwid.Columns.focus_position.fset(self, position) def set_focus_date(self, a_date: dt.date) -> None: for num, day in enumerate(self.contents[1:8], 1): if day[0].date == a_date: self.focus_position = num return None raise ValueError('%s not found in this week' % a_date) def get_date_column(self, a_date: dt.date) -> int: """return the column `a_date` is in, raises ValueError if `a_date` cannot be found """ for num, day in enumerate(self.contents[1:8], 1): if day[0].date == a_date: return num raise ValueError('%s not found in this week' % a_date) def keypress(self, size: Tuple[int], key: str) -> str: """only leave calendar area on pressing 'tab' or 'enter'""" if key in self.keybindings['left']: key = 'left' elif key in self.keybindings['up']: key = 'up' elif key in self.keybindings['right']: key = 'right' elif key in self.keybindings['down']: key = 'down' exit_row = False # set this, if we are leaving the current row old_pos = self.focus_position key = super().keypress(size, key) # make sure we don't leave the calendar if old_pos == 7 and key == 'right': self.focus_position = 1 exit_row = True key = 'down' elif old_pos == 1 and key == 'left': self.focus_position = 7 exit_row = True key = 'up' elif key in self.keybindings['view']: # TODO make this more generic self.focus_position = old_pos key = 'right' elif key in ['up', 'down']: exit_row = True if exit_row: self._clear_cursor() return key class CListBox(urwid.ListBox): """our custom version of ListBox containing a CalendarWalker instance it should contain a `CalendarWalker` instance which it autoextends on rendering, if needed """ def __init__(self, walker: 'CalendarWalker') -> None: self._init: bool = True self.keybindings = walker.keybindings self.on_press = walker.on_press self._marked: Optional[MarkType] = None self._pos_old: Optional[Tuple[int, int]] = None self.body: 'CalendarWalker' super().__init__(walker) @property def focus_position(self) -> int: return super().focus_position @focus_position.setter def focus_position(self, position: int) -> None: super().set_focus(position) def render(self, size: Tuple[int], focus: bool=False) -> urwid.Canvas: while 'bottom' in self.ends_visible(size): self.body._autoextend() if self._init: self.set_focus_valign('middle') self._init = False return super().render(size, focus) def mouse_event(self, *args): size, event, button, col, row, focus = args if event == 'mouse press' and button == 1: self.focus.focus.set_styles( self.focus.get_styles(self.body.focus_date, False)) return super().mouse_event(*args) def _date(self, row: int, column: int) -> dt.date: """return the date at row `row` and column `column`""" return self.body[row].contents[column][0].date def _unmark_one(self, row: int, column: int) -> None: """remove attribute *mark* from the date at row `row` and column `column` returning it to the attributes defined by self._get_color() """ self.body[row].contents[column][0].reset_styles() def _mark_one(self, row: int, column: int) -> None: """set attribute *mark* on the date at row `row` and column `column`""" self.body[row].contents[column][0].set_styles('mark') def _mark(self, a_date: Optional[dt.date]=None) -> None: """make sure everything between the marked entry and `a_date` is visually marked, and nothing else""" assert self._marked is not None if a_date is None: a_date = self.body.focus_date def toggle(row: int, column: int) -> None: """toggle the mark attribute on the date at row `row` and column `column`""" if self.body[row].contents[column][0].marked: self._mark_one(row, column) else: self._unmark_one(row, column) start = min(self._marked['pos'][0], self.focus_position) - 2 stop = max(self._marked['pos'][0], self.focus_position) + 2 for row in range(start, stop): for col in range(1, 8): if a_date > self._marked['date']: if self._marked['date'] <= self._date(row, col) <= a_date: self._mark_one(row, col) else: self._unmark_one(row, col) else: if self._marked['date'] >= self._date(row, col) >= a_date: self._mark_one(row, col) else: self._unmark_one(row, col) toggle(self.focus_position, self.focus.focus_col) self._pos_old = self.focus_position, self.focus.focus_col def _unmark_all(self) -> None: """remove attribute *mark* from all dates""" if self._marked and self._pos_old: start = min(self._marked['pos'][0], self.focus_position, self._pos_old[0]) end = max(self._marked['pos'][0], self.focus_position, self._pos_old[0]) + 1 for row in range(start, end): for col in range(1, 8): self._unmark_one(row, col) def set_focus_date(self, a_day: dt.date) -> None: """set focus to the date `a_day`""" self.focus.focus.set_styles(self.focus.get_styles(self.body.focus_date, False)) if self._marked: self._unmark_all() self._mark(a_day) self.body.set_focus_date(a_day) def keypress(self, size: bool, key: str) -> Optional[str]: if key in self.keybindings['mark'] + ['esc'] and self._marked: self._unmark_all() self._marked = None return None if key in self.keybindings['mark']: self._marked = {'date': self.body.focus_date, 'pos': (self.focus_position, self.focus.focus_col)} if self._marked and key in self.keybindings['other']: row, col = self._marked['pos'] self._marked = {'date': self.body.focus_date, 'pos': (self.focus_position, self.focus.focus_col)} self.focus.focus_col = col self.focus_position = row if key in self.on_press: if self._marked: start = min(self.body.focus_date, self._marked['date']) end = max(self.body.focus_date, self._marked['date']) else: start = self.body.focus_date end = None return self.on_press[key](start, end) if key in self.keybindings['today'] + ['page down', 'page up']: # reset colors of currently focused Date widget self.focus.focus.set_styles(self.focus.get_styles(self.body.focus_date, False)) if key in self.keybindings['today']: self.set_focus_date(dt.date.today()) self.set_focus_valign(('relative', 10)) key = super().keypress(size, key) if self._marked: self._mark() return key class CalendarWalker(urwid.SimpleFocusListWalker): def __init__(self, on_date_change: Callable[[dt.date], None], on_press: Dict[str, Callable[[dt.date, Optional[dt.date]], Optional[str]]], keybindings: Dict[str, List[str]], get_styles: GetStylesSignature, firstweekday: int = 0, weeknumbers: Literal['left', 'right', False]=False, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', initial: Optional[dt.date]=None, ) -> None: self.firstweekday = firstweekday self.weeknumbers = weeknumbers self.monthdisplay = monthdisplay self.on_date_change = on_date_change self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles self.reset(initial) def reset(self, initial: Optional[dt.date]=None) -> None: if initial is None: initial = dt.date.today() weeks = self._construct_month(initial.year, initial.month) urwid.SimpleFocusListWalker.__init__(self, weeks) def set_focus(self, position: int) -> None: """set focus by item number""" while position >= len(self) - 1: self._autoextend() while position <= 0: no_additional_weeks = self._autoprepend() position += no_additional_weeks urwid.SimpleFocusListWalker.set_focus(self, position) def days_to_next_already_loaded(self, day: dt.date) -> int: """return the number of weeks from the focus to the next week that is already loaded""" if len(self) == 0: return 0 elif self.earliest_date <= day <= self.latest_date: return 0 elif day <= self.earliest_date: return (self.earliest_date - day).days elif self.latest_date <= day: return (day - self.latest_date).days else: raise ValueError("This should not happen") @property def focus_date(self) -> dt.date: """return the date the focus is currently set to""" return self[self.focus].focus.date def set_focus_date(self, a_day: dt.date) -> None: """set the focus to `a_day`""" if self.days_to_next_already_loaded(a_day) > 200: # arbitrary number self.reset(a_day) row, column = self.get_date_pos(a_day) self.set_focus(row) self[self.focus].focus_position = (column) @property def earliest_date(self) -> dt.date: """return earliest day that is already loaded into the CalendarWidget""" return self[0][1].date @property def latest_date(self) -> dt.date: """return latest day that is already loaded into the CalendarWidget""" return self[-1][7].date def reset_styles_range(self, min_date: dt.date, max_date: dt.date) -> None: """reset styles for all (displayed) dates between min_date and max_date""" minr, minc = self.get_date_pos(max(min_date, self.earliest_date)) maxr, maxc = self.get_date_pos(min(max_date, self.latest_date)) focus_pos = self.focus, self[self.focus].focus_col for row in range(minr, maxr + 1): for column in range(1, 8): focus = ((row, column) == focus_pos) self[row][column].reset_styles(focus) def get_date_pos(self, a_day: dt.date) -> Tuple[int, int]: """get row and column where `a_day` is located""" # rough estimate of difference in lines, i.e. full weeks, we might be # off by as much as one week though week_diff = int((self.focus_date - a_day).days / 7) new_focus = self.focus - week_diff # in case new_focus is 1 we will later try set the focus to 0 which # will lead to an autoprepend which will f*ck up our estimation, # therefore better autoprepending anyway, even if it might not be # necessary while new_focus <= 1: self._autoprepend() week_diff = int((self.focus_date - a_day).days / 7) new_focus = self.focus - week_diff for offset in [0, -1, 1]: # we might be off by a week row = new_focus + offset try: while row >= len(self): self._autoextend() column = self[row].get_date_column(a_day) return row, column except (ValueError, IndexError): pass # we didn't find the date we were looking for... raise ValueError('something is wrong') def _autoextend(self) -> None: """appends the next month""" date_last_month = self[-1][1].date # a date from the last month last_month = date_last_month.month last_year = date_last_month.year month = last_month % 12 + 1 year = last_year if not last_month == 12 else last_year + 1 weeks = self._construct_month(year, month, clean_first_row=True) self.extend(weeks) def _autoprepend(self) -> int: """prepends the previous month :returns: number of weeks prepended """ try: date_first_month = self[0][-1].date # a date from the first month except AttributeError: # rightmost column is weeknumber date_first_month = self[0][-2].date first_month = date_first_month.month first_year = date_first_month.year if first_month == 1: month = 12 year = first_year - 1 else: month = first_month - 1 year = first_year weeks = self._construct_month(year, month, clean_last_row=True) weeks.reverse() for one in weeks: self.insert(0, one) return len(weeks) def _construct_week(self, week: List[dt.date]) -> DateCColumns: """ constructs a CColumns week from a week of datetime.date objects. Also prepends the month name if the first day of the month is included in that week. :param week: list of datetime.date objects :returns: the week as an CColumns object """ if self.monthdisplay == 'firstday' and 1 in (day.day for day in week): month_name = calendar.month_abbr[week[-1].month].ljust(4) attr = 'monthname' elif self.monthdisplay == 'firstfullweek' and week[0].day <= 7: month_name = calendar.month_abbr[week[-1].month].ljust(4) attr = 'monthname' elif self.weeknumbers == 'left': month_name = f' {getweeknumber(week[0]):2} ' attr = 'weeknumber_left' else: month_name = ' ' attr = None this_week: List[Tuple[int, Union[Date, urwid.AttrMap]]] this_week = [(get_month_abbr_len(), urwid.AttrMap(urwid.Text(month_name), attr))] for _number, day in enumerate(week): new_date = Date(day, self.get_styles) this_week.append((2, new_date)) new_date.set_styles(self.get_styles(new_date.date, False)) if self.weeknumbers == 'right': this_week.append((2, urwid.AttrMap( urwid.Text(f'{getweeknumber(week[0]):2}'), 'weeknumber_right'))) week = DateCColumns(this_week, on_date_change=self.on_date_change, on_press=self.on_press, keybindings=self.keybindings, dividechars=1, get_styles=self.get_styles) return week def _construct_month(self, year: int=dt.date.today().year, month: int=dt.date.today().month, clean_first_row: bool=False, clean_last_row: bool=False, ) -> List[DateCColumns]: """construct one month of DateCColumns :param year: the year this month is set in :param month: the number of the month to be constructed :param clean_first_row: if set, makes sure that the first element returned is completely in `month` and not partly in the one before (which might lead to that line occurring twice :param clean_last_row: if set, makes sure that the last element returned is completely in `month` and not partly in the one after (which might lead to that line occurring twice) :returns: list of DateCColumns and the number of the list element which contains today (or None if it isn't in there) """ plain_weeks = calendar.Calendar(self.firstweekday).monthdatescalendar(year, month) weeks = [] for _number, week in enumerate(plain_weeks): weeks.append(self._construct_week(week)) if clean_first_row and weeks[0][1].date.month != weeks[0][7].date.month: return weeks[1:] elif clean_last_row and weeks[-1][1].date.month != weeks[-1][7].date.month: return weeks[:-1] else: return weeks class CalendarWidget(urwid.WidgetWrap): def __init__(self, on_date_change: Callable[[dt.date], None], keybindings: Dict[str, List[str]], on_press: Optional[OnPressType]=None, firstweekday: int=0, weeknumbers: Literal['left', 'right', False]=False, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', get_styles: Optional[GetStylesSignature]=None, initial: Optional[dt.date]=None, ) -> None: """A calendar widget that can be used in urwid applications :param on_date_change: a function that is called every time the selected date is changed with the newly selected date as an argument :param keybindings: bind keys to specific functionionality, keys are the available commands, values are lists of keys that should be bound to those commands. See below for the defaults. Available commands: 'left', 'right', 'up', 'down': move cursor in that direction 'today': refocus on today 'mark': toggles selection mode 'other': toggles between selecting the earlier and the later end of a selection 'view': returns the key `right` to the widget containing the CalendarWidget :param on_press: dictonary of functions that are called when the key is pressed and is not already bound to one of the internal functions via `keybindings`. These functions must accept two arguments, in normal mode the first argument is the currently selected date (datetime.date) and the second is `None`. When a date range is selected, the first argument is the earlier, the second argument is the later date. The function's return values are interpreted as pressed keys, which are handed to the widget containing the CalendarWidget. :param firstweekday: the first day of the week, 0 for Monday, 6 for :param weeknumbers: display weeknumbers on the left or right side of the calendar. :param monthdisplay: display the month name in the row of the 1st of the month or in the first row that only contains days of the current month. :param get_styles: a function that returns a list of styles for a given date :param initial: the date that is selected when the widget is first rendered """ if initial is None: self._initial = dt.date.today() else: self._initial = initial if on_press is None: on_press = {} default_keybindings: Dict[str, List[str]] = { 'left': ['left'], 'down': ['down'], 'right': ['right'], 'up': ['up'], 'today': ['t'], 'view': [], 'mark': ['v'], 'other': ['%'], } default_keybindings.update(keybindings) calendar.setfirstweekday(firstweekday) try: mylocale: str = '.'.join(getlocale(LC_TIME)) # type: ignore except TypeError: # language code and encoding may be None mylocale = 'C' _calendar = calendar.LocaleTextCalendar(firstweekday, mylocale) # type: ignore weekheader = _calendar.formatweekheader(2) dnames = weekheader.split(' ') def _get_styles(date: dt.date, focus: bool) -> Optional[str]: if focus: if date == dt.date.today(): return 'today focus' else: return 'reveal focus' else: if date == dt.date.today(): return 'today' else: return None if get_styles is None: get_styles = _get_styles if weeknumbers == 'right': dnames.append('#w') month_names_length = get_month_abbr_len() cnames = urwid.Columns( [(month_names_length, urwid.Text(' ' * month_names_length))] + [(2, urwid.AttrMap(urwid.Text(name), 'dayname')) for name in dnames], dividechars=1) self.walker = CalendarWalker( on_date_change=on_date_change, on_press=on_press, keybindings=default_keybindings, firstweekday=firstweekday, weeknumbers=weeknumbers, monthdisplay=monthdisplay, get_styles=get_styles, initial=self._initial, ) self.box = CListBox(self.walker) frame = urwid.Frame(self.box, header=cnames) urwid.WidgetWrap.__init__(self, frame) self.set_focus_date(self._initial) def focus_today(self) -> None: self.set_focus_date(dt.date.today()) def reset_styles_range(self, min_date: dt.date, max_date: dt.date) -> None: self.walker.reset_styles_range(min_date, max_date) @classmethod def selectable(cls) -> bool: return True @property def focus_date(self) -> dt.date: return self.walker.focus_date def set_focus_date(self, a_day: dt.date) -> None: """set the focus to `a_day`""" self.box.set_focus_date(a_day) khal-0.11.4/khal/ui/colors.py000066400000000000000000000102071477603436700157440ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from typing import Dict, List, Tuple dark = [ ('header', 'white', 'black'), ('footer', 'white', 'black'), ('line header', 'black', 'white', 'bold'), ('alt header', 'white', '', 'bold'), ('bright', 'dark blue', 'white', 'bold,standout'), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), ('edit focus', 'white', 'light blue', 'bold'), ('button', 'black', 'dark cyan'), ('button focused', 'white', 'light blue', 'bold'), ('reveal focus', 'black', 'light gray'), ('today focus', 'white', 'dark magenta'), ('today', 'dark gray', 'dark green',), ('date header', 'light gray', 'black'), ('date header focused', 'black', 'white'), ('date header selected', 'dark gray', 'light gray'), ('dayname', 'light gray', ''), ('monthname', 'light gray', ''), ('weeknumber_right', 'light gray', ''), ('alert', 'white', 'dark red'), ('mark', 'white', 'dark green'), ('frame', 'white', 'black'), ('frame focus', 'light red', 'black'), ('frame focus color', 'dark blue', 'black'), ('frame focus top', 'dark magenta', 'black'), ('eventcolumn', '', '', ''), ('eventcolumn focus', '', '', ''), ('calendar', '', '', ''), ('calendar focus', '', '', ''), ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), ('popupper', 'white', 'dark cyan'), ('caption', 'white', '', 'bold'), ] light = [ ('header', 'black', 'white'), ('footer', 'black', 'white'), ('line header', 'black', 'white', 'bold'), ('alt header', 'black', '', 'bold'), ('bright', 'dark blue', 'white', 'bold,standout'), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), ('edit focus', 'white', 'light blue', 'bold'), ('button', 'black', 'dark cyan'), ('button focused', 'white', 'light blue', 'bold'), ('reveal focus', 'black', 'dark cyan', 'standout'), ('today focus', 'white', 'dark cyan', 'standout'), ('today', 'black', 'light gray'), ('date header', '', 'white'), ('date header focused', 'white', 'dark gray', 'bold,standout'), ('date header selected', 'dark gray', 'light cyan'), ('dayname', 'dark gray', 'white'), ('monthname', 'dark gray', 'white'), ('weeknumber_right', 'dark gray', 'white'), ('alert', 'white', 'dark red'), ('mark', 'white', 'dark green'), ('frame', 'dark gray', 'white'), ('frame focus', 'light red', 'white'), ('frame focus color', 'dark blue', 'white'), ('frame focus top', 'dark magenta', 'white'), ('eventcolumn', '', '', ''), ('eventcolumn focus', '', '', ''), ('calendar', '', '', ''), ('calendar focus', '', '', ''), ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), ('popupper', 'black', 'light gray'), ('caption', 'black', '', ''), ] themes: Dict[str, List[Tuple[str, ...]]] = {'light': light, 'dark': dark} khal-0.11.4/khal/ui/editor.py000066400000000000000000000772441477603436700157470ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt from typing import TYPE_CHECKING, Callable, Dict, List, Literal, Optional, Tuple import urwid from ..utils import get_weekday_occurrence, get_wrapped_text from .widgets import ( AlarmsEditor, CalendarWidget, CAttrMap, Choice, CPadding, DateConversionError, DateWidget, ExtendedEdit, NColumns, NListBox, NPile, PositiveIntEdit, TimeWidget, ValidatedEdit, button, ) if TYPE_CHECKING: import khal.khalendar.event class StartEnd: def __init__(self, startdate, starttime, enddate, endtime) -> None: """collecting some common properties""" self.startdate = startdate self.starttime = starttime self.enddate = enddate self.endtime = endtime class CalendarPopUp(urwid.PopUpLauncher): def __init__(self, widget, on_date_change, weeknumbers: Literal['left', 'right', False]=False, firstweekday=0, monthdisplay='firstday', keybindings=None) -> None: self._on_date_change = on_date_change self._weeknumbers = weeknumbers self._monthdisplay = monthdisplay self._firstweekday = firstweekday self._keybindings = {} if keybindings is None else keybindings super().__init__(widget) def keypress(self, size, key): if key == 'enter': self.open_pop_up() else: return super().keypress(size, key) def create_pop_up(self): def on_change(new_date): self._get_base_widget().set_value(new_date) self._on_date_change(new_date) on_press = {'enter': lambda _, __: self.close_pop_up(), 'esc': lambda _, __: self.close_pop_up()} try: initial_date = self.base_widget._get_current_value() except DateConversionError: return None else: pop_up = CalendarWidget( on_change, self._keybindings, on_press, firstweekday=self._firstweekday, weeknumbers=self._weeknumbers, monthdisplay=self._monthdisplay, initial=initial_date) pop_up = CAttrMap(pop_up, 'calendar', ' calendar focus') pop_up = CAttrMap(urwid.LineBox(pop_up), 'calendar', 'calendar focus') return pop_up def get_pop_up_parameters(self): width = 31 if self._weeknumbers == 'right' else 28 return {'left': 0, 'top': 1, 'overlay_width': width, 'overlay_height': 8} class DateEdit(urwid.WidgetWrap): """Widget that allows editing a Date. Will open a calendar when `enter` is pressed, pressing `enter` again will select that date. """ def __init__( self, startdt: dt.date, dateformat: str='%Y-%m-%d', on_date_change: Callable=lambda _: None, weeknumbers: Literal['left', 'right', False]=False, firstweekday: int=0, monthdisplay: Literal['firstday', 'firstfullweek']='firstday', keybindings: Optional[Dict[str, List[str]]] = None, ) -> None: datewidth = len(startdt.strftime(dateformat)) self._dateformat = dateformat if startdt is None: startdt = dt.date.today() self._edit = ValidatedEdit( dateformat=dateformat, EditWidget=DateWidget, validate=self._validate, edit_text=startdt.strftime(dateformat), on_date_change=on_date_change) wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers, firstweekday, monthdisplay, keybindings) padded = CAttrMap( urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1), 'calendar', 'calendar focus', ) super().__init__(padded) def _validate(self, text: str): try: _date = dt.datetime.strptime(text, self._dateformat).date() except ValueError: return False else: return _date @property def date(self): """Get currently entered date, or False, if input is invalid. :returns: the currently entered date :rtype: datetime.date """ return self._validate(self._edit.get_edit_text()) @date.setter def date(self, date): """Update text of this Widget. :type date: datetime.date """ self._edit.set_edit_text(dt.date.strftime(self._dateformat)) class StartEndEditor(urwid.WidgetWrap): """Widget for editing start and end times (of an event).""" def __init__(self, start: dt.datetime, end: dt.datetime, conf, on_start_date_change=lambda x: None, on_end_date_change=lambda x: None, on_type_change: Callable[[bool], None]=lambda _: None, ) -> None: """ :param on_start_date_change: a callable that gets called everytime a new start date is entered, with that new date as an argument :param on_end_date_change: same as for on_start_date_change, just for the end date :param on_type_change: callback that gets called when the event type (allday or datetime) changes, gets passed True if toggled to allday and False if toggled to daytime """ self.allday = not isinstance(start, dt.datetime) self.conf = conf self._startdt: dt.date = start self._original_start: dt.date = start self._enddt: dt.date = end self._original_end: dt.date = end self.on_start_date_change = on_start_date_change self.on_end_date_change = on_end_date_change self.on_type_change = on_type_change self._datewidth = len(start.strftime(self.conf['locale']['longdateformat'])) self._timewidth = len(start.strftime(self.conf['locale']['timeformat'])) # this will contain the widgets for [start|end] [date|time] self.widgets = StartEnd(None, None, None, None) self.checkallday = urwid.CheckBox( 'Allday', state=self.allday, on_state_change=self.toggle) self.toggle(None, self.allday) def keypress(self, size, key): return super().keypress(size, key) @property def startdt(self): if self.allday and isinstance(self._startdt, dt.datetime): return self._startdt.date() else: return self._startdt @property def _start_time(self): try: return self._startdt.time() except AttributeError: return dt.time(0) @property def localize_start(self): if getattr(self.startdt, 'tzinfo', None) is None: return self.conf['locale']['default_timezone'].localize else: return self.startdt.tzinfo.localize @property def localize_end(self): if getattr(self.enddt, 'tzinfo', None) is None: return self.conf['locale']['default_timezone'].localize else: return self.enddt.tzinfo.localize @property def enddt(self): if self.allday and isinstance(self._enddt, dt.datetime): return self._enddt.date() else: return self._enddt @property def _end_time(self): try: return self._enddt.time() except AttributeError: return dt.time(0) def _validate_start_time(self, text): try: startval = dt.datetime.strptime(text, self.conf['locale']['timeformat']) self._startdt = self.localize_start( dt.datetime.combine(self._startdt.date(), startval.time())) except ValueError: return False else: return startval def _start_date_change(self, date): self._startdt = self.localize_start(dt.datetime.combine(date, self._start_time)) self.on_start_date_change(date) def _validate_end_time(self, text): try: endval = dt.datetime.strptime(text, self.conf['locale']['timeformat']) self._enddt = self.localize_end(dt.datetime.combine(self._enddt.date(), endval.time())) except ValueError: return False else: return endval def _end_date_change(self, date): self._enddt = self.localize_end(dt.datetime.combine(date, self._end_time)) self.on_end_date_change(date) def toggle(self, checkbox, state: bool): """change from allday to datetime event :param checkbox: the checkbox instance that is used for toggling, gets automatically passed by urwid (is not used) :type checkbox: checkbox :param state: state the event will toggle to; True if allday event, False if datetime """ if self.allday is True and state is False: self._startdt = dt.datetime.combine(self._startdt, dt.datetime.min.time()) self._enddt = dt.datetime.combine(self._enddt, dt.datetime.min.time()) elif self.allday is False and state is True: assert isinstance(self._startdt, dt.datetime) assert isinstance(self._enddt, dt.datetime) self._startdt = self._startdt.date() self._enddt = self._enddt.date() self.allday = state self.widgets.startdate = DateEdit( self._startdt, self.conf['locale']['longdateformat'], self._start_date_change, self.conf['locale']['weeknumbers'], self.conf['locale']['firstweekday'], self.conf['view']['monthdisplay'], self.conf['keybindings'], ) self.widgets.enddate = DateEdit( self._enddt, self.conf['locale']['longdateformat'], self._end_date_change, self.conf['locale']['weeknumbers'], self.conf['locale']['firstweekday'], self.conf['view']['monthdisplay'], self.conf['keybindings'], ) if state is True: # allday event self.on_type_change(True) timewidth = 1 self.widgets.starttime = urwid.Text('') self.widgets.endtime = urwid.Text('') elif state is False: # datetime event self.on_type_change(False) timewidth = self._timewidth + 1 raw_start_time_widget = ValidatedEdit( dateformat=self.conf['locale']['timeformat'], EditWidget=TimeWidget, validate=self._validate_start_time, edit_text=self.startdt.strftime(self.conf['locale']['timeformat']), ) self.widgets.starttime = urwid.Padding( raw_start_time_widget, align='left', width=self._timewidth + 1, left=1) raw_end_time_widget = ValidatedEdit( dateformat=self.conf['locale']['timeformat'], EditWidget=TimeWidget, validate=self._validate_end_time, edit_text=self.enddt.strftime(self.conf['locale']['timeformat']), ) self.widgets.endtime = urwid.Padding( raw_end_time_widget, align='left', width=self._timewidth + 1, left=1) columns = NPile([ self.checkallday, NColumns([(5, urwid.Text('From:')), (self._datewidth, self.widgets.startdate), ( timewidth, self.widgets.starttime)], dividechars=1), NColumns( [(5, urwid.Text('To:')), (self._datewidth, self.widgets.enddate), (timewidth, self.widgets.endtime)], dividechars=1) ], focus_item=1) urwid.WidgetWrap.__init__(self, columns) @property def changed(self): """returns True if content has been edited, False otherwise""" return (self.startdt != self._original_start) or (self.enddt != self._original_end) def validate(self): return self.startdt <= self.enddt class EventEditor(urwid.WidgetWrap): """Widget that allows Editing one `Event()`""" def __init__( self, pane, event: 'khal.khalendar.event.Event', save_callback=None, always_save: bool=False, ) -> None: """ :param save_callback: call when saving event with new start and end dates and recursiveness of original and edited event as parameters :type save_callback: callable :param always_save: save event even if it has not changed """ self.pane = pane self.event = event self._save_callback = save_callback self.collection = pane.collection self._conf = pane._conf self._abort_confirmed = False self.description = event.description self.location = event.location self.attendees = event.attendees self.categories = event.categories self.url = event.url self.startendeditor = StartEndEditor( event.start_local, event.end_local, self._conf, self.start_datechange, self.end_datechange, self.type_change, ) # TODO make sure recurrence rules cannot be edited if we only # edit one instance (or this and future) (once we support that) self.recurrenceeditor = RecurrenceEditor( self.event.recurobject, self._conf, event.start_local, ) self.summary = urwid.AttrMap(ExtendedEdit( caption=('caption', 'Title: '), edit_text=event.summary), 'edit', 'edit focus', ) divider = urwid.Divider(' ') def decorate_choice(c) -> Tuple[str, str]: return ('calendar ' + c['name'] + ' popup', c['name']) self.calendar_chooser= CAttrMap(Choice( [self.collection._calendars[c] for c in self.collection.writable_names], self.collection._calendars[self.event.calendar], decorate_choice ), 'caption') self.description = urwid.AttrMap( ExtendedEdit( caption=('caption', 'Description: '), edit_text=self.description, multiline=True ), 'edit', 'edit focus', ) self.location = urwid.AttrMap(ExtendedEdit( caption=('caption', 'Location: '), edit_text=self.location), 'edit', 'edit focus', ) self.categories = urwid.AttrMap(ExtendedEdit( caption=('caption', 'Categories: '), edit_text=self.categories), 'edit', 'edit focus', ) self.attendees = urwid.AttrMap( ExtendedEdit( caption=('caption', 'Attendees: '), edit_text=self.attendees, multiline=True ), 'edit', 'edit focus', ) self.url = urwid.AttrMap(ExtendedEdit( caption=('caption', 'URL: '), edit_text=self.url), 'edit', 'edit focus', ) self.alarmseditor: AlarmsEditor = AlarmsEditor(self.event) self.pile = NListBox(urwid.SimpleFocusListWalker([ self.summary, urwid.Columns([(13, urwid.AttrMap(urwid.Text('Calendar:'), 'caption')), (12, self.calendar_chooser)], ), divider, self.location, self.categories, self.description, self.url, divider, self.attendees, divider, self.startendeditor, self.recurrenceeditor, divider, self.alarmseditor, divider, urwid.Columns( [(12, button('Save', on_press=self.save, padding_left=0, padding_right=0))] ), urwid.Columns( [(12, button('Export', on_press=self.export, padding_left=0, padding_right=0))], ) ]), outermost=True) self._always_save = always_save urwid.WidgetWrap.__init__(self, self.pile) def start_datechange(self, date): self.pane.eventscolumn.original_widget.set_focus_date(date) self.recurrenceeditor.update_startdt(date) def end_datechange(self, date): self.pane.eventscolumn.original_widget.set_focus_date(date) def type_change(self, allday: bool) -> None: """when the event type changes, we might want to change the default alarms :params allday: True if the event is now an allday event, False if it isn't """ # test if self.alarmseditor exists if not hasattr(self, 'alarmseditor'): return # to make the alarms before the event, we need to set it them to # negative values default_event_alarm = -1 * self._conf['default']['default_event_alarm'] default_dayevent_alarm =-1 * self._conf['default']['default_dayevent_alarm'] alarms = self.alarmseditor.get_alarms() if len(alarms) == 1: timedelta = alarms[0][0] if allday and timedelta == default_event_alarm: self.alarmseditor.clear() self.alarmseditor.add_alarm(None, default_dayevent_alarm) elif (not allday) and timedelta == default_dayevent_alarm: self.alarmseditor.clear() self.alarmseditor.add_alarm(None, default_event_alarm) else: # either there were more than one alarm or the alarm was not the default pass @property def title(self): # Window title return f'Edit: {get_wrapped_text(self.summary)}' @classmethod def selectable(cls): return True @property def changed(self): if get_wrapped_text(self.summary) != self.event.summary: return True if get_wrapped_text(self.description) != self.event.description: return True if get_wrapped_text(self.location) != self.event.location: return True if get_wrapped_text(self.categories) != self.event.categories: return True if get_wrapped_text(self.url) != self.event.url: return True if get_wrapped_text(self.attendees) != self.event.attendees: return True if self.startendeditor.changed or self.calendar_chooser.changed: return True if self.recurrenceeditor.changed: return True if self.alarmseditor.changed: return True return False def update_vevent(self): self.event.update_summary(get_wrapped_text(self.summary)) self.event.update_description(get_wrapped_text(self.description)) self.event.update_location(get_wrapped_text(self.location)) self.event.update_attendees(get_wrapped_text(self.attendees).split(',')) self.event.update_categories(get_wrapped_text(self.categories).split(',')) self.event.update_url(get_wrapped_text(self.url)) if self.startendeditor.changed: self.event.update_start_end( self.startendeditor.startdt, self.startendeditor.enddt) if self.recurrenceeditor.changed: rrule = self.recurrenceeditor.active self.event.update_rrule(rrule) if self.alarmseditor.changed: self.event.update_alarms(self.alarmseditor.get_alarms()) def export(self, button): """ export the event as ICS :param button: not needed, passed via the button press """ def export_this(_, user_data): try: self.event.export_ics(user_data.get_edit_text()) except Exception as e: self.pane.window.backtrack() self.pane.window.alert( ('light red', 'Failed to save event: %s' % e)) return self.pane.window.backtrack() self.pane.window.alert( ('light green', 'Event successfuly exported')) overlay = urwid.Overlay( ExportDialog( export_this, self.pane.window.backtrack, self.event, ), self.pane, 'center', ('relative', 50), ('relative', 50), None) self.pane.window.open(overlay) def save(self, button): """saves the event to the db (only when it has been changed or always_save is set) :param button: not needed, passed via the button press """ if not self.startendeditor.validate(): self.pane.window.alert( ('light red', "Can't save: end date is before start date!")) return if self._always_save or self.changed is True: self.update_vevent() self.event.allday = self.startendeditor.allday self.event.increment_sequence() if self.event.etag is None: # has not been saved before self.event.calendar = self.calendar_chooser.original_widget.active['name'] self.collection.insert(self.event) elif self.calendar_chooser.changed: self.collection.change_collection( self.event, self.calendar_chooser.active['name'] ) else: self.collection.update(self.event) self._save_callback( self.event.start_local, self.event.end_local, self.event.recurring or self.recurrenceeditor.changed, ) self._abort_confirmed = False self.pane.window.backtrack() def keypress(self, size: Tuple[int], key: str) -> Optional[str]: if key in ['esc'] and self.changed and not self._abort_confirmed: self.pane.window.alert( ('light red', 'Unsaved changes! Hit ESC again to discard.')) self._abort_confirmed = True return None else: self._abort_confirmed = False return_value = super().keypress(size, key) if key in self.pane._conf['keybindings']['save']: self.save(None) return None return return_value WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] # TODO use locale and respect weekdaystart class WeekDaySelector(urwid.WidgetWrap): def __init__(self, startdt, selected_days) -> None: self._weekday_boxes = {day: urwid.CheckBox(day, state=False) for day in WEEKDAYS} weekday = startdt.weekday() self._weekday_boxes[WEEKDAYS[weekday]].state = True self.weekday_checks = NColumns([(7, self._weekday_boxes[wd]) for wd in WEEKDAYS]) for day in selected_days: self._weekday_boxes[day].state = True urwid.WidgetWrap.__init__(self, self.weekday_checks) @property def days(self): days = [day.label for (day, _) in self.weekday_checks.contents if day.state] return days class RecurrenceEditor(urwid.WidgetWrap): def __init__(self, rrule, conf, startdt) -> None: self._conf = conf self._startdt = startdt self._rrule = rrule self.repeat = bool(rrule) self._allow_edit = not self.repeat or self.check_understood_rrule(rrule) self.repeat_box = urwid.CheckBox( 'Repeat: ', state=self.repeat, on_state_change=self.check_repeat, ) if "UNTIL" in self._rrule: self._until = "Until" elif "COUNT" in self._rrule: self._until = "Repetitions" else: self._until = "Forever" recurrence = self._rrule['freq'][0].lower() if self._rrule else "weekly" self.recurrence_choice = CPadding(CAttrMap(Choice( ["daily", "weekly", "monthly", "yearly"], recurrence, callback=self.rebuild, ), 'popupper'), align='center', left=2, right=2) self.interval_edit = PositiveIntEdit( caption=('caption', 'every:'), edit_text=str(self._rrule.get('INTERVAL', [1])[0]), ) self.until_choice = CPadding(CAttrMap(Choice( ["Forever", "Until", "Repetitions"], self._until, callback=self.rebuild, ), 'popupper'), align='center', left=2, right=2) count = str(self._rrule.get('COUNT', [1])[0]) self.repetitions_edit = PositiveIntEdit(edit_text=count) until = self._rrule.get('UNTIL', [None])[0] if until is None and isinstance(self._startdt, dt.datetime): until = self._startdt.date() elif until is None: until = self._startdt if isinstance(until, dt.datetime): until = until.date() self.until_edit = DateEdit( until, self._conf['locale']['longdateformat'], lambda _: None, self._conf['locale']['weeknumbers'], self._conf['locale']['firstweekday'], self._conf['view']['monthdisplay'], ) self._rebuild_weekday_checks() self._rebuild_monthly_choice() self._pile = pile = NPile([urwid.Text('')]) urwid.WidgetWrap.__init__(self, pile) self.rebuild() def _rebuild_monthly_choice(self): weekday, xth = get_weekday_occurrence(self._startdt) ords = {1: 'st', 2: 'nd', 3: 'rd', 21: 'st', 22: 'nd', 23: 'rd', 31: 'st'} self._xth_weekday = f"on every {xth}{ords.get(xth, 'th')} {WEEKDAYS[weekday]}" self._xth_monthday = (f"on every {self._startdt.day}" f"{ords.get(self._startdt.day, 'th')} of the month") self.monthly_choice = Choice( [self._xth_monthday, self._xth_weekday], self._xth_monthday, callback=self.rebuild, ) def _rebuild_weekday_checks(self): if self.recurrence_choice.active == 'weekly': initial_days = self._rrule.get('BYDAY', []) else: initial_days = [] self.weekday_checks = WeekDaySelector(self._startdt, initial_days) def update_startdt(self, startdt): self._startdt = startdt self._rebuild_weekday_checks() self._rebuild_monthly_choice() self.rebuild() @staticmethod def check_understood_rrule(rrule): """test if we can reproduce `rrule`.""" keys = set(rrule.keys()) freq = rrule.get('FREQ', [None])[0] unsupported_rrule_parts = { 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', } if keys.intersection(unsupported_rrule_parts): return False if len(rrule.get('BYMONTHDAY', [1])) > 1: return False # we don't support negative BYMONTHDAY numbers # don't need to check whole list, we only support one monthday anyway if rrule.get('BYMONTHDAY', [1])[0] < 1: return False if rrule.get('BYDAY', ['1'])[0][0] == '-': return False if rrule.get('BYSETPOS', [1])[0] != 1: return False if freq not in ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']: return False if 'BYDAY' in keys and freq == 'YEARLY': return False return True def check_repeat(self, checkbox, state): self.repeat = state self.rebuild() def _refill_contents(self, lines): while True: try: self._pile.contents.pop() except IndexError: break [self._pile.contents.append((line, ('pack', None))) for line in lines] def rebuild(self): old_focus_y = self._pile.focus_position if not self._allow_edit: self._rebuild_no_edit() elif self.repeat: self._rebuild_edit() self._pile.set_focus(old_focus_y) else: self._rebuild_edit_no_repeat() def _rebuild_no_edit(self): def _allow_edit(_): self._allow_edit = True self.rebuild() lines = [ urwid.Text("We cannot reproduce this event's repetition rules."), urwid.Text("Editing the repetition rules will destroy the current rules."), urwid.Button("Edit anyway", on_press=_allow_edit), ] self._refill_contents(lines) self._pile.set_focus(2) def _rebuild_edit_no_repeat(self): lines = [NColumns([(13, self.repeat_box)])] self._refill_contents(lines) def _rebuild_edit(self): firstline = NColumns([ (13, self.repeat_box), (18, self.recurrence_choice), (13, self.interval_edit), ]) lines = [firstline] if self.recurrence_choice.active == "weekly": lines.append(self.weekday_checks) if self.recurrence_choice.active == "monthly": lines.append(self.monthly_choice) nextline = [(20, self.until_choice.original_widget)] if self.until_choice.active == "Until": nextline.append((24, self.until_edit)) elif self.until_choice.active == "Repetitions": nextline.append((4, self.repetitions_edit)) lines.append(NColumns(nextline)) self._refill_contents(lines) @property def changed(self): # TODO this often gives false positives which leads to redraws of all # events shown return self._rrule != self.rrule() # TODO do this properly def rrule(self): rrule = {} rrule['freq'] = [self.recurrence_choice.active] interval = int(self.interval_edit.get_edit_text()) if interval != 1: rrule['interval'] = [interval] if rrule['freq'] == ['weekly'] and len(self.weekday_checks.days) > 1: rrule['byday'] = self.weekday_checks.days if rrule['freq'] == ['monthly'] and self.monthly_choice.active == self._xth_weekday: weekday, occurrence = get_weekday_occurrence(self._startdt) rrule['byday'] = [f'{occurrence}{WEEKDAYS[weekday]}'] if self.until_choice.active == 'Until': if isinstance(self._startdt, dt.datetime): rrule['until'] = dt.datetime.combine( self.until_edit.date, self._startdt.time(), ) else: rrule['until'] = self.until_edit.date elif self.until_choice.active == 'Repetitions': rrule['count'] = int(self.repetitions_edit.get_edit_text()) return rrule @property def active(self): if not self.repeat: return None else: return self.rrule() @active.setter def active(self, val): raise ValueError self.recurrence_choice.active = val class ExportDialog(urwid.WidgetWrap): def __init__(self, this_func, abort_func, event) -> None: lines = [] lines.append(urwid.Text('Export event as ICS file')) lines.append(urwid.Text('')) export_location = ExtendedEdit( caption='Location: ', edit_text="~/%s.ics" % event.summary.strip()) lines.append(export_location) lines.append(urwid.Divider(' ')) lines.append(CAttrMap( urwid.Button('Save', on_press=this_func, user_data=export_location), 'button', 'button focus')) content = NPile(lines) urwid.WidgetWrap.__init__(self, urwid.LineBox(content)) khal-0.11.4/khal/ui/widgets.py000066400000000000000000000626021477603436700161170ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """A collection of (reusable) urwid widgets Widgets that are specific to calendaring/khal should go into __init__.py or, if they are large, into their own files """ import datetime as dt import re from typing import List, Optional, Tuple import urwid from .calendarwidget import CalendarWidget # noqa class DateConversionError(Exception): pass def delete_last_word(text, number=1): """delete last `number` of words from text""" words = re.findall(r"[\w]+|[^\w\s]", text, re.UNICODE) for one in range(1, number + 1): text = text.rstrip() if text == '': return text text = text[:len(text) - len(words[-one])] return text def delete_till_beginning_of_line(text): """delete till beginning of line""" if text.rfind("\n") == -1: return '' return text[0:text.rfind("\n") + 1] def delete_till_end_of_line(text): """delete till beginning of line""" if text.find("\n") == -1: return '' return text[text.find("\n"):] def goto_beginning_of_line(text): if text.rfind("\n") == -1: return 0 return text.rfind("\n") + 1 def goto_end_of_line(text): if text.find("\n") == -1: return len(text) return text.find("\n") class ExtendedEdit(urwid.Edit): """A text editing widget supporting some more editing commands""" def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key == 'ctrl w': self._delete_word() elif key == 'ctrl u': self._delete_till_beginning_of_line() elif key == 'ctrl k': self._delete_till_end_of_line() elif key == 'ctrl a': self._goto_beginning_of_line() elif key == 'ctrl e': self._goto_end_of_line() else: return super().keypress(size, key) return None def _delete_word(self): """delete word before cursor""" text = self.get_edit_text() f_text = delete_last_word(text[:self.edit_pos]) self.set_edit_text(f_text + text[self.edit_pos:]) self.set_edit_pos(len(f_text)) def _delete_till_beginning_of_line(self): """delete till start of line before cursor""" text = self.get_edit_text() f_text = delete_till_beginning_of_line(text[:self.edit_pos]) self.set_edit_text(f_text + text[self.edit_pos:]) self.set_edit_pos(len(f_text)) def _delete_till_end_of_line(self): """delete till end of line before cursor""" text = self.get_edit_text() f_text = delete_till_end_of_line(text[self.edit_pos:]) self.set_edit_text(text[:self.edit_pos] + f_text) def _goto_beginning_of_line(self): text = self.get_edit_text() self.set_edit_pos(goto_beginning_of_line(text[:self.edit_pos])) def _goto_end_of_line(self): text = self.get_edit_text() self.set_edit_pos(goto_end_of_line(text[self.edit_pos:]) + self.edit_pos) class DateTimeWidget(ExtendedEdit): def __init__(self, dateformat: str, on_date_change=lambda x: None, **kwargs) -> None: self.dateformat = dateformat self.on_date_change = on_date_change super().__init__(wrap='any', **kwargs) def keypress(self, size, key): if key == 'ctrl x': self.decrease() return None elif key == 'ctrl a': self.increase() return None if ( key in ['up', 'down', 'tab', 'shift tab', 'page up', 'page down', 'meta enter'] or (key in ['right'] and self.edit_pos >= len(self.edit_text)) or (key in ['left'] and self.edit_pos == 0)): # when leaving the current Widget we check if currently # entered value is valid and if so pass the new value try: new_date = self._get_current_value() except DateConversionError: pass else: self.on_date_change(new_date) return super().keypress(size, key) def increase(self): """call to increase the datefield by self.timedelta""" self._crease(self.dtype.__add__) def decrease(self): """call to decrease the datefield by self.timedelta""" self._crease(self.dtype.__sub__) def _crease(self, fun): """common implementation for `self.increase` and `self.decrease`""" try: new_date = fun(self._get_current_value(), self.timedelta) self.on_date_change(new_date) self.set_edit_text(new_date.strftime(self.dateformat)) except DateConversionError: pass def set_value(self, new_date: dt.date): """set a new value for this widget""" self.set_edit_text(new_date.strftime(self.dateformat)) class DateWidget(DateTimeWidget): dtype = dt.date timedelta = dt.timedelta(days=1) def _get_current_value(self): try: new_date = dt.datetime.strptime(self.get_edit_text(), self.dateformat).date() except ValueError: raise DateConversionError else: return new_date class TimeWidget(DateTimeWidget): dtype = dt.datetime timedelta = dt.timedelta(minutes=15) def _get_current_value(self): try: new_datetime = dt.datetime.strptime(self.get_edit_text(), self.dateformat) except ValueError: raise DateConversionError else: return new_datetime class Choice(urwid.PopUpLauncher): def __init__( self, choices: List[str], active: str, decorate_func=None, overlay_width: int=32, callback=lambda: None, ) -> None: self.choices = choices self._callback = callback self._decorate = decorate_func or (lambda x: x) self._overlay_width = overlay_width self.active = self._original = active def create_pop_up(self): pop_up = ChoiceList(self, callback=self._callback) urwid.connect_signal(pop_up, 'close', lambda button: self.close_pop_up()) return pop_up def get_pop_up_parameters(self): return {'left': 0, 'top': 1, 'overlay_width': self._overlay_width, 'overlay_height': len(self.choices)} @property def changed(self): return self._active != self._original @property def active(self): return self._active @active.setter def active(self, val): self._active = val self.button = urwid.Button(self._decorate(self._active)) urwid.PopUpLauncher.__init__(self, self.button) urwid.connect_signal(self.button, 'click', lambda button: self.open_pop_up()) class ChoiceList(urwid.WidgetWrap): """A pile of Button() widgets, intended to be used with Choice()""" signals = ['close'] def __init__(self, parent, callback=lambda: None) -> None: self.parent = parent self._callback = callback buttons = [] for c in parent.choices: buttons.append( button( parent._decorate(c), attr_map='popupbg', focus_map='popupbg focus', on_press=self.set_choice, user_data=c, ) ) pile = NPile(buttons, outermost=True) num = [num for num, elem in enumerate(parent.choices) if elem == parent.active][0] pile.focus_position = num fill = urwid.Filler(pile) urwid.WidgetWrap.__init__(self, urwid.AttrMap(fill, 'popupbg')) def set_choice(self, button, account): self.parent.active = account self._callback() self._emit("close") class SupportsNext: """classes inheriting from SupportsNext must implement the following methods: _select_first_selectable _select_last_selectable """ def __init__(self, *args, **kwargs) -> None: self.outermost = kwargs.get('outermost', False) if 'outermost' in kwargs: kwargs.pop('outermost') super().__init__(*args, **kwargs) class NextMixin(SupportsNext): """Implements SupportsNext for urwid.Pile and urwid.Columns""" def _select_first_selectable(self): """select our first selectable item (recursivly if that item SupportsNext)""" i = self._first_selectable() self.focus_position = i if isinstance(self.contents[i][0], SupportsNext): self.contents[i][0]._select_first_selectable() def _select_last_selectable(self): """select our last selectable item (recursivly if that item SupportsNext)""" i = self._last_selectable() self.focus_position = i if isinstance(self._contents[i][0], SupportsNext): self.contents[i][0]._select_last_selectable() def _first_selectable(self): """return sequence number of self.contents last selectable item""" for j in range(0, len(self._contents)): if self._contents[j][0].selectable(): return j return False def _last_selectable(self): """return sequence number of self._contents last selectable item""" for j in range(len(self._contents) - 1, - 1, - 1): if self._contents[j][0].selectable(): return j return False def keypress(self, size, key): key = super().keypress(size, key) if key == 'tab': if self.outermost and self.focus_position == self._last_selectable(): self._select_first_selectable() else: for i in range(self.focus_position + 1, len(self._contents)): if self._contents[i][0].selectable(): self.focus_position = i if isinstance(self._contents[i][0], SupportsNext): self._contents[i][0]._select_first_selectable() break else: # no break return key elif key == 'shift tab': if self.outermost and self.focus_position == self._first_selectable(): self._select_last_selectable() else: for i in range(self.focus_position - 1, 0 - 1, -1): if self._contents[i][0].selectable(): self.focus_position = i if isinstance(self._contents[i][0], SupportsNext): self._contents[i][0]._select_last_selectable() break else: # no break return key else: return key class NPile(NextMixin, urwid.Pile): pass class NColumns(NextMixin, urwid.Columns): pass class NListBox(SupportsNext, urwid.ListBox): def _select_first_selectable(self): """select our first selectable item (recursivly if that item SupportsNext)""" i = self._first_selectable() self.focus_position = i if isinstance(self.body[i], SupportsNext): self.body[i]._select_first_selectable() def _select_last_selectable(self): """select our last selectable item (recursivly if that item SupportsNext)""" i = self._last_selectable() self.focus_position = i if isinstance(self.body[i], SupportsNext): self.body[i]._select_last_selectable() def _first_selectable(self): """return sequence number of self._contents last selectable item""" for j in range(0, len(self.body)): if self.body[j].selectable(): return j return False def _last_selectable(self): """return sequence number of self.contents last selectable item""" for j in range(len(self.body) - 1, - 1, - 1): if self.body[j].selectable(): return j return False def keypress(self, size, key): key = super().keypress(size, key) if key == 'tab': if self.outermost and self.focus_position == self._last_selectable(): self._select_first_selectable() else: self._keypress_down(size) elif key == 'shift tab': if self.outermost and self.focus_position == self._first_selectable(): self._select_last_selectable() else: self._keypress_up(size) else: return key class ValidatedEdit(urwid.WidgetWrap): def __init__(self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs) -> None: assert validate self._validate_func = validate self._original_widget = urwid.AttrMap(EditWidget(*args, **kwargs), 'edit', 'edit focused') super().__init__(self._original_widget) @property def _get_base_widget(self): return self._original_widget @property def base_widget(self): return self._original_widget.original_widget def _validate(self): text = self.base_widget.get_edit_text() if self._validate_func(text): self._original_widget.set_attr_map({None: 'edit'}) self._original_widget.set_focus_map({None: 'edit'}) return True else: self._original_widget.set_attr_map({None: 'alert'}) self._original_widget.set_focus_map({None: 'alert'}) return False def get_edit_text(self): self._validate() return self.base_widget.get_edit_text() @property def edit_pos(self): return self.base_widget.edit_pos @property def edit_text(self): return self.base_widget.edit_text def keypress(self, size, key): if ( key in ['up', 'down', 'tab', 'shift tab', 'page up', 'page down', 'meta enter'] or (key in ['right'] and self.edit_pos >= len(self.edit_text)) or (key in ['left'] and self.edit_pos == 0)): if not self._validate(): return return super().keypress(size, key) class PositiveIntEdit(ValidatedEdit): def __init__(self, *args, EditWidget=ExtendedEdit, validate=False, **kwargs) -> None: """Variant of Validated Edit that only accepts positive integers.""" super().__init__(*args, validate=self._unsigned_int, **kwargs) @staticmethod def _unsigned_int(number): """test if `number` can be converted to a positive int""" try: return int(number) >= 0 except ValueError: return False class DurationWidget(urwid.WidgetWrap): @staticmethod def unsigned_int(number): """test if `number` can be converted to a positive int""" try: return int(number) >= 0 except ValueError: return False @staticmethod def _convert_timedelta(dt): seconds = dt.total_seconds() days = int(seconds // (24 * 60 * 60)) hours = int((seconds // 3600) % 24) minutes = int((seconds % 3600) // 60) seconds = int(seconds % 60) return days, hours, minutes, seconds def __init__(self, timedelta: dt.timedelta) -> None: days, hours, minutes, seconds = self._convert_timedelta(timedelta) self.days_edit = ValidatedEdit( edit_text=str(days), validate=self.unsigned_int, align='right') self.hours_edit = ValidatedEdit( edit_text=str(hours), validate=self.unsigned_int, align='right') self.minutes_edit = ValidatedEdit( edit_text=str(minutes), validate=self.unsigned_int, align='right') self.seconds_edit = ValidatedEdit( edit_text=str(seconds), validate=self.unsigned_int, align='right') self.columns = NColumns([ (4, self.days_edit), (2, urwid.Text('D')), (3, self.hours_edit), (2, urwid.Text('H')), (3, self.minutes_edit), (2, urwid.Text('M')), (3, self.seconds_edit), (2, urwid.Text('S')), ]) urwid.WidgetWrap.__init__(self, self.columns) def get_timedelta(self) -> dt.timedelta: return dt.timedelta( seconds=int(self.seconds_edit.get_edit_text()) + int(self.minutes_edit.get_edit_text()) * 60 + int(self.hours_edit.get_edit_text()) * 60 * 60 + int(self.days_edit.get_edit_text()) * 24 * 60 * 60) class AlarmsEditor(urwid.WidgetWrap): class AlarmEditor(urwid.WidgetWrap): def __init__(self, alarm: Tuple[dt.timedelta, str], delete_handler) -> None: duration, description = alarm if duration.total_seconds() > 0: direction = 'after' else: direction = 'before' duration = -1 * duration self.duration = DurationWidget(duration) self.description = ExtendedEdit( edit_text=description if description is not None else "") self.direction = Choice( ['before', 'after'], active=direction, overlay_width=10) self.columns = NColumns([ (2, urwid.Text(' ')), (21, self.duration), (14, urwid.Padding(self.direction, right=1)), self.description, (10, button('Delete', on_press=delete_handler, user_data=self)), ]) urwid.WidgetWrap.__init__(self, self.columns) def get_alarm(self): direction = self.direction.active if direction == 'before': prefix = -1 else: prefix = 1 return (prefix * self.duration.get_timedelta(), self.description.get_edit_text()) def __init__(self, event) -> None: self.event = event self.pile = NPile( [urwid.Text('Alarms:')] + [self.AlarmEditor(a, self.remove_alarm) for a in event.alarms] + [urwid.Columns([(12, button('Add', on_press=self.add_alarm))])]) urwid.WidgetWrap.__init__(self, self.pile) def clear(self) -> None: """clear the alarm list""" self.pile.contents.clear() def add_alarm(self, button, timedelta: Optional[dt.timedelta] = None): if timedelta is None: timedelta = dt.timedelta(0) self.pile.contents.insert( len(self.pile.contents) - 1, (self.AlarmEditor((timedelta, self.event.summary), self.remove_alarm), ('weight', 1))) def remove_alarm(self, button, editor): self.pile.contents.remove((editor, ('weight', 1))) def get_alarms(self): alarms = [] for widget, _ in self.pile.contents: if isinstance(widget, self.AlarmEditor): alarms.append(widget.get_alarm()) return alarms @property def changed(self): try: return self.event.alarms != self.get_alarms() except ValueError: return False class FocusLineBoxWidth(urwid.WidgetDecoration, urwid.WidgetWrap): def __init__(self, widget) -> None: # we cheat here with the attrs, if we use thick dividers we apply the # focus attr group. We probably should fix this in render() hline = urwid.AttrMap(urwid.Divider('─'), 'frame') hline_focus = urwid.AttrMap(urwid.Divider('━'), 'frame focus') self._vline = urwid.AttrMap(urwid.SolidFill('│'), 'frame') self._vline_focus = urwid.AttrMap(urwid.SolidFill('┃'), 'frame focus') self._topline = urwid.Columns([ ('fixed', 1, urwid.AttrMap(urwid.Text('┌'), 'frame')), hline, ('fixed', 1, urwid.AttrMap(urwid.Text('┐'), 'frame')), ]) self._topline_focus = urwid.Columns([ ('fixed', 1, urwid.AttrMap(urwid.Text('┏'), 'frame focus')), hline_focus, ('fixed', 1, urwid.AttrMap(urwid.Text('┓'), 'frame focus')), ]) self._bottomline = urwid.Columns([ ('fixed', 1, urwid.AttrMap(urwid.Text('└'), 'frame')), hline, ('fixed', 1, urwid.AttrMap(urwid.Text('┘'), 'frame')), ]) self._bottomline_focus = urwid.Columns([ ('fixed', 1, urwid.AttrMap(urwid.Text('┗'), 'frame focus')), hline_focus, ('fixed', 1, urwid.AttrMap(urwid.Text('┛'), 'frame focus')), ]) self._middle = urwid.Columns( [('fixed', 1, self._vline), widget, ('fixed', 1, self._vline)], focus_column=1, ) self._all = urwid.Pile( [('flow', self._topline), self._middle, ('flow', self._bottomline)], focus_item=1, ) urwid.WidgetDecoration.__init__(self, widget) urwid.WidgetWrap.__init__(self, self._all) def render(self, size, focus): inner = self._all.contents[1][0] if focus: self._all.contents[0] = (self._topline_focus, ('pack', None)) inner.contents[0] = (self._vline_focus, ('given', 1, False)) inner.contents[2] = (self._vline_focus, ('given', 1, False)) self._all.contents[2] = (self._bottomline_focus, ('pack', None)) else: self._all.contents[0] = (self._topline, ('pack', None)) inner.contents[0] = (self._vline, ('given', 1, False)) inner.contents[2] = (self._vline, ('given', 1, False)) self._all.contents[2] = (self._bottomline, ('pack', None)) return super().render(size, focus) class FocusLineBoxColor(urwid.WidgetDecoration, urwid.WidgetWrap): def __init__(self, widget) -> None: hline = urwid.Divider('─') self._vline = urwid.AttrMap(urwid.SolidFill('│'), 'frame') self._topline = urwid.AttrMap( urwid.Columns([ ('fixed', 1, urwid.Text('┌')), hline, ('fixed', 1, urwid.Text('┐')), ]), 'frame') self._bottomline = urwid.AttrMap( urwid.Columns([ ('fixed', 1, urwid.Text('└')), hline, ('fixed', 1, urwid.Text('┘')), ]), 'frame') self._middle = urwid.Columns( [('fixed', 1, self._vline), widget, ('fixed', 1, self._vline)], focus_column=1, ) self._all = urwid.Pile( [('flow', self._topline), self._middle, ('flow', self._bottomline)], focus_item=1, ) urwid.WidgetWrap.__init__(self, self._all) urwid.WidgetDecoration.__init__(self, widget) def render(self, size, focus): if focus: self._middle.contents[0][0].set_attr_map({None: 'frame focus color'}) self._all.contents[0][0].set_attr_map({None: 'frame focus color'}) self._all.contents[2][0].set_attr_map({None: 'frame focus color'}) else: self._middle.contents[0][0].set_attr_map({None: 'frame'}) self._all.contents[0][0].set_attr_map({None: 'frame'}) self._all.contents[2][0].set_attr_map({None: 'frame'}) return super().render(size, focus) class FocusLineBoxTop(urwid.WidgetDecoration, urwid.WidgetWrap): def __init__(self, widget) -> None: topline = urwid.AttrMap(urwid.Divider('━'), 'frame') self._all = urwid.Pile([('flow', topline), widget], focus_item=1) urwid.WidgetWrap.__init__(self, self._all) urwid.WidgetDecoration.__init__(self, widget) def render(self, size, focus): if focus: self._all.contents[0][0].set_attr_map({None: 'frame focus top'}) else: self._all.contents[0][0].set_attr_map({None: 'frame'}) return super().render(size, focus) linebox = { 'color': FocusLineBoxColor, 'top': FocusLineBoxTop, 'width': FocusLineBoxWidth, 'False': urwid.WidgetPlaceholder, } def button(*args, attr_map: str='button', focus_map='button focus', padding_left=0, padding_right=0, **kwargs): """wrapping an urwid button in attrmap and padding""" button_ = urwid.Button(*args, **kwargs) button_ = urwid.AttrMap(button_, attr_map=attr_map, focus_map=focus_map) button_ = urwid.Padding(button_, left=padding_left, right=padding_right) return button_ class CAttrMap(urwid.AttrMap): """A variant of AttrMap that exposes all properties of the original widget""" def __getattr__(self, name): return getattr(self.original_widget, name) class CPadding(urwid.Padding): """A variant of Patting that exposes some properties of the original widget""" @property def active(self): return self.original_widget.active @property def changed(self): return self.original_widget.changed khal-0.11.4/khal/utils.py000066400000000000000000000217441477603436700151760ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """collection of utility functions""" import datetime as dt import json import random import re import string from calendar import month_abbr, timegm from textwrap import wrap from typing import Iterator, List, Optional, Tuple import icalendar import pytz import urwid from click import style from .parse_datetime import guesstimedeltafstr from .terminal import get_color def generate_random_uid() -> str: """generate a random uid when random isn't broken, getting a random UID from a pool of roughly 10^56 should be good enough""" choice = string.ascii_uppercase + string.digits return ''.join([random.choice(choice) for _ in range(36)]) RESET = '\x1b[0m' ansi_reset = re.compile(r'\x1b\[0m') ansi_sgr = re.compile(r'\x1b\[' '(?!0m)' # negative lookahead, don't match 0m '([0-9]+;?)+' 'm') def find_last_reset(string: str) -> Tuple[int, int, str]: for match in re.finditer(ansi_reset, string): # noqa B007: this is actually used below. pass try: return match.start(), match.end(), match.group(0) except UnboundLocalError: return -2, -1, '' def find_last_sgr(string: str) -> Tuple[int, int, str]: for match in re.finditer(ansi_sgr, string): # noqa B007: this is actually used below. pass try: return match.start(), match.end(), match.group(0) except UnboundLocalError: return -2, -1, '' def find_unmatched_sgr(string: str) -> Optional[str]: reset_pos, _, _ = find_last_reset(string) sgr_pos, _, sgr = find_last_sgr(string) if sgr_pos > reset_pos: return sgr else: return None def color_wrap(text: str, width: int = 70) -> List[str]: """A variant of wrap that takes SGR codes (somewhat) into account. This doesn't actually adjust the length, but makes sure that lines that enable some attribues also contain a RESET, and also adds that code to the next line """ # TODO we really want to ignore all SGR codes when measuring the width lines = wrap(text, width) for num, _ in enumerate(lines): sgr = find_unmatched_sgr(lines[num]) if sgr is not None: lines[num] += RESET if (num + 1) < len(lines): lines[num + 1] = sgr + lines[num + 1] return lines def get_weekday_occurrence(day: dt.date) -> Tuple[int, int]: """Calculate how often this weekday has already occurred in a given month. :returns: weekday (0=Monday, ..., 6=Sunday), occurrence """ xthday = 1 + (day.day - 1) // 7 return day.weekday(), xthday def get_month_abbr_len() -> int: """Calculate the number of characters we need to display the month abbreviated name. It depends on the locale. """ return max(len(month_abbr[i]) for i in range(1, 13)) + 1 def localize_strip_tz(dates: List[dt.datetime], timezone: dt.tzinfo) -> Iterator[dt.datetime]: """converts a list of dates to timezone, than removes tz info""" for one_date in dates: if getattr(one_date, 'tzinfo', None) is not None: one_date = one_date.astimezone(timezone) one_date = one_date.replace(tzinfo=None) yield one_date def to_unix_time(dtime: dt.datetime) -> float: """convert a datetime object to unix time in UTC (as a float)""" if getattr(dtime, 'tzinfo', None) is not None: dtime = dtime.astimezone(pytz.UTC) unix_time = timegm(dtime.timetuple()) return unix_time def to_naive_utc(dtime: dt.datetime) -> dt.datetime: """convert a datetime object to UTC and than remove the tzinfo, if datetime is naive already, return it """ if not hasattr(dtime, 'tzinfo') or dtime.tzinfo is None: return dtime dtime_utc = dtime.astimezone(pytz.UTC) dtime_naive = dtime_utc.replace(tzinfo=None) return dtime_naive def is_aware(dtime: dt.datetime) -> bool: """test if a datetime instance is timezone aware""" if dtime.tzinfo is not None and dtime.tzinfo.utcoffset(dtime) is not None: return True else: return False def relative_timedelta_str(day: dt.date) -> str: """Converts the timespan from `day` to today into a human readable string. """ days = (day - dt.date.today()).days if days < 0: direction = 'ago' else: direction = 'from now' approx = '' if abs(days) < 7: unit = 'day' count = abs(days) elif abs(days) < 365: unit = 'week' count = int(abs(days) / 7) if abs(days) % 7 != 0: approx = '~' else: unit = 'year' count = int(abs(days) / 365) if abs(days) % 365 != 0: approx = '~' if count > 1: unit += 's' return f'{approx}{count} {unit} {direction}' def get_wrapped_text(widget: urwid.AttrMap) -> str: return widget.original_widget.get_edit_text() def human_formatter(format_string, width=None, colors=True): """Create a formatter that formats events to be human readable.""" def fmt(rows): single = isinstance(rows, dict) if single: rows = [rows] results = [] for row in rows: if 'calendar-color' in row: row['calendar-color'] = get_color(row['calendar-color']) s = format_string.format(**row) if colors: s += style('', reset=True) if width: results += color_wrap(s, width) else: results.append(s) if single: return results[0] else: return results return fmt CONTENT_ATTRIBUTES = ['start', 'start-long', 'start-date', 'start-date-long', 'start-time', 'end', 'end-long', 'end-date', 'end-date-long', 'end-time', 'duration', 'start-full', 'start-long-full', 'start-date-full', 'start-date-long-full', 'start-time-full', 'end-full', 'end-long-full', 'end-date-full', 'end-date-long-full', 'end-time-full', 'duration-full', 'start-style', 'end-style', 'to-style', 'start-end-time-style', 'end-necessary', 'end-necessary-long', 'repeat-symbol', 'repeat-pattern', 'title', 'organizer', 'description', 'location', 'all-day', 'categories', 'uid', 'url', 'calendar', 'calendar-color', 'status', 'cancelled'] def json_formatter(fields): """Create a formatter that formats events in JSON.""" if len(fields) == 1 and fields[0] == 'all': fields = CONTENT_ATTRIBUTES def fmt(rows): single = isinstance(rows, dict) if single: rows = [rows] filtered = [] for row in rows: f = dict(filter(lambda e: e[0] in fields and e[0] in CONTENT_ATTRIBUTES, row.items())) if f.get('repeat-symbol', '') != '': f["repeat-symbol"] = f["repeat-symbol"].strip() if f.get('status', '') != '': f["status"] = f["status"].strip() if f.get('cancelled', '') != '': f["cancelled"] = f["cancelled"].strip() filtered.append(f) results = [json.dumps(filtered, ensure_ascii=False)] if single: return results[0] else: return results return fmt def alarmstr2trigger(alarms: str) -> Iterator[dt.timedelta]: """convert a comma separated list of alarm strings to dt.timedelta""" for alarm in alarms.split(","): alarm = alarm.strip() alarm_trig = -1 * guesstimedeltafstr(alarm) yield alarm_trig def str2alarm(alarms: str, description: str) -> Iterator[icalendar.Alarm]: """convert a comma separated list of alarm strings to icalendar.Alarm""" for alarm_trig in alarmstr2trigger(alarms): new_alarm = icalendar.Alarm() new_alarm.add('ACTION', 'DISPLAY') new_alarm.add('TRIGGER', alarm_trig) new_alarm.add('DESCRIPTION', description) yield new_alarm khal-0.11.4/misc/000077500000000000000000000000001477603436700134705ustar00rootroot00000000000000khal-0.11.4/misc/khal.desktop000066400000000000000000000002611477603436700160010ustar00rootroot00000000000000[Desktop Entry] Name=ikhal Categories=Calendar;ConsoleOnly; GenericName=Calendar application Comment=Terminal CLI calendar application Exec=ikhal Terminal=true Type=Application khal-0.11.4/misc/mutt2khal000077500000000000000000000023741477603436700153370ustar00rootroot00000000000000#!/usr/bin/awk -f # mutt2khal is designed to be used in conjunction with vcalendar-filter (https://github.com/terabyte/mutt-filters/blob/master/vcalendar-filter) # and was inspired by the work of Jason Ryan (https://bitbucket.org/jasonwryan/shiv/src/tip/Scripts/mutt2khal) # example muttrc: macro attach A "vcalendar-filter | mutt2khal" /^Summary/ { sub(/^Summary[ ]*:[ ]*/, "") gsub("'", "'\\''") summ = sprintf("'%s'", $0) next } /^Location/ { sub(/^Location[ ]*:[ ]*/,"") gsub("'", "'\\''") loc = sprintf("'%s'", $0) next } /^Desc/ { sub(/^Description[ ]*:[ ]*/, "") gsub("'", "'\\''") desc = sprintf("'%s'", $0) next } /^Dtstart/ { split($3, a, "-") t_st = $4 d_st = sprintf("%s.%s.%s", a[3], a[2], a[1]) next } /^Dtend/ { split($3, a, "-") t_end = $4 d_end = sprintf("%s.%s.%s", a[3], a[2], a[1]) next } END { printf("khal new --location %s %s %s %s %s %s :: %s", loc, d_st, t_st, d_end, t_end, summ, desc) | "sh" } ## IMPORTANT ## # the d_st and d_end variables assume the default datetimeformat variable of #%d.%m.%Y, if another format is in use, the sprintf variables must be changed #accordingly. For example, if the datetimeforma is set to %m.%d.%Y, use: #sprintf("%s.%s.%s", a[2], a[3], a[1]) khal-0.11.4/pyproject.toml000066400000000000000000000041511477603436700154520ustar00rootroot00000000000000[project] name = "khal" dynamic = ["version"] description = "Standards based terminal calendar" readme = "README.rst" authors = [ {name = "khal contributors", email = "khal@lostpackets.de"}, ] license = {file = "LICENSE"} classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console :: Curses", "Intended Audience :: End Users/Desktop", "License :: OSI Approved :: MIT License", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Communications", "Topic :: Utilities", ] requires-python = ">=3.8,<3.14" dependencies = [ "click>=3.2", "click_log>=0.2.0", "icalendar>=4.0.3,<6.0.0", "urwid>=2.6.15", "pyxdg", "pytz", "python-dateutil", "configobj", "tzlocal>=1.0", ] [project.optional-dependencies] proctitle = [ "setproctitle" ] test = [ "pytest", "freezegun", "hypothesis", "packaging", "vdirsyncer", "setuptools", # python > 3.12 does not ship pkg_resources anymore "importlib-metadata; python_version <= '3.9'", # importlib.metadata is in stdlib since 3.10 ] docs = [ "sphinx!=1.6.1", "sphinxcontrib-newsfeed", "sphinx-rtd-theme", ] # install all optional dependencies all = ["khal[proctitle,test,docs]"] [project.urls] homepage = "http://lostpackets.de/khal/" repository = "https://github.com/pimutils/khal" [project.scripts] khal = "khal.cli:main_khal" ikhal = "khal.cli:main_ikhal" [build-system] requires = ["setuptools>=64", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" [tool.setuptools.packages] find = {} [tool.setuptools_scm] version_file = "khal/version.py" [tool.ruff] select = ["E", "F", "W", "I", "B0", "UP", "C4"] ignore = ["B008"] line-length = 100 target-version = "py38" [tool.coverage.report] exclude_lines = [ "if TYPE_CHECKING:", ] [tool.mypy] ignore_missing_imports = true khal-0.11.4/tests/000077500000000000000000000000001477603436700136775ustar00rootroot00000000000000khal-0.11.4/tests/__init__.py000066400000000000000000000000001477603436700157760ustar00rootroot00000000000000khal-0.11.4/tests/backend_test.py000066400000000000000000000723161477603436700167100ustar00rootroot00000000000000import datetime as dt from operator import itemgetter import icalendar import pkg_resources import pytest from khal.khalendar import backend from khal.khalendar.exceptions import OutdatedDbVersionError, UpdateFailed from .utils import BERLIN, LOCALE_BERLIN, _get_text calname = 'home' def test_new_db_version(): dbi = backend.SQLiteDb(calname, ':memory:', locale=LOCALE_BERLIN) backend.DB_VERSION += 1 with pytest.raises(OutdatedDbVersionError): dbi._check_table_version() def test_event_rrule_recurrence_id(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert dbi.list(calname) == [] events = dbi.get_localized( BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)), ) assert list(events) == [] dbi.update(_get_text('event_rrule_recuid'), href='12345.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('12345.ics', 'abcd')] events = dbi.get_localized( BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)), ) events = sorted(events, key=itemgetter(2)) assert len(events) == 6 # start assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0)) assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0)) calendars = dbi.get_localized_calendars( BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0)), ) calendars = list(calendars) assert len(calendars) == 6 def test_event_rrule_recurrence_id_invalid_tzid(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(_get_text('event_rrule_recuid_invalid_tzid'), href='12345.ics', etag='abcd', calendar=calname) events = dbi.get_localized( BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=itemgetter(2)) assert len(events) == 6 assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0)) assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0)) event_rrule_recurrence_id_reverse = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;COUNT=6 DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT END:VCALENDAR """ def test_event_rrule_recurrence_id_reverse(): """as icalendar elements can be saved in arbitrary order, we also have to deal with `reverse` ordered icalendar files """ dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert dbi.list(calname) == [] events = dbi.get_localized( BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0))) assert list(events) == [] dbi.update(event_rrule_recurrence_id_reverse, href='12345.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('12345.ics', 'abcd')] events = dbi.get_localized( BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0))) events = sorted(events, key=itemgetter(2)) assert len(events) == 6 assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0)) assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0)) def test_event_rrule_recurrence_id_update_with_exclude(): """ test if updates work as they should. The updated event has the extra RECURRENCE-ID event removed and one recurrence date excluded via EXDATE """ dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(_get_text('event_rrule_recuid'), href='12345.ics', etag='abcd', calendar=calname) dbi.update(_get_text('event_rrule_recuid_update'), href='12345.ics', etag='abcd', calendar=calname) events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=itemgetter(2)) assert len(events) == 5 assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 7, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 7, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 7, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 7, 0)) def test_event_recuid_no_master(): """ test for events which have a RECUID component, but the master event is not present in the same file """ dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(_get_text('event_dt_recuid_no_master'), href='12345.ics', etag='abcd', calendar=calname) events = dbi.get_floating( dt.datetime(2017, 3, 1, 0, 0), dt.datetime(2017, 4, 1, 0, 0), ) events = sorted(events, key=itemgetter(2)) assert len(events) == 1 assert events[0][2] == dt.datetime(2017, 3, 29, 16) assert events[0][3] == dt.datetime(2017, 3, 29, 16, 25) assert 'SUMMARY:Infrastructure Planning' in events[0][0] def test_event_recuid_rrule_no_master(): """ test for events which have a RECUID and a RRULE component, but the master event is not present in the same file """ dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update( _get_text('event_dt_multi_recuid_no_master'), href='12345.ics', etag='abcd', calendar=calname, ) events = dbi.get_floating( dt.datetime(2010, 1, 1, 0, 0), dt.datetime(2020, 1, 1, 0, 0), ) events = sorted(events, key=itemgetter(2)) assert len(list(events)) == 2 assert events[0][2] == dt.datetime(2014, 6, 30, 7, 30) assert events[0][3] == dt.datetime(2014, 6, 30, 12, 0) assert events[1][2] == dt.datetime(2014, 7, 7, 8, 30) assert events[1][3] == dt.datetime(2014, 7, 7, 12, 0) events = dbi.search('VEVENT') assert len(list(events)) == 2 def test_no_valid_timezone(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(_get_text('event_dt_local_missing_tz'), href='12345.ics', etag='abcd', calendar=calname) events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 9, 0, 0)), BERLIN.localize(dt.datetime(2014, 4, 10, 0, 0))) events = sorted(events) assert len(events) == 1 event = events[0] assert event[2] == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event[3] == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) def test_event_delete(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert dbi.list(calname) == [] events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 8, 26, 0, 0))) assert list(events) == [] dbi.update(event_rrule_recurrence_id_reverse, href='12345.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('12345.ics', 'abcd')] events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) assert len(list(events)) == 6 dbi.delete('12345.ics', calendar=calname) events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) assert len(list(events)) == 0 event_rrule_this_and_prior = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:event_rrule_recurrence_id_this_and_prior SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id_this_and_prior SUMMARY:Arbeit RECURRENCE-ID;RANGE=THISANDPRIOR:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT END:VCALENDAR """ def test_this_and_prior(): """we do not support THISANDPRIOR, therefore this should fail""" dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) with pytest.raises(UpdateFailed): dbi.update(event_rrule_this_and_prior, href='12345.ics', etag='abcd', calendar=calname) event_rrule_this_and_future_temp = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit (lang) RECURRENCE-ID;RANGE=THISANDFUTURE:20140707T050000Z DTSTART;TZID=Europe/Berlin:{0} DTEND;TZID=Europe/Berlin:{1} END:VEVENT END:VCALENDAR """ event_rrule_this_and_future = \ event_rrule_this_and_future_temp.format('20140707T090000', '20140707T180000') def test_event_rrule_this_and_future(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(event_rrule_this_and_future, href='12345.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('12345.ics', 'abcd')] events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=itemgetter(2)) assert len(events) == 6 assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 14, 9, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 21, 9, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 28, 9, 0)) assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 4, 9, 0)) assert events[0][3] == BERLIN.localize(dt.datetime(2014, 6, 30, 12, 0)) assert events[1][3] == BERLIN.localize(dt.datetime(2014, 7, 7, 18, 0)) assert events[2][3] == BERLIN.localize(dt.datetime(2014, 7, 14, 18, 0)) assert events[3][3] == BERLIN.localize(dt.datetime(2014, 7, 21, 18, 0)) assert events[4][3] == BERLIN.localize(dt.datetime(2014, 7, 28, 18, 0)) assert events[5][3] == BERLIN.localize(dt.datetime(2014, 8, 4, 18, 0)) assert 'SUMMARY:Arbeit\n' in events[0][0] for event in events[1:]: assert 'SUMMARY:Arbeit (lang)\n' in event[0] event_rrule_this_and_future_multi_day_shift = \ event_rrule_this_and_future_temp.format('20140708T090000', '20140709T150000') def test_event_rrule_this_and_future_multi_day_shift(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(event_rrule_this_and_future_multi_day_shift, href='12345.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('12345.ics', 'abcd')] events = dbi.get_localized(BERLIN.localize(dt.datetime(2014, 4, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=itemgetter(2)) assert len(events) == 6 assert events[0][2] == BERLIN.localize(dt.datetime(2014, 6, 30, 7, 0)) assert events[1][2] == BERLIN.localize(dt.datetime(2014, 7, 8, 9, 0)) assert events[2][2] == BERLIN.localize(dt.datetime(2014, 7, 15, 9, 0)) assert events[3][2] == BERLIN.localize(dt.datetime(2014, 7, 22, 9, 0)) assert events[4][2] == BERLIN.localize(dt.datetime(2014, 7, 29, 9, 0)) assert events[5][2] == BERLIN.localize(dt.datetime(2014, 8, 5, 9, 0)) assert events[0][3] == BERLIN.localize(dt.datetime(2014, 6, 30, 12, 0)) assert events[1][3] == BERLIN.localize(dt.datetime(2014, 7, 9, 15, 0)) assert events[2][3] == BERLIN.localize(dt.datetime(2014, 7, 16, 15, 0)) assert events[3][3] == BERLIN.localize(dt.datetime(2014, 7, 23, 15, 0)) assert events[4][3] == BERLIN.localize(dt.datetime(2014, 7, 30, 15, 0)) assert events[5][3] == BERLIN.localize(dt.datetime(2014, 8, 6, 15, 0)) assert 'SUMMARY:Arbeit\n' in events[0][0] for event in events[1:]: assert 'SUMMARY:Arbeit (lang)\n' in event[0] event_rrule_this_and_future_allday_temp = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:event_rrule_recurrence_id_allday SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806 DTSTART;VALUE=DATE:20140630 DTEND;VALUE=DATE:20140701 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id_allday SUMMARY:Arbeit (lang) RECURRENCE-ID;RANGE=THISANDFUTURE;VALUE=DATE:20140707 DTSTART;VALUE=DATE:{} DTEND;VALUE=DATE:{} END:VEVENT END:VCALENDAR """ event_rrule_this_and_future_allday = \ event_rrule_this_and_future_allday_temp.format(20140708, 20140709) def test_event_rrule_this_and_future_allday(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(event_rrule_this_and_future_allday, href='rrule_this_and_future_allday.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('rrule_this_and_future_allday.ics', 'abcd')] events = list(dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0))) assert len(events) == 6 assert events[0][2] == dt.date(2014, 6, 30) assert events[1][2] == dt.date(2014, 7, 8) assert events[2][2] == dt.date(2014, 7, 15) assert events[3][2] == dt.date(2014, 7, 22) assert events[4][2] == dt.date(2014, 7, 29) assert events[5][2] == dt.date(2014, 8, 5) assert events[0][3] == dt.date(2014, 7, 1) assert events[1][3] == dt.date(2014, 7, 9) assert events[2][3] == dt.date(2014, 7, 16) assert events[3][3] == dt.date(2014, 7, 23) assert events[4][3] == dt.date(2014, 7, 30) assert events[5][3] == dt.date(2014, 8, 6) assert 'SUMMARY:Arbeit\n' in events[0][0] for event in events[1:]: assert 'SUMMARY:Arbeit (lang)\n' in event[0] def test_event_rrule_this_and_future_allday_prior(): event_rrule_this_and_future_allday_prior = \ event_rrule_this_and_future_allday_temp.format(20140705, 20140706) dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(event_rrule_this_and_future_allday_prior, href='rrule_this_and_future_allday.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('rrule_this_and_future_allday.ics', 'abcd')] events = list(dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0))) assert len(events) == 6 assert events[0][2] == dt.date(2014, 6, 30) assert events[1][2] == dt.date(2014, 7, 5) assert events[2][2] == dt.date(2014, 7, 12) assert events[3][2] == dt.date(2014, 7, 19) assert events[4][2] == dt.date(2014, 7, 26) assert events[5][2] == dt.date(2014, 8, 2) assert events[0][3] == dt.date(2014, 7, 1) assert events[1][3] == dt.date(2014, 7, 6) assert events[2][3] == dt.date(2014, 7, 13) assert events[3][3] == dt.date(2014, 7, 20) assert events[4][3] == dt.date(2014, 7, 27) assert events[5][3] == dt.date(2014, 8, 3) assert 'SUMMARY:Arbeit\n' in events[0][0] for event in events[1:]: assert 'SUMMARY:Arbeit (lang)\n' in event[0] event_rrule_multi_this_and_future_allday = """BEGIN:VCALENDAR BEGIN:VEVENT UID:event_multi_rrule_recurrence_id_allday SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806 DTSTART;VALUE=DATE:20140630 DTEND;VALUE=DATE:20140701 END:VEVENT BEGIN:VEVENT UID:event_multi_rrule_recurrence_id_allday SUMMARY:Arbeit (neu) RECURRENCE-ID;RANGE=THISANDFUTURE;VALUE=DATE:20140721 DTSTART;VALUE=DATE:20140717 DTEND;VALUE=DATE:20140718 END:VEVENT BEGIN:VEVENT UID:event_multi_rrule_recurrence_id_allday SUMMARY:Arbeit (lang) RECURRENCE-ID;RANGE=THISANDFUTURE;VALUE=DATE:20140707 DTSTART;VALUE=DATE:20140712 DTEND;VALUE=DATE:20140714 END:VEVENT END:VCALENDAR""" def test_event_rrule_multi_this_and_future_allday(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(event_rrule_multi_this_and_future_allday, href='event_rrule_multi_this_and_future_allday.ics', etag='abcd', calendar=calname) assert dbi.list(calname) == [('event_rrule_multi_this_and_future_allday.ics', 'abcd')] events = sorted( dbi.get_floating(dt.datetime(2014, 4, 30, 0, 0), dt.datetime(2014, 9, 27, 0, 0)), ) assert len(events) == 6 assert events[0][2] == dt.date(2014, 6, 30) assert events[1][2] == dt.date(2014, 7, 12) assert events[2][2] == dt.date(2014, 7, 17) assert events[3][2] == dt.date(2014, 7, 19) assert events[4][2] == dt.date(2014, 7, 24) assert events[5][2] == dt.date(2014, 7, 31) assert events[0][3] == dt.date(2014, 7, 1) assert events[1][3] == dt.date(2014, 7, 14) assert events[2][3] == dt.date(2014, 7, 18) assert events[3][3] == dt.date(2014, 7, 21) assert events[4][3] == dt.date(2014, 7, 25) assert events[5][3] == dt.date(2014, 8, 1) assert 'SUMMARY:Arbeit\n' in events[0][0] for event in [events[1], events[3]]: assert 'SUMMARY:Arbeit (lang)\n' in event[0] for event in [events[2], events[4], events[5]]: assert 'SUMMARY:Arbeit (neu)\n' in event[0] master = """BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT""" recuid_this_future = icalendar.Event.from_ical("""BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID;RANGE=THISANDFUTURE:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT""") recuid_this_future_duration = icalendar.Event.from_ical("""BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID;RANGE=THISANDFUTURE:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DURATION:PT4H30M END:VEVENT""") def test_calc_shift_deltas(): assert (dt.timedelta(hours=2), dt.timedelta(hours=5)) == \ backend.calc_shift_deltas(recuid_this_future) assert (dt.timedelta(hours=2), dt.timedelta(hours=4, minutes=30)) == \ backend.calc_shift_deltas(recuid_this_future_duration) event_a = """BEGIN:VEVENT UID:123 SUMMARY:event a RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT""" event_b = """BEGIN:VEVENT UID:123 SUMMARY:event b RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT""" def test_two_calendars_same_uid(): home = 'home' work = 'work' dbi = backend.SQLiteDb([home, work], ':memory:', locale=LOCALE_BERLIN) assert dbi.list(home) == [] assert dbi.list(work) == [] dbi.update(event_a, href='12345.ics', etag='abcd', calendar=home) assert dbi.list(home) == [('12345.ics', 'abcd')] assert dbi.list(work) == [] dbi.update(event_b, href='12345.ics', etag='abcd', calendar=work) assert dbi.list(home) == [('12345.ics', 'abcd')] assert dbi.list(work) == [('12345.ics', 'abcd')] dbi.calendars = [home] events_a = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) dbi.calendars = [work] events_b = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) assert len(events_a) == 4 assert len(events_b) == 4 dbi.calendars = [work, home] events_c = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) assert len(events_c) == 8 # count events from a given calendar assert [event[6] for event in events_c].count(home) == 4 assert [event[6] for event in events_c].count(work) == 4 dbi.delete('12345.ics', calendar=home) dbi.calendars = [home] events_a = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) dbi.calendars = [work] events_b = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) assert len(events_a) == 0 assert len(events_b) == 4 dbi.calendars = [work, home] events_c = list(dbi.get_localized(BERLIN.localize(dt.datetime(2014, 6, 30, 0, 0)), BERLIN.localize(dt.datetime(2014, 7, 26, 0, 0)))) assert [event[6] for event in events_c].count('home') == 0 assert [event[6] for event in events_c].count('work') == 4 assert dbi.list(home) == [] assert dbi.list(work) == [('12345.ics', 'abcd')] def test_update_one_should_not_affect_others(): """test if an THISANDFUTURE param effects other events as well""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update(_get_text('event_d_15'), href='first', calendar=calname) events = db.get_floating(dt.datetime(2015, 4, 9, 0, 0), dt.datetime(2015, 4, 10, 0, 0)) assert len(list(events)) == 1 db.update(event_rrule_multi_this_and_future_allday, href='second', calendar=calname) events = list(db.get_floating(dt.datetime(2015, 4, 9, 0, 0), dt.datetime(2015, 4, 10, 0, 0))) assert len(events) == 1 def test_no_dtend(): """test support for events with no dtend""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update(_get_text('event_dt_no_end'), href='event_dt_no_end', calendar=calname) events = db.get_localized( BERLIN.localize(dt.datetime(2016, 1, 16, 0, 0)), BERLIN.localize(dt.datetime(2016, 1, 17, 0, 0)), ) event = list(events)[0] assert event[2] == BERLIN.localize(dt.datetime(2016, 1, 16, 8, 0)) assert event[3] == BERLIN.localize(dt.datetime(2016, 1, 16, 9, 0)) event_rdate_period = """BEGIN:VEVENT SUMMARY:RDATE period DTSTART:19961230T020000Z DTEND:19961230T060000Z UID:rdate_period RDATE;VALUE=PERIOD:19970101T180000Z/19970102T070000Z,19970109T180000Z/PT5H30M END:VEVENT""" supported_events = [ event_a, event_b, event_rrule_this_and_future, event_rrule_this_and_future_allday, event_rrule_this_and_future_multi_day_shift ] def test_check_support(): for cal_str in supported_events: ical = icalendar.Calendar.from_ical(cal_str) [backend.check_support(event, '', '') for event in ical.walk()] ical = icalendar.Calendar.from_ical(event_rrule_this_and_prior) with pytest.raises(UpdateFailed): [backend.check_support(event, '', '') for event in ical.walk()] # icalendar 3.9.2 changed how it deals with unsupported components if pkg_resources.get_distribution('icalendar').parsed_version \ <= pkg_resources.parse_version('3.9.1'): ical = icalendar.Calendar.from_ical(event_rdate_period) with pytest.raises(UpdateFailed): [backend.check_support(event, '', '') for event in ical.walk()] def test_check_support_rdate_no_values(): """check if `check_support` doesn't choke on events with an RDATE property without a VALUE parameter""" ical = icalendar.Calendar.from_ical(_get_text('event_rdate_no_value')) [backend.check_support(event, '', '') for event in ical.walk()] card = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:19710311 END:VCARD """ card_29thfeb = """BEGIN:VCARD VERSION:3.0 FN:leapyear BDAY:20000229 END:VCARD """ card_no_year = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:--0311 END:VCARD """ card_does_not_parse = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:x END:VCARD """ card_no_fn = """BEGIN:VCARD VERSION:3.0 N:Ritchie;Dennis;MacAlistair;; BDAY:19410909 END:VCARD """ card_two_birthdays = """BEGIN:VCARD VERSION:3.0 N:Ritchie;Dennis;MacAlistair;; BDAY:19410909 BDAY:--0311 END:VCARD """ card_anniversary = """BEGIN:VCARD VERSION:3.0 FN:Unix X-ANNIVERSARY:19710311 END:VCARD """ card_abdate = """BEGIN:VCARD VERSION:3.0 FN:Unix ITEM1.X-ABDATE:19710311 ITEM1.X-ABLabel:spouse's birthday END:VCARD """ card_abdate_nolabel = """BEGIN:VCARD VERSION:3.0 FN:Unix ITEM1.X-ABDATE:19710311 END:VCARD """ card_v3 = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:1971-03-11 END:VCARD """ day = dt.date(1971, 3, 11) start = dt.datetime.combine(day, dt.time.min) end = dt.datetime.combine(day, dt.time.max) def test_birthdays(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert 'SUMMARY:Unix\'s birthday' in events[0][0] events = list( db.get_floating( dt.datetime(2016, 3, 11, 0, 0), dt.datetime(2016, 3, 11, 23, 59, 59, 999))) assert 'SUMMARY:Unix\'s birthday' in events[0][0] def test_birthdays_update(): """test if we can update a birthday""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update_vcf_dates(card, 'unix.vcf', calendar=calname) db.update_vcf_dates(card, 'unix.vcf', calendar=calname) def test_birthdays_no_fn(): db = backend.SQLiteDb(['home'], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(dt.datetime(1941, 9, 9, 0, 0), dt.datetime(1941, 9, 9, 23, 59, 59, 9999))) == [] db.update_vcf_dates(card_no_fn, 'unix.vcf', calendar=calname) events = list(db.get_floating(dt.datetime(1941, 9, 9, 0, 0), dt.datetime(1941, 9, 9, 23, 59, 59, 9999))) assert len(events) == 1 assert 'SUMMARY:Dennis MacAlistair Ritchie\'s birthday' in events[0][0] def test_birthday_does_not_parse(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_does_not_parse, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 0 def test_vcard_two_birthdays(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_two_birthdays, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 0 def test_anniversary(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_anniversary, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert 'SUMMARY:Unix\'s anniversary' in events[0][0] events = list( db.get_floating( dt.datetime(2016, 3, 11, 0, 0), dt.datetime(2016, 3, 11, 23, 59, 59, 999))) assert 'SUMMARY:Unix\'s anniversary' in events[0][0] def test_abdate(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_abdate, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert 'SUMMARY:Unix\'s spouse\'s birthday' in events[0][0] events = list( db.get_floating( dt.datetime(2016, 3, 11, 0, 0), dt.datetime(2016, 3, 11, 23, 59, 59, 999))) assert 'SUMMARY:Unix\'s spouse\'s birthday' in events[0][0] def test_abdate_nolabel(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_abdate_nolabel, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert 'SUMMARY:Unix\'s custom event from vcard' in events[0][0] events = list( db.get_floating( dt.datetime(2016, 3, 11, 0, 0), dt.datetime(2016, 3, 11, 23, 59, 59, 999))) assert 'SUMMARY:Unix\'s custom event from vcard' in events[0][0] def test_birthday_v3(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == [] db.update_vcf_dates(card_v3, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert 'SUMMARY:Unix\'s birthday' in events[0][0] events = list( db.get_floating( dt.datetime(2016, 3, 11, 0, 0), dt.datetime(2016, 3, 11, 23, 59, 59, 999))) assert 'SUMMARY:Unix\'s birthday' in events[0][0] khal-0.11.4/tests/cal_display_test.py000066400000000000000000000403231477603436700175760ustar00rootroot00000000000000import datetime as dt import locale import platform import unicodedata import pytest from khal.calendar_display import ( get_calendar_color, get_color_list, getweeknumber, str_week, vertical_month, ) today = dt.date.today() yesterday = today - dt.timedelta(days=1) tomorrow = today + dt.timedelta(days=1) def test_getweeknumber(): assert getweeknumber(dt.date(2011, 12, 12)) == 50 assert getweeknumber(dt.date(2011, 12, 31)) == 52 assert getweeknumber(dt.date(2012, 1, 1)) == 52 assert getweeknumber(dt.date(2012, 1, 2)) == 1 def test_str_week(): aday = dt.date(2012, 6, 1) bday = dt.date(2012, 6, 8) week = [dt.date(2012, 6, 6), dt.date(2012, 6, 7), dt.date(2012, 6, 8), dt.date(2012, 6, 9), dt.date(2012, 6, 10), dt.date(2012, 6, 11), dt.date(2012, 6, 12), dt.date(2012, 6, 13)] assert str_week(week, aday) == ' 6 7 8 9 10 11 12 13 ' assert str_week(week, bday) == ' 6 7 \x1b[7m 8\x1b[0m 9 10 11 12 13 ' class testCollection: def __init__(self) -> None: self._calendars : dict[str, dict]= {} def addCalendar(self, name: str , color: str, priority: int) -> None: self._calendars[name] = {'color': color, 'priority': priority} def test_get_calendar_color(): exampleCollection = testCollection() exampleCollection.addCalendar('testCalendar1', 'dark red', 20) exampleCollection.addCalendar('testCalendar2', 'light green', 10) exampleCollection.addCalendar('testCalendar3', '', 10) assert get_calendar_color('testCalendar1', 'light blue', exampleCollection) == 'dark red' assert get_calendar_color('testCalendar2', 'light blue', exampleCollection) == 'light green' # test default color assert get_calendar_color('testCalendar3', 'light blue', exampleCollection) == 'light blue' def test_get_color_list(): exampleCalendarList = ['testCalendar1', 'testCalendar2'] # test different priorities exampleCollection1 = testCollection() exampleCollection1.addCalendar('testCalendar1', 'dark red', 20) exampleCollection1.addCalendar('testCalendar2', 'light green', 10) testList1 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection1) assert 'dark red' in testList1 assert len(testList1) == 1 # test same priorities exampleCollection2 = testCollection() exampleCollection2.addCalendar('testCalendar1', 'dark red', 20) exampleCollection2.addCalendar('testCalendar2', 'light green', 20) testList2 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection2) assert 'dark red' in testList2 assert 'light green' in testList2 assert len(testList2) == 2 # test duplicated colors exampleCollection3 = testCollection() exampleCollection3.addCalendar('testCalendar1', 'dark red', 20) exampleCollection3.addCalendar('testCalendar2', 'dark red', 20) testList3 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection3) assert len(testList3) == 1 # test indexing operator (required by str_highlight_day()) exampleCollection4 = testCollection() exampleCollection4.addCalendar('testCalendar1', 'dark red', 20) exampleCollection4.addCalendar('testCalendar2', 'dark red', 20) testList3 = get_color_list(exampleCalendarList, 'light_blue', exampleCollection4) assert testList3[0] == 'dark red' example1 = [ '\x1b[1m Mo Tu We Th Fr Sa Su \x1b[0m', '\x1b[1mDec \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mJan \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mFeb \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mMar \x1b[0m27 28 29 1 2 3 4 '] example2 = [ '\x1b[1m Mo Tu We Th Fr Sa Su \x1b[0m', ' 28 29 30 1 2 3 4 ', '\x1b[1mDec \x1b[0m 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', ' 26 27 28 29 30 31 1 ', '\x1b[1mJan \x1b[0m 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', ' 30 31 1 2 3 4 5 ', '\x1b[1mFeb \x1b[0m 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', ' 27 28 29 1 2 3 4 '] example_weno = [ '\x1b[1m Mo Tu We Th Fr Sa Su \x1b[0m', '\x1b[1mDec \x1b[0m28 29 30 1 2 3 4 \x1b[1m48\x1b[0m', ' 5 6 7 8 9 10 11 \x1b[1m49\x1b[0m', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 \x1b[1m50\x1b[0m', ' 19 20 21 22 23 24 25 \x1b[1m51\x1b[0m', '\x1b[1mJan \x1b[0m26 27 28 29 30 31 1 \x1b[1m52\x1b[0m', ' 2 3 4 5 6 7 8 \x1b[1m 1\x1b[0m', ' 9 10 11 12 13 14 15 \x1b[1m 2\x1b[0m', ' 16 17 18 19 20 21 22 \x1b[1m 3\x1b[0m', ' 23 24 25 26 27 28 29 \x1b[1m 4\x1b[0m', '\x1b[1mFeb \x1b[0m30 31 1 2 3 4 5 \x1b[1m 5\x1b[0m', ' 6 7 8 9 10 11 12 \x1b[1m 6\x1b[0m', ' 13 14 15 16 17 18 19 \x1b[1m 7\x1b[0m', ' 20 21 22 23 24 25 26 \x1b[1m 8\x1b[0m', '\x1b[1mMar \x1b[0m27 28 29 1 2 3 4 \x1b[1m 9\x1b[0m'] example_we_start_su = [ '\x1b[1m Su Mo Tu We Th Fr Sa \x1b[0m', '\x1b[1mDec \x1b[0m27 28 29 30 1 2 3 ', ' 4 5 6 7 8 9 10 ', ' 11 \x1b[7m12\x1b[0m 13 14 15 16 17 ', ' 18 19 20 21 22 23 24 ', ' 25 26 27 28 29 30 31 ', '\x1b[1mJan \x1b[0m 1 2 3 4 5 6 7 ', ' 8 9 10 11 12 13 14 ', ' 15 16 17 18 19 20 21 ', ' 22 23 24 25 26 27 28 ', '\x1b[1mFeb \x1b[0m29 30 31 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' 12 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mMar \x1b[0m26 27 28 29 1 2 3 '] example_cz = [ '\x1b[1m Po \xdat St \u010ct P\xe1 So Ne \x1b[0m', '\x1b[1mpro \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mled \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1m\xfano \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mb\u0159e \x1b[0m27 28 29 1 2 3 4 '] example_gr = [ '\x1b[1m δε τρ τε πε πα σα κυ \x1b[0m', '\x1b[1mδεκ \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mιαν \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mφεβ \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mμαρ \x1b[0m27 28 29 1 2 3 4 '] example_gr_darwin = [ '\x1b[1m δε τρ τε πε πα σα κυ \x1b[0m', '\x1b[1mδεκ \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mιαν \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mφεβ \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mμαρ \x1b[0m27 28 29 1 2 3 4 '] example_de = [ '\x1b[1m Mo Di Mi Do Fr Sa So \x1b[0m', '\x1b[1mDez \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mJan \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mFeb \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mMär \x1b[0m27 28 29 1 2 3 4 '] example_de_freebsd = [ '\x1b[1m Mo Di Mi Do Fr Sa So \x1b[0m', '\x1b[1mDez. \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mJan. \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mFeb. \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mMärz \x1b[0m27 28 29 1 2 3 4 '] example_de_netbsd = [ '\x1b[1m Mo Di Mi Do Fr Sa So \x1b[0m', '\x1b[1mDez. \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mJan. \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mFeb. \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mM\xe4r. \x1b[0m27 28 29 1 2 3 4 '] example_fr = [ '\x1b[1m lu ma me je ve sa di \x1b[0m', '\x1b[1mdéc. \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mjanv. \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mfévr. \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mmars \x1b[0m27 28 29 1 2 3 4 '] example_fr_darwin = [ '\x1b[1m Lu Ma Me Je Ve Sa Di \x1b[0m', '\x1b[1mdéc \x1b[0m28 29 30 1 2 3 4 ', ' 5 6 7 8 9 10 11 ', ' \x1b[7m12\x1b[0m 13 14 15 16 17 18 ', ' 19 20 21 22 23 24 25 ', '\x1b[1mjan \x1b[0m26 27 28 29 30 31 1 ', ' 2 3 4 5 6 7 8 ', ' 9 10 11 12 13 14 15 ', ' 16 17 18 19 20 21 22 ', ' 23 24 25 26 27 28 29 ', '\x1b[1mfév \x1b[0m30 31 1 2 3 4 5 ', ' 6 7 8 9 10 11 12 ', ' 13 14 15 16 17 18 19 ', ' 20 21 22 23 24 25 26 ', '\x1b[1mmar \x1b[0m27 28 29 1 2 3 4 '] def test_vertical_month(): try: locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12)) assert vert_str == example1 vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12), monthdisplay='firstfullweek') assert vert_str == example2 weno_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12), weeknumber='right') assert weno_str == example_weno we_start_su_str = vertical_month( month=12, year=2011, today=dt.date(2011, 12, 12), firstweekday=6) assert we_start_su_str == example_we_start_su except locale.Error as error: if str(error) == 'unsupported locale setting': pytest.xfail( 'To get this test to run, you need to add `en_US.utf-8` to ' 'your locales. On Debian GNU/Linux 8 you do this by ' 'uncommenting `de_DE.utf-8 in /etc/locale.gen and then run ' '`locale-gen` (as root).' ) finally: locale.setlocale(locale.LC_ALL, 'C') def test_vertical_month_unicode(): try: locale.setlocale(locale.LC_ALL, 'de_DE.UTF-8') vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12)) # de_DE locale on at least Net and FreeBSD is different from the one # commonly used on linux systems if platform.system() == 'FreeBSD': assert vert_str == example_de_freebsd elif platform.system() == 'NetBSD': assert vert_str == example_de_netbsd else: assert vert_str == example_de '\n'.join(vert_str) # issue 142 except locale.Error as error: if str(error) == 'unsupported locale setting': pytest.xfail( 'To get this test to run, you need to add `de_DE.utf-8` to ' 'your locales. On Debian GNU/Linux 8 you do this by ' 'uncommenting `de_DE.utf-8 in /etc/locale.gen and then run ' '`locale-gen` (as root).' ) else: raise finally: locale.setlocale(locale.LC_ALL, 'C') def test_vertical_month_unicode_weekdeays(): try: locale.setlocale(locale.LC_ALL, 'cs_CZ.UTF-8') vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12)) assert [line.lower() for line in vert_str] == [line.lower() for line in example_cz] '\n'.join(vert_str) # issue 142/293 except locale.Error as error: if str(error) == 'unsupported locale setting': pytest.xfail( 'To get this test to run, you need to add `cs_CZ.UTF-8` to ' 'your locales. On Debian GNU/Linux 8 you do this by ' 'uncommenting `cs_CZ.UTF-8` in /etc/locale.gen and then run ' '`locale-gen` (as root).' ) else: raise finally: locale.setlocale(locale.LC_ALL, 'C') def strip_accents(string): """remove accents from unicode characters""" return ''.join(c for c in unicodedata.normalize('NFD', string) if unicodedata.category(c) != 'Mn') def test_vertical_month_unicode_weekdeays_gr(): try: locale.setlocale(locale.LC_ALL, 'el_GR.UTF-8') vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12)) # on some OSes, Greek locale's abbreviated day of the week and # month names have accents, on some they haven't if platform.system() == 'Darwin': assert strip_accents('\n'.join([line.lower() for line in vert_str])) == \ '\n'.join(example_gr_darwin) else: assert strip_accents('\n'.join([line.lower() for line in vert_str])) == \ '\n'.join(example_gr) '\n'.join(vert_str) # issue 142/293 except locale.Error as error: if str(error) == 'unsupported locale setting': pytest.xfail( 'To get this test to run, you need to add `el_GR.UTF-8` to ' 'your locales. On Debian GNU/Linux 8 you do this by ' 'uncommenting `el_GR.UTF-8` in /etc/locale.gen and then run ' '`locale-gen` (as root).' ) else: raise finally: locale.setlocale(locale.LC_ALL, 'C') def test_vertical_month_abbr_fr(): # see issue #653 try: locale.setlocale(locale.LC_ALL, 'fr_FR.UTF-8') vert_str = vertical_month(month=12, year=2011, today=dt.date(2011, 12, 12)) if platform.system() == 'Darwin': assert '\n'.join(vert_str) == '\n'.join(example_fr_darwin) else: assert '\n'.join(vert_str) == '\n'.join(example_fr) except locale.Error as error: if str(error) == 'unsupported locale setting': pytest.xfail( 'To get this test to run, you need to add `fr_FR.UTF-8` to ' 'your locales. On Debian GNU/Linux 8 you do this by ' 'uncommenting `fr_FR.UTF-8` in /etc/locale.gen and then run ' '`locale-gen` (as root).' ) else: raise finally: locale.setlocale(locale.LC_ALL, 'C') khal-0.11.4/tests/cli_test.py000066400000000000000000001020021477603436700160520ustar00rootroot00000000000000import datetime as dt import json import os import re import sys import traceback import pytest from click.testing import CliRunner from freezegun import freeze_time from khal.cli import main_ikhal, main_khal from khal.utils import CONTENT_ATTRIBUTES from .utils import _get_ics_filepath, _get_text class CustomCliRunner(CliRunner): def __init__(self, config_file, db=None, calendars=None, xdg_data_home=None, xdg_config_home=None, tmpdir=None, **kwargs) -> None: self.config_file = config_file self.db = db self.calendars = calendars self.xdg_data_home = xdg_data_home self.xdg_config_home = xdg_config_home self.tmpdir = tmpdir super().__init__(**kwargs) def invoke(self, cli, args=None, *a, **kw): args = ['-c', str(self.config_file)] + (args or []) return super().invoke(cli, args, *a, **kw) @pytest.fixture def runner(tmpdir, monkeypatch): db = tmpdir.join('khal.db') calendar = tmpdir.mkdir('calendar') calendar2 = tmpdir.mkdir('calendar2') calendar3 = tmpdir.mkdir('calendar3') xdg_data_home = tmpdir.join('vdirs') xdg_config_home = tmpdir.join('.config') config_file = xdg_config_home.join('khal').join('config') # TODO create a vdir config on disk and let vdirsyncer actually read it monkeypatch.setattr('vdirsyncer.cli.config.load_config', lambda: Config()) monkeypatch.setattr('xdg.BaseDirectory.xdg_data_home', str(xdg_data_home)) monkeypatch.setattr('xdg.BaseDirectory.xdg_config_home', str(xdg_config_home)) monkeypatch.setattr('xdg.BaseDirectory.xdg_config_dirs', [str(xdg_config_home)]) def inner(print_new=False, default_calendar=True, days=2, **kwargs): if default_calendar: default_calendar = 'default_calendar = one' else: default_calendar = '' if not os.path.exists(str(xdg_config_home.join('khal'))): os.makedirs(str(xdg_config_home.join('khal'))) config_file.write(config_template.format( delta=str(days) + 'd', calpath=str(calendar), calpath2=str(calendar2), calpath3=str(calendar3), default_calendar=default_calendar, print_new=print_new, dbpath=str(db), **kwargs)) runner = CustomCliRunner( config_file=config_file, db=db, calendars={"one": calendar}, xdg_data_home=xdg_data_home, xdg_config_home=xdg_config_home, tmpdir=tmpdir, ) return runner return inner config_template = ''' [calendars] [[one]] path = {calpath} color = dark blue [[two]] path = {calpath2} color = dark green [[three]] path = {calpath3} [locale] local_timezone = Europe/Berlin default_timezone = Europe/Berlin timeformat = %H:%M dateformat = %d.%m. longdateformat = %d.%m.%Y datetimeformat = %d.%m. %H:%M longdatetimeformat = %d.%m.%Y %H:%M firstweekday = 0 [default] {default_calendar} timedelta = {delta} print_new = {print_new} [sqlite] path = {dbpath} ''' def test_direct_modification(runner): runner = runner() result = runner.invoke(main_khal, ['list']) assert result.output == '' assert not result.exception cal_dt = _get_text('event_dt_simple') event = runner.calendars['one'].join('test.ics') event.write(cal_dt) format = '{start-end-time-style}: {title}' args = ['list', '--format', format, '--day-format', '', '09.04.2014'] result = runner.invoke(main_khal, args) assert not result.exception assert result.output == '09:30-10:30: An Event\n' os.remove(str(event)) result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == '' def test_simple(runner): runner = runner(days=2) result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == '' now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke( main_khal, f'new {now} 18:00 myevent'.split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal, ['list']) print(result.output) assert 'myevent' in result.output assert '18:00' in result.output # test show_all_days default value assert 'Tomorrow:' not in result.output assert not result.exception def test_simple_color(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke(main_khal, f'new {now} 18:00 myevent'.split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal, ['list'], color=True) assert not result.exception assert '\x1b[34m' in result.output def test_days(runner): runner = runner(days=9) when = (dt.datetime.now() + dt.timedelta(days=7)).strftime('%d.%m.%Y') result = runner.invoke(main_khal, f'new {when} 18:00 nextweek'.split()) assert result.output == '' assert not result.exception when = (dt.datetime.now() + dt.timedelta(days=30)).strftime('%d.%m.%Y') result = runner.invoke(main_khal, f'new {when} 18:00 nextmonth'.split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal, ['list']) assert 'nextweek' in result.output assert 'nextmonth' not in result.output assert '18:00' in result.output assert not result.exception def test_notstarted(runner): with freeze_time('2015-6-1 15:00'): runner = runner(days=2) for command in [ 'new 30.5.2015 5.6.2015 long event', 'new 2.6.2015 4.6.2015 two day event', 'new 1.6.2015 14:00 18:00 four hour event', 'new 1.6.2015 16:00 17:00 one hour event', 'new 2.6.2015 10:00 13:00 three hour event', ]: result = runner.invoke(main_khal, command.split()) assert not result.exception result = runner.invoke(main_khal, 'list now'.split()) assert result.output == \ """Today, 01.06.2015 ↔ long event 14:00-18:00 four hour event 16:00-17:00 one hour event Tomorrow, 02.06.2015 ↔ long event ↦ two day event 10:00-13:00 three hour event Wednesday, 03.06.2015 ↔ long event ↔ two day event """ assert not result.exception result = runner.invoke(main_khal, 'list now --notstarted'.split()) assert result.output == \ """Today, 01.06.2015 16:00-17:00 one hour event Tomorrow, 02.06.2015 ↦ two day event 10:00-13:00 three hour event Wednesday, 03.06.2015 ↔ two day event """ assert not result.exception result = runner.invoke(main_khal, 'list now --once'.split()) assert result.output == \ """Today, 01.06.2015 ↔ long event 14:00-18:00 four hour event 16:00-17:00 one hour event Tomorrow, 02.06.2015 ↦ two day event 10:00-13:00 three hour event """ assert not result.exception result = runner.invoke(main_khal, 'list now --once --notstarted'.split()) assert result.output == \ """Today, 01.06.2015 16:00-17:00 one hour event Tomorrow, 02.06.2015 ↦ two day event 10:00-13:00 three hour event """ assert not result.exception def test_calendar(runner): with freeze_time('2015-6-1'): runner = runner(days=0) result = runner.invoke(main_khal, ['calendar']) assert not result.exception assert result.exit_code == 0 output = '\n'.join([ " Mo Tu We Th Fr Sa Su No events", "Jun 1 2 3 4 5 6 7 ", " 8 9 10 11 12 13 14 ", " 15 16 17 18 19 20 21 ", " 22 23 24 25 26 27 28 ", "Jul 29 30 1 2 3 4 5 ", " 6 7 8 9 10 11 12 ", " 13 14 15 16 17 18 19 ", " 20 21 22 23 24 25 26 ", "Aug 27 28 29 30 31 1 2 ", " 3 4 5 6 7 8 9 ", " 10 11 12 13 14 15 16 ", " 17 18 19 20 21 22 23 ", " 24 25 26 27 28 29 30 ", "Sep 31 1 2 3 4 5 6 ", "", ]) assert result.output == output def test_long_calendar(runner): with freeze_time('2015-6-1'): runner = runner(days=100) result = runner.invoke(main_khal, ['calendar']) assert not result.exception assert result.exit_code == 0 output = '\n'.join([ " Mo Tu We Th Fr Sa Su No events", "Jun 1 2 3 4 5 6 7 ", " 8 9 10 11 12 13 14 ", " 15 16 17 18 19 20 21 ", " 22 23 24 25 26 27 28 ", "Jul 29 30 1 2 3 4 5 ", " 6 7 8 9 10 11 12 ", " 13 14 15 16 17 18 19 ", " 20 21 22 23 24 25 26 ", "Aug 27 28 29 30 31 1 2 ", " 3 4 5 6 7 8 9 ", " 10 11 12 13 14 15 16 ", " 17 18 19 20 21 22 23 ", " 24 25 26 27 28 29 30 ", "Sep 31 1 2 3 4 5 6 ", " 7 8 9 10 11 12 13 ", " 14 15 16 17 18 19 20 ", " 21 22 23 24 25 26 27 ", "Oct 28 29 30 1 2 3 4 ", "", ]) assert result.output == output def test_default_command_empty(runner): runner = runner(days=2) result = runner.invoke(main_khal) assert result.exception assert result.exit_code == 2 assert result.output.startswith('Usage: ') def test_invalid_calendar(runner): runner = runner(days=2) result = runner.invoke( main_khal, ['new'] + '-a one 18:00 myevent'.split()) assert not result.exception result = runner.invoke( main_khal, ['new'] + '-a inexistent 18:00 myevent'.split()) assert result.exception assert result.exit_code == 2 assert 'Unknown calendar ' in result.output def test_attach_calendar(runner): runner = runner(days=2) result = runner.invoke(main_khal, ['printcalendars']) assert set(result.output.split('\n')[:3]) == {'one', 'two', 'three'} assert not result.exception result = runner.invoke(main_khal, ['printcalendars', '-a', 'one']) assert result.output == 'one\n' assert not result.exception result = runner.invoke(main_khal, ['printcalendars', '-d', 'one']) assert set(result.output.split('\n')[:2]) == {'two', 'three'} assert not result.exception # "see #905" @pytest.mark.xfail @pytest.mark.parametrize('contents', [ '', 'BEGIN:VCALENDAR\nBEGIN:VTODO\nEND:VTODO\nEND:VCALENDAR\n' ]) def test_no_vevent(runner, tmpdir, contents): runner = runner(days=2) broken_item = runner.calendars['one'].join('broken_item.ics') broken_item.write(contents.encode('utf-8'), mode='wb') result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == '' def test_printformats(runner): runner = runner(days=2) result = runner.invoke(main_khal, ['printformats']) assert '\n'.join(['longdatetimeformat: 21.12.2013 21:45', 'datetimeformat: 21.12. 21:45', 'longdateformat: 21.12.2013', 'dateformat: 21.12.', 'timeformat: 21:45', '']) == result.output assert not result.exception # "see #810" @pytest.mark.xfail def test_repeating(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') end_date = dt.datetime.now() + dt.timedelta(days=10) result = runner.invoke( main_khal, (f"new {now} 18:00 myevent -r weekly -u " f"{end_date.strftime('%d.%m.%Y')}").split()) assert not result.exception assert result.output == '' def test_at(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') end_date = dt.datetime.now() + dt.timedelta(days=10) result = runner.invoke( main_khal, f"new {now} {end_date.strftime('%d.%m.%Y')} 18:00 myevent".split()) args = ['--color', 'at', '--format', '{start-time}{title}', '--day-format', '', '18:30'] result = runner.invoke(main_khal, args) assert not result.exception assert result.output.startswith('myevent') def test_at_json(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') end_date = dt.datetime.now() + dt.timedelta(days=10) result = runner.invoke( main_khal, 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split()) args = ['--color', 'at', '--json', 'start-time', '--json', 'title', '18:30'] result = runner.invoke(main_khal, args) assert not result.exception assert result.output.startswith('[{"start-time": "", "title": "myevent"}]') def test_at_json_default_fields(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') end_date = dt.datetime.now() + dt.timedelta(days=10) result = runner.invoke( main_khal, 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).split()) args = ['--color', 'at', '--json', 'all', '18:30'] result = runner.invoke(main_khal, args) assert not result.exception output_fields = json.loads(result.output)[0].keys() assert all(x in output_fields for x in CONTENT_ATTRIBUTES) def test_at_json_strip(runner): runner = runner() result = runner.invoke(main_khal, ['import', _get_ics_filepath( 'event_rrule_recuid_cancelled')], input='0\ny\n') assert not result.exception result = runner.invoke(main_khal, ['at', '--json', 'repeat-symbol', '--json', 'status', '--json', 'cancelled', '14.07.2014', '07:00']) traceback.print_tb(result.exc_info[2]) assert not result.exception assert result.output.startswith( '[{"repeat-symbol": "⟳", "status": "CANCELLED", "cancelled": "CANCELLED"}]') def test_at_day_format(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') end_date = dt.datetime.now() + dt.timedelta(days=10) result = runner.invoke( main_khal, f"new {now} {end_date.strftime('%d.%m.%Y')} 18:00 myevent".split()) args = ['--color', 'at', '--format', '{start-time}{title}', '--day-format', '{name}', '18:30'] result = runner.invoke(main_khal, args) assert not result.exception assert result.output.startswith('Today\x1b[0m\nmyevent') def test_list(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke( main_khal, f'new {now} 18:00 myevent'.split()) format = '{red}{start-end-time-style}{reset} {title} :: {description}' args = ['--color', 'list', '--format', format, '--day-format', 'header', '18:30'] result = runner.invoke(main_khal, args) expected = 'header\x1b[0m\n\x1b[31m18:00-19:00\x1b[0m myevent :: \x1b[0m\n' assert not result.exception assert result.output.startswith(expected) def test_list_json(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke( main_khal, f'new {now} 18:00 myevent'.split()) args = ['list', '--json', 'start-end-time-style', '--json', 'title', '--json', 'description', '18:30'] result = runner.invoke(main_khal, args) expected = '[{"start-end-time-style": "18:00-19:00", "title": "myevent", "description": ""}]' assert not result.exception assert result.output.startswith(expected) def test_search(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke(main_khal, f'new {now} 18:00 myevent'.split()) format = '{red}{start-end-time-style}{reset} {title} :: {description}' result = runner.invoke(main_khal, ['--color', 'search', '--format', format, 'myevent']) assert not result.exception assert result.output.startswith('\x1b[34m\x1b[31m18:00') def test_search_json(runner): runner = runner(days=2) now = dt.datetime.now().strftime('%d.%m.%Y') result = runner.invoke(main_khal, f'new {now} 18:00 myevent'.split()) result = runner.invoke(main_khal, ['search', '--json', 'start-end-time-style', '--json', 'title', '--json', 'description', 'myevent']) assert not result.exception assert result.output.startswith('[{"start-end-time-style": "18:00') def test_no_default_new(runner): runner = runner(default_calendar=False) result = runner.invoke(main_khal, 'new 18:00 beer'.split()) assert ("Error: Invalid value: No default calendar is configured, " "please provide one explicitly.") in result.output assert result.exit_code == 2 def test_print_bad_ics(runner): """Attempt to print a .ics that is malformed, but does not have a DST-related error.""" runner = runner() result = runner.invoke(main_khal, ['printics', _get_ics_filepath('non_dst_error')]) assert result.exception expected = ValueError("Invalid iCalendar duration: PT-2H") assert expected.__class__ == result.exception.__class__ assert expected.args == result.exception.args def test_import(runner, monkeypatch): runner = runner() result = runner.invoke(main_khal, 'import -a one -a two import file.ics'.split()) assert result.exception assert result.exit_code == 2 assert 'Can\'t use "--include-calendar" / "-a" more than once' in result.output class FakeImport: args, kwargs = None, None def clean(self): self.args, self.kwargs = None, None def import_ics(self, *args, **kwargs): print('saving args') print(args) self.args = args self.kwargs = kwargs fake = FakeImport() monkeypatch.setattr('khal.controllers.import_ics', fake.import_ics) # as we are not actually parsing the file we want to import, we can use # any readable file at all, therefore re-using the configuration file result = runner.invoke(main_khal, f'import -a one {runner.config_file}'.split()) assert not result.exception assert {cal['name'] for cal in fake.args[0].calendars} == {'one'} fake.clean() result = runner.invoke(main_khal, f'import {runner.config_file}'.split()) assert not result.exception assert {cal['name'] for cal in fake.args[0].calendars} == {'one', 'two', 'three'} def test_import_proper(runner): runner = runner() result = runner.invoke(main_khal, ['import', _get_ics_filepath('cal_d')], input='0\ny\n') assert result.output.startswith('09.04.-09.04. An Event') assert not result.exception result = runner.invoke(main_khal, ['search', 'Event']) assert result.output == '09.04.-09.04. An Event\n' def test_import_proper_invalid_timezone(runner): runner = runner() result = runner.invoke( main_khal, ['import', _get_ics_filepath('invalid_tzoffset')], input='0\ny\n') assert result.output.startswith( 'warning: Invalid timezone offset encountered, timezone information may be wrong') assert not result.exception result = runner.invoke(main_khal, ['search', 'Event']) assert result.output.startswith( 'warning: Invalid timezone offset encountered, timezone information may be wrong') assert '02.12. 08:00-02.12. 09:30 Some event' in result.output def test_import_invalid_choice_and_prefix(runner): runner = runner() result = runner.invoke(main_khal, ['import', _get_ics_filepath('cal_d')], input='9\nth\ny\n') assert result.output.startswith('09.04.-09.04. An Event') assert result.output.find('invalid choice') == 125 assert not result.exception result = runner.invoke(main_khal, ['search', 'Event']) assert result.output == '09.04.-09.04. An Event\n' def test_import_from_stdin(runner, monkeypatch): ics_data = 'This is some really fake icalendar data' class FakeImport: args, kwargs = None, None call_count = 0 def clean(self): self.args, self.kwargs = None, None def import_ics(self, *args, **kwargs): print('saving args') print(args) self.call_count += 1 self.args = args self.kwargs = kwargs importer = FakeImport() monkeypatch.setattr('khal.controllers.import_ics', importer.import_ics) runner = runner() result = runner.invoke(main_khal, ['import'], input=ics_data) assert not result.exception assert importer.call_count == 1 assert importer.kwargs['ics'] == ics_data def test_interactive_command(runner, monkeypatch): runner = runner(days=2) token = "hooray" def fake_ui(*a, **kw): print(token) sys.exit(0) monkeypatch.setattr('khal.ui.start_pane', fake_ui) result = runner.invoke(main_ikhal, ['-a', 'one']) assert not result.exception assert result.output.strip() == token result = runner.invoke(main_khal, ['interactive', '-a', 'one']) assert not result.exception assert result.output.strip() == token def test_color_option(runner): runner = runner(days=2) result = runner.invoke(main_khal, ['--no-color', 'list']) assert result.output == '' result = runner.invoke(main_khal, ['--color', 'list']) assert result.output == '' def choices(dateformat=0, timeformat=0, default_calendar='', calendar_option=0, accept_vdirsyncer_dir=True, vdir='', caldav_url='', caldav_user='', caldav_pw='', write_config=True): """helper function to generate input for testing `configure`""" confirm = {True: 'y', False: 'n'} out = [ str(dateformat), str(timeformat), str(calendar_option), ] if calendar_option == 1: out.append(confirm[accept_vdirsyncer_dir]) if not accept_vdirsyncer_dir: out.append(vdir) elif calendar_option == 2: out.append(caldav_url) out.append(caldav_user) out.append(caldav_pw) out.append(default_calendar) out.append(confirm[write_config]) out.append('') return '\n'.join(out) class Config: """helper class for mocking vdirsyncer's config objects""" # TODO crate a vdir config on disk and let vdirsyncer actually read it storages = { 'home_calendar_local': { 'type': 'filesystem', 'instance_name': 'home_calendar_local', 'path': '~/.local/share/calendars/home/', 'fileext': '.ics', }, 'events_local': { 'type': 'filesystem', 'instance_name': 'events_local', 'path': '~/.local/share/calendars/events/', 'fileext': '.ics', }, 'home_calendar_remote': { 'type': 'caldav', 'url': 'https://some.url/caldav', 'username': 'foo', 'password.fetch': ['command', 'get_secret'], 'instance_name': 'home_calendar_remote', }, 'home_contacts_remote': { 'type': 'carddav', 'url': 'https://another.url/caldav', 'username': 'bar', 'password.fetch': ['command', 'get_secret'], 'instance_name': 'home_contacts_remote', }, 'home_contacts_local': { 'type': 'filesystem', 'instance_name': 'home_contacts_local', 'path': '~/.local/share/contacts/', 'fileext': '.vcf', }, 'events_remote': { 'type': 'http', 'instance_name': 'events_remote', 'url': 'http://list.of/events/', }, } def test_configure_command(runner): runner_factory = runner runner = runner() runner.config_file.remove() result = runner.invoke(main_khal, ['configure'], input=choices()) assert f'Successfully wrote configuration to {runner.config_file}' in result.output assert result.exit_code == 0 with open(str(runner.config_file)) as f: actual_config = ''.join(f.readlines()) assert actual_config == f'''[calendars] [[private]] path = {runner.tmpdir}/vdirs/khal/calendars/private type = calendar [locale] timeformat = %H:%M dateformat = %Y-%m-%d longdateformat = %Y-%m-%d datetimeformat = %Y-%m-%d %H:%M longdatetimeformat = %Y-%m-%d %H:%M [default] default_calendar = private ''' # if aborting, no config file should be written runner = runner_factory() assert os.path.exists(str(runner.config_file)) runner.config_file.remove() assert not os.path.exists(str(runner.config_file)) result = runner.invoke(main_khal, ['configure'], input=choices(write_config=False)) assert 'aborted' in result.output assert result.exit_code == 1 def test_print_ics_command(runner): runner = runner() # Input is empty and loading from stdin result = runner.invoke(main_khal, ['printics', '-']) assert result.exception # Non existing file result = runner.invoke(main_khal, ['printics', 'nonexisting_file']) assert result.exception assert re.search(r'''Error: Invalid value for "?'?\[?(ICS|ics)\]?'?"?: ''' r'''('nonexisting_file': No such file or directory\n|''' r'Could not open file:)', result.output) # Run on test files result = runner.invoke(main_khal, ['printics', _get_ics_filepath('cal_d')]) assert not result.exception result = runner.invoke(main_khal, ['printics', _get_ics_filepath('cal_dt_two_tz')]) assert not result.exception # Test with some nice format strings form = '{uid}\t{title}\t{description}\t{start}\t{start-long}\t{start-date}' \ '\t{start-date-long}\t{start-time}\t{end}\t{end-long}\t{end-date}' \ '\t{end-date-long}\t{end-time}\t{repeat-symbol}\t{description}' \ '\t{description-separator}\t{location}\t{calendar}' \ '\t{calendar-color}\t{start-style}\t{to-style}\t{end-style}' \ '\t{start-end-time-style}\t{end-necessary}\t{end-necessary-long}' result = runner.invoke(main_khal, [ 'printics', '-f', form, _get_ics_filepath('cal_dt_two_tz')]) assert not result.exception assert 25 == len(result.output.split('\t')) result = runner.invoke(main_khal, [ 'printics', '-f', form, _get_ics_filepath('cal_dt_two_tz')]) assert not result.exception assert 25 == len(result.output.split('\t')) def test_printics_read_from_stdin(runner): runner = runner(command='printics') result = runner.invoke(main_khal, ['printics'], input=_get_text('cal_d')) assert not result.exception assert '1 events found in stdin input\n09.04.-09.04. An Event\n' in result.output def test_configure_command_config_exists(runner): runner = runner() result = runner.invoke(main_khal, ['configure'], input=choices()) assert 'Found an existing' in result.output assert result.exit_code == 1 def test_configure_command_create_vdir(runner): runner = runner() runner.config_file.remove() runner.xdg_config_home.remove() result = runner.invoke( main_khal, ['configure'], input=choices(), ) assert f'Successfully wrote configuration to {str(runner.config_file)}' in result.output assert result.exit_code == 0 with open(str(runner.config_file)) as f: actual_config = ''.join(f.readlines()) assert actual_config == f'''[calendars] [[private]] path = {str(runner.xdg_data_home)}/khal/calendars/private type = calendar [locale] timeformat = %H:%M dateformat = %Y-%m-%d longdateformat = %Y-%m-%d datetimeformat = %Y-%m-%d %H:%M longdatetimeformat = %Y-%m-%d %H:%M [default] default_calendar = private ''' # running configure again, should yield another vdir path, as the old # one still exists runner.config_file.remove() result = runner.invoke( main_khal, ['configure'], input=choices(), ) assert f'Successfully wrote configuration to {str(runner.config_file)}' in result.output assert result.exit_code == 0 with open(str(runner.config_file)) as f: actual_config = ''.join(f.readlines()) assert f'{runner.xdg_data_home}/khal/calendars/private1' in actual_config def cleanup(paths): """reset permissions of all files and folders in `paths` to 644 resp. 755""" for path in paths: if os.path.exists(path): os.chmod(str(path), 0o755) for dirpath, _dirnames, filenames in os.walk(path): os.chmod(str(dirpath), 0o755) for filename in filenames: os.chmod(str(os.path.join(dirpath, filename)), 0o644) def test_configure_command_cannot_write_config_file(runner): runner = runner() runner.config_file.remove() os.chmod(str(runner.xdg_config_home), 555) result = runner.invoke(main_khal, ['configure'], input=choices()) assert result.exit_code == 1 # make sure pytest can clean up behind us cleanup([runner.xdg_config_home]) def test_configure_command_cannot_create_vdir(runner): runner = runner() runner.config_file.remove() os.mkdir(str(runner.xdg_data_home), mode=555) result = runner.invoke( main_khal, ['configure'], input=choices(), ) assert 'Exiting' in result.output assert result.exit_code == 1 # make sure pytest can clean up behind us cleanup([runner.xdg_data_home]) def test_edit(runner): runner = runner() result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == '' for name in ['event_dt_simple', 'event_d_15']: cal_dt = _get_text(name) event = runner.calendars['one'].join(f'{name}.ics') event.write(cal_dt) format = '{start-end-time-style}: {title}' result = runner.invoke( main_khal, ['edit', '--show-past', 'Event'], input='s\nGreat Event\nn\nn\n') assert not result.exception args = ['list', '--format', format, '--day-format', '', '09.04.2014'] result = runner.invoke(main_khal, args) assert '09:30-10:30: Great Event' in result.output assert not result.exception args = ['list', '--format', format, '--day-format', '', '09.04.2015'] result = runner.invoke(main_khal, args) assert ': An Event' in result.output assert not result.exception def test_new(runner): runner = runner(print_new='path') result = runner.invoke(main_khal, 'new 13.03.2016 3d Visit'.split()) assert not result.exception assert result.output.endswith('.ics\n') assert result.output.startswith(str(runner.tmpdir)) def test_new_format(runner): runner = runner(print_new='event') format = '{start-end-time-style}: {title}' result = runner.invoke(main_khal, ['new', '13.03.2016 12:00', '3d', '--format', format, 'Visit']) assert not result.exception assert result.output.startswith('→12:00: Visit') def test_new_json(runner): runner = runner(print_new='event') result = runner.invoke(main_khal, ['new', '13.03.2016 12:00', '3d', '--json', 'start-end-time-style', '--json', 'title', 'Visit']) assert not result.exception assert result.output.startswith( '[{"start-end-time-style": "→12:00", "title": "Visit"}]') @ freeze_time('2015-6-1 8:00') def test_new_interactive(runner): runner = runner(print_new='path') result = runner.invoke( main_khal, 'new -i'.split(), 'Another event\n13:00 17:00\n\nNone\nn\n' ) assert not result.exception assert result.exit_code == 0 def test_debug(runner): runner = runner() result = runner.invoke(main_khal, ['-v', 'debug', 'printformats']) assert result.output.startswith('debug: khal 0.') assert 'using the config file at' in result.output assert 'debug: Using config:\ndebug: [calendars]' in result.output assert not result.exception @freeze_time('2015-6-1 8:00') def test_new_interactive_extensive(runner): runner = runner(print_new='path', default_calendar=False) result = runner.invoke( main_khal, 'new -i 15:00 15:30'.split(), '?\ninvalid\ntwo\n' 'Unicce Name\n' '\n' 'Europe/London\n' 'bar\n' 'l\non a boat\n' 'p\nweekly\n' '1.1.2018\n' 'a\n30m\n' 'c\nwork\n' 'n\n' ) assert not result.exception assert result.exit_code == 0 @freeze_time('2015-6-1 8:00') def test_issue_1056(runner): """if an ansi escape sequence is contained in the output, we can't parse it properly""" runner = runner(print_new='path', default_calendar=False) result = runner.invoke( main_khal, 'new -i'.split(), 'two\n' 'new event\n' 'now\n' 'Europe/London\n' 'None\n' 't\n' # edit datetime range '\n' 'n\n' ) assert 'error parsing range' not in result.output assert not result.exception assert result.exit_code == 0 def test_list_now(runner, tmpdir): # reproduce #693 runner = runner() xdg_config_home = tmpdir.join('.config') config_file = xdg_config_home.join('khal').join('config') config_file.write(""" [calendars] [[one]] path = {} color = dark blue [[two]] path = {} color = dark green [[three]] path = {} [locale] longdateformat = %a %Y-%m-%d dateformat = %Y-%m-%d """.format( tmpdir.join('calendar'), tmpdir.join('calendar2'), tmpdir.join('calendar3'), )) result = runner.invoke(main_khal, ['list', 'now']) assert not result.exception khal-0.11.4/tests/configs/000077500000000000000000000000001477603436700153275ustar00rootroot00000000000000khal-0.11.4/tests/configs/nocalendars.conf000066400000000000000000000003051477603436700204650ustar00rootroot00000000000000[locale] local_timezone= Europe/Berlin default_timezone= Europe/Berlin timeformat= %H:%M dateformat= %d.%m. longdateformat= %d.%m.%Y datetimeformat= %d.%m. %H:%M longdatetimeformat= %d.%m.%Y %H:%M khal-0.11.4/tests/configs/one_level_calendars.conf000066400000000000000000000000721477603436700221610ustar00rootroot00000000000000[calendars] path = /home/user/.nextcloud/ type = discover khal-0.11.4/tests/configs/simple.conf000066400000000000000000000004311477603436700174650ustar00rootroot00000000000000[calendars] [[home]] path = ~/.calendars/home/ [[work]] path = ~/.calendars/work/ [locale] local_timezone= Europe/Berlin default_timezone= Europe/Berlin timeformat= %H:%M dateformat= %d.%m. longdateformat= %d.%m.%Y datetimeformat= %d.%m. %H:%M longdatetimeformat= %d.%m.%Y %H:%M khal-0.11.4/tests/configs/small.conf000066400000000000000000000002761477603436700173130ustar00rootroot00000000000000[calendars] [[home]] path = ~/.calendars/home/ color = dark green priority = 20 [[work]] path = ~/.calendars/work/ readonly = True addresses = user@example.com khal-0.11.4/tests/configwizard_test.py000066400000000000000000000012111477603436700177710ustar00rootroot00000000000000import click import pytest from khal.configwizard import get_collection_names_from_vdirs, validate_int def test_validate_int(): assert validate_int('3', 0, 3) == 3 with pytest.raises(click.UsageError): validate_int('3', 0, 2) with pytest.raises(click.UsageError): validate_int('two', 0, 2) def test_default_vdir(metavdirs): names = get_collection_names_from_vdirs([('found', f'{metavdirs}/**/', 'discover')]) assert names == [ 'my private calendar', 'my calendar', 'public', 'home', 'public1', 'work', 'cfgcolor', 'cfgcolor_again', 'cfgcolor_once_more', 'dircolor', 'singlecollection', ] khal-0.11.4/tests/conftest.py000066400000000000000000000112461477603436700161020ustar00rootroot00000000000000import logging import os from time import sleep import pytest from khal.custom_types import CalendarConfiguration from khal.khalendar import CalendarCollection from khal.khalendar.vdir import Vdir from .utils import LOCALE_BERLIN, CollVdirType, cal1, example_cals @pytest.fixture def metavdirs(tmpdir): tmpdir = str(tmpdir) dirstructure = [ '/cal1/public/', '/cal1/private/', '/cal2/public/', '/cal3/public/', '/cal3/work/', '/cal3/home/', '/cal4/cfgcolor/', '/cal4/dircolor/', '/cal4/cfgcolor_again/', '/cal4/cfgcolor_once_more/', '/singlecollection/', ] for one in dirstructure: os.makedirs(tmpdir + one) filestructure = [ ('/cal1/public/displayname', 'my calendar'), ('/cal1/public/color', 'dark blue'), ('/cal1/private/displayname', 'my private calendar'), ('/cal1/private/color', '#FF00FF'), ('/cal4/dircolor/color', 'dark blue'), ] for filename, content in filestructure: with open(tmpdir + filename, 'w') as metafile: metafile.write(content) return tmpdir @pytest.fixture def coll_vdirs(tmpdir) -> CollVdirType: calendars, vdirs = {}, {} for name in example_cals: path = str(tmpdir) + '/' + name os.makedirs(path, mode=0o770) readonly = True if name == 'a_calendar' else False calendars[name] = CalendarConfiguration( name=name, path=path, readonly=readonly, color='dark blue', priority=10, ctype='calendar', addresses='user@example.com', ) vdirs[name] = Vdir(path, '.ics') coll = CalendarCollection(calendars=calendars, dbpath=':memory:', locale=LOCALE_BERLIN) coll.default_calendar_name = cal1 return coll, vdirs @pytest.fixture def coll_vdirs_birthday(tmpdir): calendars, vdirs = {}, {} for name in example_cals: path = str(tmpdir) + '/' + name os.makedirs(path, mode=0o770) readonly = True if name == 'a_calendar' else False calendars[name] = {'name': name, 'path': path, 'color': 'dark blue', 'readonly': readonly, 'unicode_symbols': True, 'ctype': 'birthdays', 'addresses': 'user@example.com'} vdirs[name] = Vdir(path, '.vcf') coll = CalendarCollection(calendars=calendars, dbpath=':memory:', locale=LOCALE_BERLIN) coll.default_calendar_name = cal1 return coll, vdirs @pytest.fixture(autouse=True) def never_echo_bytes(monkeypatch): '''Click's echo function will not strip colorcodes if we call `click.echo` with a bytestring message. The reason for this that bytestrings may contain arbitrary binary data (such as images). Khal is not concerned with such data at all, but may contain a few instances where it explicitly encodes its output into the configured locale. This in turn would break the functionality of the global `--color/--no-color` flag. ''' from click import echo as old_echo def echo(msg=None, *a, **kw): assert not isinstance(msg, bytes) return old_echo(msg, *a, **kw) monkeypatch.setattr('click.echo', echo) class Result: @staticmethod def undo(): monkeypatch.setattr('click.echo', old_echo) return Result @pytest.fixture(scope='session') def sleep_time(tmpdir_factory): """ Returns the filesystem's mtime precision Returns how long we need to sleep for the filesystem's mtime precision to pick up differences. This keeps test fast on systems with high precisions, but makes them pass on those that don't. """ tmpfile = tmpdir_factory.mktemp('sleep').join('touch_me') def touch_and_mtime(): tmpfile.open('w').close() stat = os.stat(str(tmpfile)) return getattr(stat, 'st_mtime_ns', stat.st_mtime) i = 0.00001 while i < 100: # Measure three times to avoid things like 12::18:11.9994 [mis]passing first = touch_and_mtime() sleep(i) second = touch_and_mtime() sleep(i) third = touch_and_mtime() if first != second != third: return i * 1.1 i = i * 10 # This should never happen, but oh, well: raise Exception( 'Filesystem does not seem to save modified times of files. \n' 'Cannot run tests that depend on this.' ) @pytest.fixture def fix_caplog(monkeypatch): """Temporarily undoes the logging setup by click-log such that the caplog fixture can be used""" logger = logging.getLogger('khal') monkeypatch.setattr(logger, 'handlers', []) monkeypatch.setattr(logger, 'propagate', True) khal-0.11.4/tests/controller_test.py000066400000000000000000000157671477603436700175130ustar00rootroot00000000000000import datetime as dt from textwrap import dedent import pytest from freezegun import freeze_time from khal import exceptions from khal.controllers import import_ics, khal_list, start_end_from_daterange from khal.khalendar.vdir import Item from . import utils from .utils import _get_text today = dt.date.today() yesterday = today - dt.timedelta(days=1) tomorrow = today + dt.timedelta(days=1) event_allday_template = """BEGIN:VEVENT SEQUENCE:0 UID:uid3@host1.com DTSTART;VALUE=DATE:{} DTEND;VALUE=DATE:{} SUMMARY:a meeting DESCRIPTION:short description LOCATION:LDB Lobby END:VEVENT""" event_today = event_allday_template.format(today.strftime('%Y%m%d'), tomorrow.strftime('%Y%m%d')) item_today = Item(event_today) event_format = '{calendar-color}{start-end-time-style:16} {title}' event_format += '{repeat-symbol}{description-separator}{description}{calendar-color}' conf = {'locale': utils.LOCALE_BERLIN, 'default': {'timedelta': dt.timedelta(days=2), 'show_all_days': False} } class TestGetAgenda: def test_new_event(self, coll_vdirs): coll, vdirs = coll_vdirs event = coll.create_event_from_ics(event_today, utils.cal1) coll.insert(event) assert [' a meeting :: short description\x1b[0m'] == \ khal_list(coll, [], conf, agenda_format=event_format, day_format="") def test_new_event_day_format(self, coll_vdirs): coll, vdirs = coll_vdirs event = coll.create_event_from_ics(event_today, utils.cal1) coll.insert(event) assert ['Today\x1b[0m', ' a meeting :: short description\x1b[0m'] == \ khal_list(coll, [], conf, agenda_format=event_format, day_format="{name}") def test_agenda_default_day_format(self, coll_vdirs): with freeze_time('2016-04-10 12:33'): today = dt.date.today() event_today = event_allday_template.format( today.strftime('%Y%m%d'), tomorrow.strftime('%Y%m%d')) coll, vdirs = coll_vdirs event = coll.create_event_from_ics(event_today, utils.cal1) coll.insert(event) out = khal_list( coll, conf=conf, agenda_format=event_format, datepoint=[]) assert [ '\x1b[1m10.04.2016 12:33\x1b[0m\x1b[0m', '↦ a meeting :: short description\x1b[0m'] == out def test_agenda_fail(self, coll_vdirs): with freeze_time('2016-04-10 12:33'): coll, vdirs = coll_vdirs with pytest.raises(exceptions.FatalError): khal_list(coll, conf=conf, agenda_format=event_format, datepoint=['xyz']) with pytest.raises(exceptions.FatalError): khal_list(coll, conf=conf, agenda_format=event_format, datepoint=['today']) def test_empty_recurrence(self, coll_vdirs): coll, vidrs = coll_vdirs coll.insert(coll.create_event_from_ics(dedent( 'BEGIN:VEVENT\r\n' 'UID:no_recurrences\r\n' 'SUMMARY:No recurrences\r\n' 'RRULE:FREQ=DAILY;COUNT=2;INTERVAL=1\r\n' 'EXDATE:20110908T130000\r\n' 'EXDATE:20110909T130000\r\n' 'DTSTART:20110908T130000\r\n' 'DTEND:20110908T170000\r\n' 'END:VEVENT\r\n' ), utils.cal1)) assert '\n'.join(khal_list(coll, [], conf, agenda_format=event_format, day_format="{name}")).lower() == '' class TestImport: def test_import(self, coll_vdirs): coll, vdirs = coll_vdirs view = {'event_format': '{title}'} conf = {'locale': utils.LOCALE_BERLIN, 'view': view} import_ics(coll, conf, _get_text('event_rrule_recuid'), batch=True) start_date = utils.BERLIN.localize(dt.datetime(2014, 4, 30)) end_date = utils.BERLIN.localize(dt.datetime(2014, 9, 26)) events = list(coll.get_localized(start_date, end_date)) assert len(events) == 6 events = sorted(events) assert events[1].start_local == utils.BERLIN.localize(dt.datetime(2014, 7, 7, 9, 0)) assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) in \ [ev.start for ev in events] import_ics(coll, conf, _get_text('event_rrule_recuid_update'), batch=True) events = list(coll.get_localized(start_date, end_date)) for ev in events: print(ev.start) assert ev.calendar == 'foobar' assert len(events) == 5 assert utils.BERLIN.localize(dt.datetime(2014, 7, 14, 7, 0)) not in \ [ev.start_local for ev in events] def test_mix_datetime_types(self, coll_vdirs): """ Test importing events with mixed tz-aware and tz-naive datetimes. """ coll, vdirs = coll_vdirs view = {'event_format': '{title}'} import_ics( coll, {'locale': utils.LOCALE_BERLIN, 'view': view}, _get_text('event_dt_mixed_awareness'), batch=True ) start_date = utils.BERLIN.localize(dt.datetime(2015, 5, 29)) end_date = utils.BERLIN.localize(dt.datetime(2015, 6, 3)) events = list(coll.get_localized(start_date, end_date)) assert len(events) == 2 events = sorted(events) assert events[0].start_local == \ utils.BERLIN.localize(dt.datetime(2015, 5, 30, 12, 0)) assert events[0].end_local == \ utils.BERLIN.localize(dt.datetime(2015, 5, 30, 16, 0)) assert events[1].start_local == \ utils.BERLIN.localize(dt.datetime(2015, 6, 2, 12, 0)) assert events[1].end_local == \ utils.BERLIN.localize(dt.datetime(2015, 6, 2, 16, 0)) def test_start_end(): with freeze_time('2016-04-10'): start = dt.datetime(2016, 4, 10, 0, 0) end = dt.datetime(2016, 4, 11, 0, 0) assert (start, end) == start_end_from_daterange(('today',), locale=utils.LOCALE_BERLIN) def test_start_end_default_delta(): with freeze_time('2016-04-10'): start = dt.datetime(2016, 4, 10, 0, 0) end = dt.datetime(2016, 4, 11, 0, 0) assert (start, end) == start_end_from_daterange(('today',), utils.LOCALE_BERLIN) def test_start_end_delta(): with freeze_time('2016-04-10'): start = dt.datetime(2016, 4, 10, 0, 0) end = dt.datetime(2016, 4, 12, 0, 0) assert (start, end) == start_end_from_daterange(('today', '2d'), utils.LOCALE_BERLIN) def test_start_end_empty(): with freeze_time('2016-04-10'): start = dt.datetime(2016, 4, 10, 0, 0) end = dt.datetime(2016, 4, 11, 0, 0) assert (start, end) == start_end_from_daterange([], utils.LOCALE_BERLIN) def test_start_end_empty_default(): with freeze_time('2016-04-10'): start = dt.datetime(2016, 4, 10, 0, 0) end = dt.datetime(2016, 4, 13, 0, 0) assert (start, end) == start_end_from_daterange( [], utils.LOCALE_BERLIN, default_timedelta_date=dt.timedelta(days=3), default_timedelta_datetime=dt.timedelta(hours=1), ) khal-0.11.4/tests/event_test.py000066400000000000000000000742251477603436700164430ustar00rootroot00000000000000import datetime as dt import pytest import pytz from freezegun import freeze_time from hypothesis import event, given from hypothesis.strategies import datetimes from icalendar import Parameters, vCalAddress, vRecur, vText from packaging import version from khal.controllers import human_formatter from khal.khalendar.event import AllDayEvent, Event, FloatingEvent, LocalizedEvent, create_timezone from .utils import ( BERLIN, BOGOTA, GMTPLUS3, LOCALE_BERLIN, LOCALE_BOGOTA, LOCALE_MIXED, NEW_YORK, _get_text, normalize_component, ) EVENT_KWARGS = {'calendar': 'foobar', 'locale': LOCALE_BERLIN} LIST_FORMAT = '{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}' LIST_FORMATTER = human_formatter(LIST_FORMAT) SEARCH_FORMAT = '{calendar-color}{cancelled}{start-long}{to-style}' + \ '{end-necessary-long} {title}{repeat-symbol}' CALENDAR_FORMAT = ('{calendar-color}{cancelled}{start-end-time-style} ({calendar}) ' '{title} [{location}]{repeat-symbol}') CALENDAR_FORMATTER = human_formatter(CALENDAR_FORMAT) SEARCH_FORMATTER = human_formatter(SEARCH_FORMAT) def test_no_initialization(): with pytest.raises(ValueError): Event('', '') def test_invalid_keyword_argument(): with pytest.raises(TypeError): Event.fromString(_get_text('event_dt_simple'), keyword='foo') def test_raw_dt(): event_dt = _get_text('event_dt_simple') start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS) with freeze_time('2016-1-1'): assert normalize_component(event.raw) == \ normalize_component(_get_text('event_dt_simple_inkl_vtimezone')) event = Event.fromString(event_dt, **EVENT_KWARGS) assert LIST_FORMATTER(event.attributes( dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.recurring is False assert event.duration == dt.timedelta(hours=1) assert event.uid == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU' assert event.organizer == '' def test_calendar_in_format(): """test if the calendar is included in event.format() if specified in the FORMAT see #1121 """ event_dt = _get_text('event_dt_simple') start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS) assert CALENDAR_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09:30-10:30 (foobar) An Event []\x1b[0m' def test_update_simple(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event_updated = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS) event.update_summary('A not so simple Event') event.update_description('Everything has changed') event.update_location('anywhere') event.update_categories(['meeting']) assert normalize_component(event.raw) == normalize_component(event_updated.raw) def test_add_url(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event.update_url('https://github.com/pimutils/khal') assert 'URL:https://github.com/pimutils/khal' in event.raw def test_get_url(): event = Event.fromString(_get_text('event_dt_url'), **EVENT_KWARGS) assert event.url == "https://github.com/pimutils/khal" def test_no_end(): """reading an event with neither DTEND nor DURATION""" event = Event.fromString(_get_text('event_dt_no_end'), **EVENT_KWARGS) # TODO make sure the event also gets converted to an all day event, as we # usually do assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == \ '16.01.2016 08:00-17.01.2016 08:00 Test\x1b[0m' def test_do_not_save_empty_location(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event.update_location('') assert 'LOCATION' not in event.raw def test_do_not_save_empty_description(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event.update_description('') assert 'DESCRIPTION' not in event.raw def test_do_not_save_empty_url(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event.update_url('') assert 'URL' not in event.raw def test_remove_existing_location_if_set_to_empty(): event = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS) event.update_location('') assert 'LOCATION' not in event.raw def test_remove_existing_description_if_set_to_empty(): event = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS) event.update_description('') assert 'DESCRIPTION' not in event.raw def test_remove_existing_url_if_set_to_empty(): event = Event.fromString(_get_text('event_dt_url'), **EVENT_KWARGS) event.update_url('') assert 'URL' not in event.raw def test_update_remove_categories(): event = Event.fromString(_get_text('event_dt_simple_updated'), **EVENT_KWARGS) event_nocat = Event.fromString(_get_text('event_dt_simple_nocat'), **EVENT_KWARGS) event.update_categories([]) assert normalize_component(event.raw) == normalize_component(event_nocat.raw) def test_raw_d(): event_d = _get_text('event_d') event = Event.fromString(event_d, **EVENT_KWARGS) assert event.raw.split('\r\n') == _get_text('cal_d').split('\n') assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == ' An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09.04.2014 An Event\x1b[0m' def test_update_sequence(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) event.increment_sequence() assert event._vevents['PROTO']['SEQUENCE'] == 0 event.increment_sequence() assert event._vevents['PROTO']['SEQUENCE'] == 1 def test_event_organizer(): event = _get_text('event_dt_duration') event = Event.fromString(event, **EVENT_KWARGS) assert event.organizer == 'Frank Nord (frank@nord.tld)' def test_transform_event(): """test if transformation between different event types works""" event_d = _get_text('event_d') event = Event.fromString(event_d, **EVENT_KWARGS) assert isinstance(event, AllDayEvent) start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event.update_start_end(start, end) assert isinstance(event, LocalizedEvent) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' analog_event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert normalize_component(event.raw) == normalize_component(analog_event.raw) with pytest.raises(ValueError): event.update_start_end(start, dt.date(2014, 4, 9)) def test_update_event_d(): event_d = _get_text('event_d') event = Event.fromString(event_d, **EVENT_KWARGS) event.update_start_end(dt.date(2014, 4, 20), dt.date(2014, 4, 22)) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 20))) == '↦ An Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 21))) == '↔ An Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 22))) == '⇥ An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 20))) == \ '20.04.2014-22.04.2014 An Event\x1b[0m' assert 'DTSTART;VALUE=DATE:20140420' in event.raw.split('\r\n') assert 'DTEND;VALUE=DATE:20140423' in event.raw.split('\r\n') def test_update_event_duration(): event_dur = _get_text('event_dt_duration') event = Event.fromString(event_dur, **EVENT_KWARGS) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert event.duration == dt.timedelta(hours=1) event.update_start_end(BERLIN.localize(dt.datetime(2014, 4, 9, 8, 0)), BERLIN.localize(dt.datetime(2014, 4, 9, 12, 0))) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 8, 0)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 12, 0)) assert event.duration == dt.timedelta(hours=4) def test_dt_two_tz(): event_dt_two_tz = _get_text('event_dt_two_tz') cal_dt_two_tz = _get_text('cal_dt_two_tz') event = Event.fromString(event_dt_two_tz, **EVENT_KWARGS) with freeze_time('2016-02-16 12:00:00'): assert normalize_component(cal_dt_two_tz) == normalize_component(event.raw) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == NEW_YORK.localize(dt.datetime(2014, 4, 9, 10, 30)) # local (Berlin) time! assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 16, 30)) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-16:30 An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-16:30 An Event\x1b[0m' def test_event_dt_duration(): """event has no end, but duration""" event_dt_duration = _get_text('event_dt_duration') event = Event.fromString(event_dt_duration, **EVENT_KWARGS) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert human_formatter('{duration}')(event.attributes( relative_to=dt.date.today())) == '1h\x1b[0m' def test_event_dt_floating(): """start and end time have no timezone, i.e. a floating event""" event_str = _get_text('event_dt_floating') event = Event.fromString(event_str, **EVENT_KWARGS) assert isinstance(event, FloatingEvent) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event\x1b[0m' assert human_formatter('{duration}')(event.attributes( relative_to=dt.date.today())) == '1h\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.start == dt.datetime(2014, 4, 9, 9, 30) assert event.end == dt.datetime(2014, 4, 9, 10, 30) assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED) assert event.start == dt.datetime(2014, 4, 9, 9, 30) assert event.end == dt.datetime(2014, 4, 9, 10, 30) assert event.start_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 10, 30)) def test_event_dt_tz_missing(): """localized event DTSTART;TZID=foo, but VTIMEZONE components missing""" event_str = _get_text('event_dt_local_missing_tz') event = Event.fromString(event_str, **EVENT_KWARGS) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert human_formatter('{duration}')(event.attributes( relative_to=dt.date.today())) == '1h\x1b[0m' event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED) assert event.start == BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) assert event.start_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 2, 30)) assert event.end_local == BOGOTA.localize(dt.datetime(2014, 4, 9, 3, 30)) def test_event_dt_rr(): event_dt_rr = _get_text('event_dt_rr') event = Event.fromString(event_dt_rr, **EVENT_KWARGS) assert event.recurring is True assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30-10:30 An Event ⟳\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 09:30-10:30 An Event ⟳\x1b[0m' assert human_formatter('{repeat-pattern}')(event.attributes(dt.date(2014, 4, 9)) ) == 'FREQ=DAILY;COUNT=10\x1b[0m' def test_event_d_rr(): event_d_rr = _get_text('event_d_rr') event = Event.fromString(event_d_rr, **EVENT_KWARGS) assert event.recurring is True assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == ' Another Event ⟳\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == \ '09.04.2014 Another Event ⟳\x1b[0m' assert human_formatter('{repeat-pattern}')(event.attributes(dt.date(2014, 4, 9)) ) == 'FREQ=DAILY;COUNT=10\x1b[0m' start = dt.date(2014, 4, 10) end = dt.date(2014, 4, 11) event = Event.fromString(event_d_rr, start=start, end=end, **EVENT_KWARGS) assert event.recurring is True assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == ' Another Event ⟳\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '10.04.2014 Another Event ⟳\x1b[0m' def test_event_rd(): event_dt_rd = _get_text('event_dt_rd') event = Event.fromString(event_dt_rd, **EVENT_KWARGS) assert event.recurring is True def test_status_confirmed(): event = Event.fromString(_get_text('event_dt_status_confirmed'), **EVENT_KWARGS) assert event.status == 'CONFIRMED' FORMAT_CALENDAR = ('{calendar-color}{status-symbol}{start-end-time-style} ({calendar}) ' '{title} [{location}]{repeat-symbol}') assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \ '✔09:30-10:30 (foobar) An Event []\x1b[0m' def test_event_d_long(): event_d_long = _get_text('event_d_long') event = Event.fromString(event_d_long, **EVENT_KWARGS) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '↦ Another Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '↔ Another Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 11))) == '⇥ Another Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == ' Another Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 16))) == \ '09.04.2014-11.04.2014 Another Event\x1b[0m' assert human_formatter('{duration}')(event.attributes( relative_to=dt.date(2014, 4, 11))) == '3d\x1b[0m' def test_event_d_two_days(): event_d_long = _get_text('event_d_long') event = Event.fromString(event_d_long, **EVENT_KWARGS) event.update_start_end(dt.date(2014, 4, 9), dt.date(2014, 4, 10)) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '↦ Another Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '⇥ Another Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == ' Another Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014-10.04.2014 Another Event\x1b[0m' def test_event_dt_long(): event_dt_long = _get_text('event_dt_long') event = Event.fromString(event_dt_long, **EVENT_KWARGS) assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 9))) == '09:30→ An Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == '↔ An Event\x1b[0m' assert LIST_FORMATTER(event.attributes(dt.date(2014, 4, 12))) == '→10:30 An Event\x1b[0m' assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014 09:30-12.04.2014 10:30 An Event\x1b[0m' def test_event_no_dst(): """test the creation of a corect VTIMEZONE for timezones with no dst""" event_no_dst = _get_text('event_no_dst') cal_no_dst = _get_text('cal_no_dst') event = Event.fromString(event_no_dst, calendar='foobar', locale=LOCALE_BOGOTA) if version.parse(pytz.__version__) > version.Version('2017.1'): if version.parse(pytz.__version__) < version.Version('2022.7'): cal_no_dst = cal_no_dst.replace( 'TZNAME:COT', 'RDATE:20380118T221407\r\nTZNAME:-05' ) else: cal_no_dst = cal_no_dst.replace( 'TZNAME:COT', 'TZNAME:-05' ) assert normalize_component(event.raw) == normalize_component(cal_no_dst) assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 4, 10))) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' def test_event_raw_UTC(): """test .raw() on events which are localized in UTC""" event_utc = _get_text('event_dt_simple_zulu') event = Event.fromString(event_utc, **EVENT_KWARGS) assert event.raw == '\r\n'.join([ '''BEGIN:VCALENDAR''', '''VERSION:2.0''', '''PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN''', '''BEGIN:VEVENT''', '''SUMMARY:An Event''', '''DTSTART:20140409T093000Z''', '''DTEND:20140409T103000Z''', '''DTSTAMP:20140401T234817Z''', '''UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU''', '''END:VEVENT''', '''END:VCALENDAR\r\n''']) def test_zulu_events(): """test if events in Zulu time are correctly recognized as localized events""" event = Event.fromString(_get_text('event_dt_simple_zulu'), **EVENT_KWARGS) assert type(event) == LocalizedEvent assert event.start_local == BERLIN.localize(dt.datetime(2014, 4, 9, 11, 30)) def test_dtend_equals_dtstart(): event = Event.fromString(_get_text('event_d_same_start_end'), calendar='foobar', locale=LOCALE_BERLIN) assert event.end == event.start def test_multi_uid(): """test for support for events with consist of several sub events with the same uid""" orig_event_str = _get_text('event_rrule_recuid') event = Event.fromString(orig_event_str, **EVENT_KWARGS) for line in orig_event_str.split('\n'): assert line in event.raw.split('\r\n') def test_cancelled_instance(): orig_event_str = _get_text('event_rrule_recuid_cancelled') event = Event.fromString(orig_event_str, ref='1405314000', **EVENT_KWARGS) assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 7, 14))) == \ 'CANCELLED 14.07.2014 07:00-12:00 Arbeit ⟳\x1b[0m' event = Event.fromString(orig_event_str, ref='PROTO', **EVENT_KWARGS) assert SEARCH_FORMATTER(event.attributes(dt.date(2014, 7, 14))) == \ '30.06.2014 07:00-12:00 Arbeit ⟳\x1b[0m' def test_recur(): event = Event.fromString(_get_text('event_dt_rr'), **EVENT_KWARGS) assert event.recurring is True assert event.recurpattern == 'FREQ=DAILY;COUNT=10' assert event.recurobject == vRecur({'COUNT': [10], 'FREQ': ['DAILY']}) def test_type_inference(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert type(event) == LocalizedEvent event = Event.fromString(_get_text('event_dt_simple_zulu'), **EVENT_KWARGS) assert type(event) == LocalizedEvent def test_duplicate_event(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) dupe = event.duplicate() assert dupe._vevents['PROTO']['UID'].to_ical() != 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU' def test_remove_instance_from_rrule(): """removing an instance from a recurring event""" event = Event.fromString(_get_text('event_dt_rr'), **EVENT_KWARGS) event.delete_instance(dt.datetime(2014, 4, 10, 9, 30)) assert 'EXDATE:20140410T093000' in event.raw.split('\r\n') event.delete_instance(dt.datetime(2014, 4, 12, 9, 30)) assert 'EXDATE:20140410T093000,20140412T093000' in event.raw.split('\r\n') def test_remove_instance_from_rdate(): """removing an instance from a recurring event""" event = Event.fromString(_get_text('event_dt_rd'), **EVENT_KWARGS) assert 'RDATE' in event.raw event.delete_instance(dt.datetime(2014, 4, 10, 9, 30)) assert 'RDATE' not in event.raw def test_remove_instance_from_two_rdate(): """removing an instance from a recurring event which has two RDATE props""" event = Event.fromString(_get_text('event_dt_two_rd'), **EVENT_KWARGS) assert event.raw.count('RDATE') == 2 event.delete_instance(dt.datetime(2014, 4, 10, 9, 30)) assert event.raw.count('RDATE') == 1 assert 'RDATE:20140411T093000,20140412T093000' in event.raw.split('\r\n') def test_remove_instance_from_recuid(): """remove an istance from an event which is specified via an additional VEVENT with the same UID (which we call `recuid` here""" event = Event.fromString(_get_text('event_rrule_recuid'), **EVENT_KWARGS) assert event.raw.split('\r\n').count('UID:event_rrule_recurrence_id') == 2 event.delete_instance(BERLIN.localize(dt.datetime(2014, 7, 7, 7, 0))) assert event.raw.split('\r\n').count('UID:event_rrule_recurrence_id') == 1 assert 'EXDATE;TZID=Europe/Berlin:20140707T070000' in event.raw.split('\r\n') def test_format_24(): """test if events ending at 00:00/24:00 are displayed as ending the day before""" event_dt = _get_text('event_dt_simple') start = BERLIN.localize(dt.datetime(2014, 4, 9, 19, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 10)) event = Event.fromString(event_dt, **EVENT_KWARGS) event.update_start_end(start, end) format_ = '{start-end-time-style} {title}{repeat-symbol}' assert human_formatter(format_)(event.attributes(dt.date(2014, 4, 9)) ) == '19:30-24:00 An Event\x1b[0m' def test_invalid_format_string(): event_dt = _get_text('event_dt_simple') event = Event.fromString(event_dt, **EVENT_KWARGS) format_ = '{start-end-time-style} {title}{foo}' with pytest.raises(KeyError): human_formatter(format_)(event.attributes(dt.date(2014, 4, 9))) def test_format_colors(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) format_ = '{red}{title}{reset}' assert human_formatter(format_)(event.attributes(dt.date(2014, 4, 9)) ) == '\x1b[31mAn Event\x1b[0m\x1b[0m' assert human_formatter(format_, colors=False)( event.attributes(dt.date(2014, 4, 9), colors=False)) == 'An Event' def test_event_alarm(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert event.alarms == [] event.update_alarms([(dt.timedelta(-1, 82800), 'new event')]) assert event.alarms == [(dt.timedelta(-1, 82800), vText('new event'))] def test_event_attendees(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert event.attendees == "" event.update_attendees(["this-does@not-exist.de", ]) assert event.attendees == "this-does@not-exist.de" assert isinstance(event._vevents[event.ref].get('ATTENDEE', []), list) assert isinstance(event._vevents[event.ref].get('ATTENDEE', [])[0], vCalAddress) assert str(event._vevents[event.ref].get('ATTENDEE', [])[0]) == "MAILTO:this-does@not-exist.de" event.update_attendees(["this-does@not-exist.de", "also-does@not-exist.de"]) assert event.attendees == "this-does@not-exist.de, also-does@not-exist.de" assert isinstance(event._vevents[event.ref].get('ATTENDEE', []), list) assert len(event._vevents[event.ref].get('ATTENDEE', [])) == 2 assert isinstance(event._vevents[event.ref].get('ATTENDEE', [])[0], vCalAddress) # test if parameters from existing vCalAddress objects will be preserved new_address = vCalAddress("MAILTO:mail.address@not-exist.de") new_address.params = Parameters( {'CN': 'Real Name', 'PARTSTAT': 'NEEDS-ACTION', 'ROLE': 'REQ-PARTICIPANT', 'RSVP': 'TRUE'} ) event._vevents[event.ref]['ATTENDEE'] = [new_address, ] event.update_attendees(["another.mailaddress@not-exist.de", "mail.address@not-exist.de"]) assert event.attendees == "mail.address@not-exist.de, another.mailaddress@not-exist.de" address = [a for a in event._vevents[event.ref].get('ATTENDEE', []) if str(a) == "MAILTO:mail.address@not-exist.de"] assert len(address) == 1 address = address[0] assert address.params.get('CN', None) is not None assert address.params['CN'] == "Real Name" def test_create_timezone_static(): gmt = pytz.timezone('Etc/GMT-8') assert create_timezone(gmt).to_ical().split() == [ b'BEGIN:VTIMEZONE', b'TZID:Etc/GMT-8', b'BEGIN:STANDARD', b'DTSTART:16010101T000000', b'RDATE:16010101T000000', b'TZNAME:Etc/GMT-8', b'TZOFFSETFROM:+0800', b'TZOFFSETTO:+0800', b'END:STANDARD', b'END:VTIMEZONE', ] event_dt = _get_text('event_dt_simple') start = GMTPLUS3.localize(dt.datetime(2014, 4, 9, 9, 30)) end = GMTPLUS3.localize(dt.datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_dt, **EVENT_KWARGS) event.update_start_end(start, end) with freeze_time('2016-1-1'): assert normalize_component(event.raw) == normalize_component( """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Etc/GMT+3 BEGIN:STANDARD DTSTART:16010101T000000 RDATE:16010101T000000 TZNAME:Etc/GMT+3 TZOFFSETFROM:-0300 TZOFFSETTO:-0300 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Etc/GMT+3:20140409T093000 DTEND;TZID=Etc/GMT+3:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR""" ) def test_sort_date_vs_datetime(): event1 = Event.fromString(_get_text('event_d'), **EVENT_KWARGS) event2 = Event.fromString(_get_text('event_dt_floating'), **EVENT_KWARGS) assert event1 < event2 def test_sort_event_start(): event_dt = _get_text('event_dt_simple') start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 45)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 30)) event1 = Event.fromString(event_dt, **EVENT_KWARGS) event2 = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS) assert event1 < event2 def test_sort_event_end(): event_dt = _get_text('event_dt_simple') start = BERLIN.localize(dt.datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(dt.datetime(2014, 4, 9, 10, 45)) event1 = Event.fromString(event_dt, **EVENT_KWARGS) event2 = Event.fromString(event_dt, start=start, end=end, **EVENT_KWARGS) assert event1 < event2 def test_sort_event_summary(): event_dt = _get_text('event_dt_simple') event1 = Event.fromString(event_dt, **EVENT_KWARGS) event2 = Event.fromString(event_dt, **EVENT_KWARGS) event2.update_summary("ZZZ") assert event1 < event2 def test_create_timezone_in_future(): """Events too far into the future (after the next DST transition) used to be created with invalid timezones""" with freeze_time('2019-03-31'): assert create_timezone( pytz.timezone('Europe/Amsterdam'), dt.datetime(2022, 1, 1, 18, 0)).to_ical().split() == [ b'BEGIN:VTIMEZONE', b'TZID:Europe/Amsterdam', b'BEGIN:STANDARD', b'DTSTART:20211031T020000', b'TZNAME:CET', b'TZOFFSETFROM:+0200', b'TZOFFSETTO:+0100', b'END:STANDARD', b'BEGIN:DAYLIGHT', b'DTSTART:20220327T030000', b'TZNAME:CEST', b'TZOFFSETFROM:+0100', b'TZOFFSETTO:+0200', b'END:DAYLIGHT', b'END:VTIMEZONE'] now = dt.datetime.now() min_value = now - dt.timedelta(days=3560) max_value = now + dt.timedelta(days=3560) AMSTERDAM = pytz.timezone('Europe/Amsterdam') @given(datetimes(min_value=min_value, max_value=max_value), datetimes(min_value=min_value, max_value=max_value)) def test_timezone_creation_with_arbitrary_dates(freeze_ts, event_time): """test if for arbitrary dates from the current date we produce a valid VTIMEZONE""" event(f'freeze_ts == event_time: {freeze_ts == event_time}') with freeze_time(freeze_ts): vtimezone = create_timezone(AMSTERDAM, event_time).to_ical().decode('utf-8') assert len(vtimezone) > 14 assert 'BEGIN:STANDARD' in vtimezone assert 'BEGIN:DAYLIGHT' in vtimezone def test_parameters_description(): """test if we support DESCRIPTION properties with parameters""" event = Event.fromString(_get_text('event_dt_description'), **EVENT_KWARGS) assert event.description == ( 'Hey, \n\nJust setting aside some dedicated time to talk about redacted.' ) def test_partstat(): FORMAT_CALENDAR = ( '{calendar-color}{partstat-symbol}{status-symbol}{start-end-time-style} ({calendar}) ' '{title} [{location}]{repeat-symbol}' ) event = Event.fromString( _get_text('event_dt_partstat'), addresses=['jdoe@example.com'], **EVENT_KWARGS) assert event.partstat == 'ACCEPTED' assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \ '✔09:30-10:30 (foobar) An Event []\x1b[0m' event = Event.fromString( _get_text('event_dt_partstat'), addresses=['another@example.com'], **EVENT_KWARGS) assert event.partstat == 'DECLINED' assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \ '❌09:30-10:30 (foobar) An Event []\x1b[0m' event = Event.fromString( _get_text('event_dt_partstat'), addresses=['jqpublic@example.com'], **EVENT_KWARGS) assert event.partstat == 'ACCEPTED' assert human_formatter(FORMAT_CALENDAR)(event.attributes(dt.date(2014, 4, 9))) == \ '✔09:30-10:30 (foobar) An Event []\x1b[0m' @pytest.mark.xfail def test_partstat_deligated(): event = Event.fromString( _get_text('event_dt_partstat'), addresses=['hcabot@example.com'], **EVENT_KWARGS) assert event.partstat == 'ACCEPTED' event = Event.fromString( _get_text('event_dt_partstat'), addresses=['iamboss@example.com'], **EVENT_KWARGS) assert event.partstat == 'ACCEPTED' khal-0.11.4/tests/icalendar_test.py000066400000000000000000000067101477603436700172360ustar00rootroot00000000000000import datetime as dt import random import textwrap import icalendar from freezegun import freeze_time from khal.icalendar import new_vevent, split_ics from .utils import LOCALE_BERLIN, _get_text, _replace_uid, normalize_component def _get_TZIDs(lines): """from a list of strings, get all unique strings that start with TZID""" return sorted(line for line in lines if line.startswith('TZID')) def test_normalize_component(): assert normalize_component(textwrap.dedent(""" BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20140409T093000 END:VEVENT """)) != normalize_component(textwrap.dedent(""" BEGIN:VEVENT DTSTART;TZID=Oyrope/Berlin:20140409T093000 END:VEVENT """)) def test_new_vevent(): with freeze_time('20220702T1400'): vevent = _replace_uid(new_vevent( LOCALE_BERLIN, dt.date(2022, 7, 2), dt.date(2022, 7, 3), 'An Event', allday=True, repeat='weekly', )) assert vevent.to_ical().decode('utf-8') == '\r\n'.join([ 'BEGIN:VEVENT', 'SUMMARY:An Event', 'DTSTART;VALUE=DATE:20220702', 'DTEND;VALUE=DATE:20220703', 'DTSTAMP:20220702T140000Z', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA', 'RRULE:FREQ=WEEKLY', 'END:VEVENT', '' ]) def test_split_ics(): cal = _get_text('cal_lots_of_timezones') vevents = split_ics(cal) vevents0 = vevents[0].split('\r\n') vevents1 = vevents[1].split('\r\n') part0 = _get_text('part0').split('\n') part1 = _get_text('part1').split('\n') assert _get_TZIDs(vevents0) == _get_TZIDs(part0) assert _get_TZIDs(vevents1) == _get_TZIDs(part1) assert sorted(vevents0) == sorted(part0) assert sorted(vevents1) == sorted(part1) def test_split_ics_random_uid(): random.seed(123) cal = _get_text('cal_lots_of_timezones') vevents = split_ics(cal, random_uid=True) part0 = _get_text('part0').split('\n') part1 = _get_text('part1').split('\n') for item in icalendar.Calendar.from_ical(vevents[0]).walk(): if item.name == 'VEVENT': assert item['UID'] == 'DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1' for item in icalendar.Calendar.from_ical(vevents[1]).walk(): if item.name == 'VEVENT': assert item['UID'] == '4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB' # after replacing the UIDs, everything should be as above vevents0 = vevents[0].replace('DRF0RGCY89VVDKIV9VPKA1FYEAU2GCFJIBS1', '123').split('\r\n') vevents1 = vevents[1].replace('4Q4CTV74N7UAZ618570X6CLF5QKVV9ZE3YVB', 'abcde').split('\r\n') assert _get_TZIDs(vevents0) == _get_TZIDs(part0) assert _get_TZIDs(vevents1) == _get_TZIDs(part1) assert sorted(vevents0) == sorted(part0) assert sorted(vevents1) == sorted(part1) def test_split_ics_missing_timezone(): """testing if we detect the missing timezone in splitting""" cal = _get_text('event_dt_local_missing_tz') split_ics(cal, random_uid=True, default_timezone=LOCALE_BERLIN['default_timezone']) def test_windows_timezone(caplog): """Test if a windows tz format works""" cal = _get_text("tz_windows_format") split_ics(cal) assert "Cannot find timezone `Pacific/Auckland`" not in caplog.text def test_split_ics_without_uid(): cal = _get_text('without_uid') vevents = split_ics(cal) assert vevents vevents2 = split_ics(cal) assert vevents[0] == vevents2[0] khal-0.11.4/tests/ics/000077500000000000000000000000001477603436700144555ustar00rootroot00000000000000khal-0.11.4/tests/ics/cal_d.ics000066400000000000000000000004011477603436700162120ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140410 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/cal_dt_two_tz.ics000066400000000000000000000014241477603436700200120ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:DAYLIGHT DTSTART:20140330T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT BEGIN:STANDARD DTSTART:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:America/New_York BEGIN:DAYLIGHT DTSTART:20140309T030000 TZNAME:EDT TZOFFSETFROM:-0500 TZOFFSETTO:-0400 END:DAYLIGHT BEGIN:STANDARD DTSTART:20141102T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=America/New_York:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/cal_lots_of_timezones.ics000066400000000000000000000050471477603436700215440ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:IndianReunion BEGIN:STANDARD TZOFFSETFROM:+034152 TZOFFSETTO:+0400 TZNAME:RET DTSTART:19110601T000000 RDATE:19110601T000000 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Will_not_appear BEGIN:STANDARD TZOFFSETFROM:+034152 TZOFFSETTO:+0400 TZNAME:RET DTSTART:19110601T000000 RDATE:19110601T000000 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe_Amsterdam BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19810329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19961027T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe_Berlin BEGIN:STANDARD DTSTART:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RDATE:20151025T020000 END:STANDARD BEGIN:DAYLIGHT DTSTART:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_New_York BEGIN:STANDARD DTSTART:20141102T010000 RDATE:20151101T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD BEGIN:DAYLIGHT DTSTART:20140309T030000 RDATE:20150308T030000,20160313T030000 TZNAME:EDT TZOFFSETFROM:-0500 TZOFFSETTO:-0400 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_Bogota BEGIN:STANDARD TZOFFSETFROM:-0400 TZOFFSETTO:-0500 TZNAME:COT DTSTART:19930404T000000 RDATE:19930404T000000 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe_London BEGIN:DAYLIGHT TZOFFSETFROM:+0000 TZOFFSETTO:+0100 TZNAME:BST DTSTART:19810329T010000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0100 TZOFFSETTO:+0000 TZNAME:GMT DTSTART:19961027T020000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe_Berlin:20140409T093000 DTEND;TZID=America_New_York:20140409T103000 RDATE;TZID=America_Bogota:20140411T113000,20140413T113000 RDATE;TZID=America_Bogota:20140415T113000 RDATE;TZID=IndianReunion:20140418T113000 RRULE:FREQ=MONTHLY;COUNT=6 DTSTAMP:20140401T234817Z UID:abcde END:VEVENT BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe_London:20140509T193000 DTEND;TZID=Europe_London:20140509T203000 DTSTAMP:20140401T234817Z UID:123 END:VEVENT BEGIN:VEVENT SUMMARY:An Updated Event DTSTART;TZID=Europe_Berlin:20140409T093000 DTEND;TZID=America_New_York:20140409T103000 DTSTAMP:20140401T234817Z UID:abcde RECURRENCE-ID;TZID=Europe_Amsterdam:20140707T070000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/cal_no_dst.ics000066400000000000000000000006361477603436700172670ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:America/Bogota BEGIN:STANDARD DTSTART:19930206T230000 TZNAME:COT TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=America/Bogota:20140409T093000 DTEND;TZID=America/Bogota:20140409T103000 DTSTAMP:20140401T234817Z UID:event_no_dst END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_d.ics000066400000000000000000000002411477603436700165760ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140410 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_d_15.ics000066400000000000000000000002611477603436700171050ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20150409 DTEND;VALUE=DATE:20150410 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_d_long.ics000066400000000000000000000002661477603436700176240ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140412 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_d_no_value.ics000066400000000000000000000001671477603436700204750ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART:20140409 DTEND:20140410 UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_d_rdate.ics000066400000000000000000000004001477603436700177520ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:d_rdate SUMMARY:this events last for a day and recurrs on four subsequent days DTSTART;VALUE=DATE:20150812 DTEND;VALUE=DATE:20150813 RDATE;VALUE=DATE:20150812,20150813,20150814,20150815 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_d_rr.ics000066400000000000000000000003201477603436700172770ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140410 RRULE:FREQ=DAILY;COUNT=10 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_d_same_start_end.ics000066400000000000000000000002661477603436700216550ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140409 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_description.ics000066400000000000000000000003731477603436700213730ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DESCRIPTION;LANG=en_US:Hey, \n\nJust setting aside some dedicated time to talk about redacted. DTSTART:20230409T093000 DTEND:20230409T103000 DTSTAMP:20230401T234817Z UID:floating_with_description_and_parameter END:VEVENT khal-0.11.4/tests/ics/event_dt_duration.ics000066400000000000000000000003261477603436700206730ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DURATION:PT1H0M0S DTSTAMP:20140401T234817Z ORGANIZER;CN=Frank Nord:mailto:frank@nord.tld UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_floating.ics000066400000000000000000000002411477603436700206450ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DESCRIPTION:Search for me DTSTART:20140409T093000 DTEND:20140409T103000 DTSTAMP:20140401T234817Z UID:floating1234567890 END:VEVENT khal-0.11.4/tests/ics/event_dt_local_missing_tz.ics000066400000000000000000000004731477603436700224110ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=FOO;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=FOO;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_london.ics000066400000000000000000000004371477603436700203420ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/London:20140409T140000 DTEND;TZID=Europe/London:20140409T190000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_long.ics000066400000000000000000000003111477603436700177770ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE-TIME:20140409T093000 DTEND;VALUE=DATE-TIME:20140412T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_mixed_awareness.ics000066400000000000000000000011071477603436700222220ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:ownCloud Calendar 0.7.3 BEGIN:VEVENT SUMMARY:Termin:Junghackertag URL:http://wiki.hamburg.ccc.de/Termin:Junghackertag UID:http://wiki.hamburg.ccc.de/Termin:Junghackertag DTSTART:20150530T120000 DTEND;TZID=Europe/Berlin:20150530T160000Z DESCRIPTION:Junghackertag DTSTAMP:20150526T182050 SEQUENCE:10172 END:VEVENT BEGIN:VEVENT SUMMARY:An event with mixed tz-aware/tz-naive dates UID:S9HZETRQne DTSTART;TZID=Europe/Berlin:20150602T120000Z DTEND:20150602T160000 DESCRIPTION:Junghackertag DTSTAMP:20150626T182050 SEQUENCE:10172 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_multi_recuid_no_master.ics000066400000000000000000000006551477603436700236070ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:abe304dc-f7b1-4f2b-997d-5c8bcfaa7e0b SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20160925T053000 RECURRENCE-ID:20140714T053000 DTSTART:20140630T073000 DURATION:PT4H30M END:VEVENT BEGIN:VEVENT UID:abe304dc-f7b1-4f2b-997d-5c8bcfaa7e0b SUMMARY:Arbeit RECURRENCE-ID:20140707T053000 RRULE:FREQ=WEEKLY;UNTIL=20160925T053000 DTSTART:20140707T083000 DTEND:20140707T120000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_multi_uid.ics000066400000000000000000000007001477603436700210350ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:Test original DTSTART:20171228T190000 DTEND:20171228T200000 DTSTAMP:20171228T190731Z UID:abc SEQUENCE:0 END:VEVENT BEGIN:VEVENT SUMMARY:Test next DTSTART:20180104T220000 DTEND:20180104T230000 UID:def END:VEVENT BEGIN:VEVENT SUMMARY:Test last DTSTART:20180104T220000 DTEND:20180104T230000 UID:def END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_no_end.ics000066400000000000000000000001501477603436700203030ustar00rootroot00000000000000BEGIN:VCALENDAR BEGIN:VEVENT SUMMARY:Test DTSTART:20160116T070000Z UID:nodtend END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_partstat.ics000066400000000000000000000014601477603436700207100ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;DELEGATED-FROM= "mailto:iamboss@example.com";CN=Henry Cabot:mailto:hcabot@ example.com ATTENDEE;ROLE=NON-PARTICIPANT;PARTSTAT=DELEGATED;DELEGATED-TO= "mailto:hcabot@example.com";CN=The Big Cheese:mailto:iamboss @example.com ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Jane Doe :mailto:jdoe@example.com ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com ATTENDEE;PARTSTAT=DECLINED:mailto:another@example.com ATTENDEE;PARTSTAT=TENTATIVE:mailto:tent@example.com END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_rd.ics000066400000000000000000000003571477603436700174570ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE-TIME:20140409T093000 DTEND;VALUE=DATE-TIME:20140409T103000 RDATE;VALUE=DATE-TIME:20140410T093000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_recuid_no_master.ics000066400000000000000000000003221477603436700223640ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT DTSTART:20170329T160000 DTEND:20170329T162500 DTSTAMP:20170322T171834Z RECURRENCE-ID:20170329T160000 SUMMARY:Infrastructure Planning UID:406336 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_rr.ics000066400000000000000000000003431477603436700174700ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE-TIME:20140409T093000 DTEND;VALUE=DATE-TIME:20140409T103000 RRULE:FREQ=DAILY;COUNT=10 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_rrule_invalid_until.ics000066400000000000000000000004621477603436700231210ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:This event is invalid but should still be supported by khal DTSTART;VALUE=DATE:20071201 DTEND;VALUE=DATE:20071202 UID:invalidRRULEUNTIL RRULE:FREQ=MONTHLY;UNTIL=20080202T000000Z END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_rrule_invalid_until2.ics000066400000000000000000000005501477603436700232010ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:This event is invalid but should still be supported by khal DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T103000 UID:invalidRRULEUNTIL2 RRULE:FREQ=WEEKLY;UNTIL=20141205 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_rrule_until_before_start.ics000066400000000000000000000004071477603436700241510ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:Stop recurring before we start DTSTART;VALUE=DATE-TIME:19801109T193000 DTEND;VALUE=DATE-TIME:19801109T203000 RRULE:FREQ=DAILY;UNTIL=19701119T203000Z DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOV END:VEVENT khal-0.11.4/tests/ics/event_dt_simple.ics000066400000000000000000000004371477603436700203420ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_simple_inkl_vtimezone.ics000066400000000000000000000010271477603436700234530ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD DTSTART:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART:20140330T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_simple_nocat.ics000066400000000000000000000005411477603436700215220ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:A not so simple Event DESCRIPTION:Everything has changed LOCATION:anywhere DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_simple_updated.ics000066400000000000000000000005641477603436700220510ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:A not so simple Event DESCRIPTION:Everything has changed LOCATION:anywhere CATEGORIES:meeting DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_simple_zulu.ics000066400000000000000000000003731477603436700214200ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART:20140409T093000Z DTEND:20140409T103000Z DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_status_confirmed.ics000066400000000000000000000004601477603436700224160ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU STATUS:CONFIRMED END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dt_two_rd.ics000066400000000000000000000004451477603436700203460ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE-TIME:20140409T093000 DTEND;VALUE=DATE-TIME:20140409T103000 RDATE;VALUE=DATE-TIME:20140410T093000 RDATE;VALUE=DATE-TIME:20140411T093000,20140412T093000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_two_tz.ics000066400000000000000000000003021477603436700203660ustar00rootroot00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=America/New_York:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.11.4/tests/ics/event_dt_url.ics000066400000000000000000000005041477603436700176460ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event URL:https://github.com/pimutils/khal DTSTART;TZID=Europe/Berlin:20140409T093000 DTEND;TZID=Europe/Berlin:20140409T103000 DTSTAMP:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dtr_exdatez.ics000066400000000000000000000004701477603436700206740ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event_dtr_exdatez SUMMARY:event_dtr_exdatez RRULE:FREQ=WEEKLY;UNTIL=20140725T053000Z EXDATE:20140721T053000Z DTSTART;TZID=Europe/Berlin:20140630T073000 DURATION:PT4H31M DESCRIPTION:An recurring datetime event with excluded dates in Zulu time END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dtr_no_tz_exdatez.ics000066400000000000000000000005211477603436700221020ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:5624b646-ba12-4dbd-ba9f-8d66319b0776 RRULE:FREQ=MONTHLY;COUNT=6 SUMMARY:Conf Call DESCRIPTION:event with not understood timezone for dtstart and zulu time form exdate DTSTART;TZID="LOLOLOL":20120403T100000 DTEND;TZID="LOLOLOL":20120403T103000 EXDATE:20120603T080000Z END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_dtr_notz_untilz.ics000066400000000000000000000004501477603436700216250ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:273A4F5B7 RRULE:FREQ=WEEKLY;UNTIL=20121101T035959Z;INTERVAL=2;BYDAY=TH SUMMARY:Storage Meeting DESCRIPTION:Meeting every other week DTSTART;TZID="GMT-05.00/-04.00":20120726T130000 DTEND;TZID="GMT-05.00/-04.00":20120726T140000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_invalid_exdate.ics000066400000000000000000000004711477603436700213400ustar00rootroot00000000000000BEGIN:VCALENDAR BEGIN:VEVENT UID:00D6A925-90F7-4663-AE13-E8BD1655EF77 SUMMARY:Soccer RRULE:FREQ=WEEKLY;UNTIL=20111206T205000Z EXDATE:20110924T195000Z EXDATE:20111126T205000Z EXDATE:20111008T195000Z DTSTART;TZID=America/New_York:20111112T155000 DTEND;TZID=America/New_York:20111112T170000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_no_dst.ics000066400000000000000000000002521477603436700176430ustar00rootroot00000000000000 BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=America/Bogota:20140409T093000 DTEND;TZID=America/Bogota:20140409T103000 DTSTAMP:20140401T234817Z UID:event_no_dst END:VEVENT khal-0.11.4/tests/ics/event_r_past.ics000066400000000000000000000002611477603436700176450ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT DTSTART;VALUE=DATE:19650423 DURATION:P1D UID:date_event_past RRULE:FREQ=YEARLY SUMMARY:Dummy's Birthday (1965) END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_rdate_no_value.ics000066400000000000000000000005671477603436700213550ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT LOCATION:Mumble channel RDATE;TZID=Europe/Berlin:20150831T113000 RDATE;TZID=Europe/Berlin:20150914T113000 RRULE:FREQ=WEEKLY;UNTIL=20161231;INTERVAL=2;BYDAY=MO DTSTART;TZID=Europe/Berlin:20160125T113000 DTEND;TZID=Europe/Berlin:20160125T123000 UID:rdatenovalue SUMMARY:An event with rdates which have no VALUE END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_rrule_no_occurence.ics000066400000000000000000000004211477603436700222260ustar00rootroot00000000000000BEGIN:VEVENT DTSTART;VALUE=DATE-TIME:20210101T093000Z DTEND;VALUE=DATE-TIME:20210101T094500Z RRULE:FREQ=WEEKLY;UNTIL=20210104T090000Z;BYDAY=TU DTSTAMP;VALUE=DATE-TIME:20210101T000000Z SUMMARY:This event will never happen UID:OW1M4APLKA2WTEOJLIGRHVSEHJHDZLLVXI2N END:VEVENT khal-0.11.4/tests/ics/event_rrule_recuid.ics000066400000000000000000000007321477603436700210440ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_rrule_recuid_cancelled.ics000066400000000000000000000012431477603436700230340ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID:20140714T050000Z DTSTART;TZID=Europe/Berlin:20140714T070000 DTEND;TZID=Europe/Berlin:20140714T120000 STATUS:CANCELLED END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_rrule_recuid_invalid_tzid.ics000066400000000000000000000006721477603436700236070ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=foo:20140630T070000 DTEND;TZID=foo:20140630T120000 END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID;TZID=foo:20140707T070000 DTSTART;TZID=foo:20140707T090000 DTEND;TZID=foo:20140707T140000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/event_rrule_recuid_update.ics000066400000000000000000000004151477603436700224040ustar00rootroot00000000000000BEGIN:VCALENDAR BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:ArbeitXXX RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 EXDATE;TZID=Europe/Berlin:20140714T070000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/invalid_tzoffset.ics000066400000000000000000000013051477603436700205260ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD DTSTART:18930401T000000 RDATE:18930401T000000 TZNAME:CEST TZOFFSETFROM:+5328 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART:19810329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT BEGIN:STANDARD DTSTART:19961027T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD END:VTIMEZONE BEGIN:VEVENT CREATED:20121120T195219Z UID:1234567890DC8-468D-B140-D2B41B1013E9 DTEND;TZID=Europe/Berlin:20121202T093000 SUMMARY:Some event DTSTART;TZID=Europe/Berlin:20121202T080000 DTSTAMP:20121120T195239Z END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/mult_uids_and_recuid_no_order.ics000066400000000000000000000011021477603436700232200ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RECURRENCE-ID:20140707T050000Z DTSTART;TZID=Europe/Berlin:20140707T090000 DTEND;TZID=Europe/Berlin:20140707T140000 END:VEVENT BEGIN:VEVENT UID:date123 DTSTART;VALUE=DATE:20130301 DTEND;VALUE=DATE:20130302 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 SUMMARY:Event with Ümläutß END:VEVENT BEGIN:VEVENT UID:event_rrule_recurrence_id SUMMARY:Arbeit RRULE:FREQ=WEEKLY;UNTIL=20140806T060000Z DTSTART;TZID=Europe/Berlin:20140630T070000 DTEND;TZID=Europe/Berlin:20140630T120000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/non_dst_error.ics000066400000000000000000000101571477603436700200360ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//NS Internationaal B.V.//sales-v2//en BEGIN:VTIMEZONE TZID:Europe/Brussels LAST-MODIFIED:20221105T024525Z TZURL:http://www.tzurl.org/zoneinfo/Europe/Brussels X-LIC-LOCATION:Europe/Brussels X-PROLEPTIC-TZNAME:LMT BEGIN:STANDARD TZNAME:BMT TZOFFSETFROM:+0017 TZOFFSETTO:+0017 DTSTART:18800101T000000 END:STANDARD BEGIN:STANDARD TZNAME:WET TZOFFSETFROM:+0017 TZOFFSETTO:+0000 DTSTART:18920501T001730 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0000 TZOFFSETTO:+0100 DTSTART:19141108T000000 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19161001T010000 RDATE:19421102T030000 RDATE:19431004T030000 RDATE:19440917T030000 RDATE:19450916T030000 RDATE:19461007T030000 RDATE:19770925T030000 RDATE:19781001T030000 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19170917T030000 RRULE:FREQ=YEARLY;UNTIL=19180916T010000Z;BYDAY=3MO;BYMONTH=9 END:STANDARD BEGIN:STANDARD TZNAME:WET TZOFFSETFROM:+0100 TZOFFSETTO:+0000 DTSTART:19181111T120000 RDATE:19191005T000000 RDATE:19201024T000000 RDATE:19211026T000000 RDATE:19391119T030000 END:STANDARD BEGIN:STANDARD TZNAME:WET TZOFFSETFROM:+0100 TZOFFSETTO:+0000 DTSTART:19221008T000000 RRULE:FREQ=YEARLY;UNTIL=19271001T230000Z;BYDAY=SU;BYMONTHDAY=2,3,4,5,6,7,8; BYMONTH=10 END:STANDARD BEGIN:STANDARD TZNAME:WET TZOFFSETFROM:+0100 TZOFFSETTO:+0000 DTSTART:19281007T030000 RRULE:FREQ=YEARLY;UNTIL=19381002T020000Z;BYDAY=SU;BYMONTHDAY=2,3,4,5,6,7,8; BYMONTH=10 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0100 TZOFFSETTO:+0100 DTSTART:19770101T000000 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19790930T030000 RRULE:FREQ=YEARLY;UNTIL=19950924T010000Z;BYDAY=-1SU;BYMONTH=9 END:STANDARD BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19961027T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19160501T000000 RDATE:19400520T030000 RDATE:19430329T020000 RDATE:19440403T020000 RDATE:19450402T020000 RDATE:19460519T020000 END:DAYLIGHT BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19170416T020000 RRULE:FREQ=YEARLY;UNTIL=19180415T010000Z;BYDAY=3MO;BYMONTH=4 END:DAYLIGHT BEGIN:DAYLIGHT TZNAME:WEST TZOFFSETFROM:+0000 TZOFFSETTO:+0100 DTSTART:19190301T230000 RDATE:19200214T230000 RDATE:19210314T230000 RDATE:19220325T230000 RDATE:19230421T230000 RDATE:19240329T230000 RDATE:19250404T230000 RDATE:19260417T230000 RDATE:19270409T230000 RDATE:19280414T230000 RDATE:19290421T020000 RDATE:19300413T020000 RDATE:19310419T020000 RDATE:19320403T020000 RDATE:19330326T020000 RDATE:19340408T020000 RDATE:19350331T020000 RDATE:19360419T020000 RDATE:19370404T020000 RDATE:19380327T020000 RDATE:19390416T020000 RDATE:19400225T020000 END:DAYLIGHT BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0200 TZOFFSETTO:+0200 DTSTART:19440903T000000 END:DAYLIGHT BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19770403T020000 RRULE:FREQ=YEARLY;UNTIL=19800406T010000Z;BYDAY=1SU;BYMONTH=4 END:DAYLIGHT BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19810329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:4e3e3186-28c6-42d4-81cf-eb4f56d587f1 DTSTAMP:20230122T142045Z SUMMARY:Trip from Utrecht to Bruxelles Central/Brussel Centraal DESCRIPTION:Trip from Utrecht to Bruxelles Central/Brussel Centraal LOCATION:Utrecht URL:https://www.nsinternational.com/en/traintickets-v3/ DTSTART;TZID=Europe/Brussels:20230203T151500 DTEND;TZID=Europe/Brussels:20230203T180600 BEGIN:VALARM ACTION:DISPLAY TRIGGER;RELATED=START:PT-2H END:VALARM END:VEVENT BEGIN:VEVENT UID:28d25a55-84cd-4564-910b-088066fd5244 DTSTAMP:20230122T142045Z SUMMARY:Trip from Bruxelles Central/Brussel Centraal to Utrecht DESCRIPTION:Trip from Bruxelles Central/Brussel Centraal to Utrecht LOCATION:Bruxelles Central/Brussel Centraal URL:https://www.nsinternational.com/en/traintickets-v3/ DTSTART;TZID=Europe/Brussels:20230206T095000 DTEND;TZID=Europe/Brussels:20230206T125000 BEGIN:VALARM ACTION:DISPLAY TRIGGER;RELATED=START:PT-2H END:VALARM END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/part0.ics000066400000000000000000000011041477603436700161770ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Europe_London BEGIN:DAYLIGHT TZOFFSETFROM:+0000 TZOFFSETTO:+0100 TZNAME:BST DTSTART:19810329T010000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0100 TZOFFSETTO:+0000 TZNAME:GMT DTSTART:19961027T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe_London:20140509T193000 DTEND;TZID=Europe_London:20140509T203000 DTSTAMP:20140401T234817Z UID:123 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/part1.ics000066400000000000000000000036251477603436700162120ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:IndianReunion BEGIN:STANDARD TZOFFSETFROM:+034152 TZOFFSETTO:+0400 TZNAME:RET DTSTART:19110601T000000 RDATE:19110601T000000 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe_Amsterdam BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19810329T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19961027T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 END:STANDARD END:VTIMEZONE BEGIN:VTIMEZONE TZID:Europe_Berlin BEGIN:STANDARD DTSTART:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RDATE:20151025T020000 END:STANDARD BEGIN:DAYLIGHT DTSTART:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_New_York BEGIN:STANDARD DTSTART:20141102T010000 RDATE:20151101T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD BEGIN:DAYLIGHT DTSTART:20140309T030000 RDATE:20150308T030000,20160313T030000 TZNAME:EDT TZOFFSETFROM:-0500 TZOFFSETTO:-0400 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_Bogota BEGIN:STANDARD TZOFFSETFROM:-0400 TZOFFSETTO:-0500 TZNAME:COT DTSTART:19930404T000000 RDATE:19930404T000000 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe_Berlin:20140409T093000 DTEND;TZID=America_New_York:20140409T103000 RDATE;TZID=IndianReunion:20140418T113000 RDATE;TZID=America_Bogota:20140411T113000,20140413T113000 RDATE;TZID=America_Bogota:20140415T113000 RRULE:FREQ=MONTHLY;COUNT=6 DTSTAMP:20140401T234817Z UID:abcde END:VEVENT BEGIN:VEVENT SUMMARY:An Updated Event DTSTART;TZID=Europe_Berlin:20140409T093000 DTEND;TZID=America_New_York:20140409T103000 DTSTAMP:20140401T234817Z UID:abcde RECURRENCE-ID;TZID=Europe_Amsterdam:20140707T070000 END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/tz_windows_format.ics000066400000000000000000000012441477603436700207350ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:New Zealand Standard Time BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:20191027T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20200329T030000 TZNAME:CEST TZOFFSETFROM:+1200 TZOFFSETTO:+1300 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT SUMMARY:Event with Windows TZ name DTSTART;TZID=New Zealand Standard Time;VALUE=DATE-TIME:20191120T130000 DTEND;TZID=New Zealand Standard Time;VALUE=DATE-TIME:20191120T143000 DTSTAMP;VALUE=DATE-TIME:20191105T094904Z UID:PERSOYPOYGVR55JMICVAFVDWWBIKPC9PTAG6SN4B2YT END:VEVENT END:VCALENDAR khal-0.11.4/tests/ics/without_uid.ics000066400000000000000000000003501477603436700175170ustar00rootroot00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140410 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z END:VEVENT END:VCALENDAR khal-0.11.4/tests/khalendar_test.py000066400000000000000000000567041477603436700172550ustar00rootroot00000000000000import datetime as dt import logging import os from textwrap import dedent from time import sleep import pytest from freezegun import freeze_time import khal.khalendar.exceptions import khal.utils from khal import icalendar as icalendar_helpers from khal.controllers import human_formatter from khal.khalendar import CalendarCollection from khal.khalendar.backend import CouldNotCreateDbDir from khal.khalendar.event import Event from khal.khalendar.vdir import Item from . import utils from .utils import ( BERLIN, LOCALE_BERLIN, LOCALE_SYDNEY, LONDON, SYDNEY, CollVdirType, DumbItem, _get_text, cal1, cal2, cal3, normalize_component, ) today = dt.date.today() yesterday = today - dt.timedelta(days=1) tomorrow = today + dt.timedelta(days=1) aday = dt.date(2014, 4, 9) bday = dt.date(2014, 4, 10) event_allday_template = """BEGIN:VEVENT SEQUENCE:0 UID:uid3@host1.com DTSTART;VALUE=DATE:{} DTEND;VALUE=DATE:{} SUMMARY:a meeting DESCRIPTION:short description LOCATION:LDB Lobby END:VEVENT""" event_today = event_allday_template.format( today.strftime('%Y%m%d'), tomorrow.strftime('%Y%m%d')) item_today = Item(event_today) SIMPLE_EVENT_UID = 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU' class TestCalendar: def test_create(self, coll_vdirs): assert True def test_new_event(self, coll_vdirs): coll, vdirs = coll_vdirs event = coll.create_event_from_ics(event_today, cal1) assert event.calendar == cal1 coll.insert(event) events = list(coll.get_events_on(today)) assert len(events) == 1 assert events[0].color == 'dark blue' assert len(list(coll.get_events_on(tomorrow))) == 0 assert len(list(coll.get_events_on(yesterday))) == 0 assert len(list(vdirs[cal1].list())) == 1 def test_sanity(self, coll_vdirs): coll, vdirs = coll_vdirs mtimes = {} for _ in range(100): for cal in coll._calendars: mtime = coll._local_ctag(cal) if mtimes.get(cal): assert mtimes[cal] == mtime else: mtimes[cal] = mtime def test_db_needs_update(self, coll_vdirs, sleep_time): coll, vdirs = coll_vdirs print('init') for calendar in coll._calendars: print(f'{calendar}: saved ctag: {coll._local_ctag(calendar)}, ' f'vdir ctag: {coll._backend.get_ctag(calendar)}') assert len(list(vdirs[cal1].list())) == 0 assert coll._needs_update(cal1) is False sleep(sleep_time) vdirs[cal1].upload(item_today) print('upload') for calendar in coll._calendars: print(f'{calendar}: saved ctag: {coll._local_ctag(calendar)}, ' f'vdir ctag: {coll._backend.get_ctag(calendar)}') assert len(list(vdirs[cal1].list())) == 1 assert coll._needs_update(cal1) is True coll.update_db() print('updated') for calendar in coll._calendars: print(f'{calendar}: saved ctag: {coll._local_ctag(calendar)}, ' f'vdir ctag: {coll._backend.get_ctag(calendar)}') assert coll._needs_update(cal1) is False class TestVdirsyncerCompat: def test_list(self, coll_vdirs): coll, vdirs = coll_vdirs event = Event.fromString(_get_text('event_d'), calendar=cal1, locale=LOCALE_BERLIN) assert event.etag is None assert event.href is None coll.insert(event) assert event.etag is not None assert event.href == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU.ics' event = Event.fromString(event_today, calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event) hrefs = sorted(href for href, etag in coll._backend.list(cal1)) assert {str(coll.get_event(href, calendar=cal1).uid) for href in hrefs} == { 'uid3@host1.com', 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU', } class TestCollection: astart = dt.datetime.combine(aday, dt.time.min) aend = dt.datetime.combine(aday, dt.time.max) bstart = dt.datetime.combine(bday, dt.time.min) bend = dt.datetime.combine(bday, dt.time.max) astart_berlin = utils.BERLIN.localize(astart) aend_berlin = utils.BERLIN.localize(aend) bstart_berlin = utils.BERLIN.localize(bstart) bend_berlin = utils.BERLIN.localize(bend) def test_default_calendar(self, tmpdir): calendars = { 'foobar': {'name': 'foobar', 'path': str(tmpdir), 'readonly': True}, 'home': {'name': 'home', 'path': str(tmpdir)}, "Dad's Calendar": {'name': "Dad's calendar", 'path': str(tmpdir), 'readonly': True}, } coll = CalendarCollection( calendars=calendars, locale=LOCALE_BERLIN, dbpath=':memory:', ) assert coll.default_calendar_name is None with pytest.raises(ValueError): coll.default_calendar_name = "Dad's calendar" assert coll.default_calendar_name is None with pytest.raises(ValueError): coll.default_calendar_name = 'unknownstuff' assert coll.default_calendar_name is None coll.default_calendar_name = 'home' assert coll.default_calendar_name == 'home' assert coll.writable_names == ['home'] def test_empty(self, coll_vdirs): coll, vdirs = coll_vdirs start = dt.datetime.combine(today, dt.time.min) end = dt.datetime.combine(today, dt.time.max) assert list(coll.get_floating(start, end)) == [] assert list(coll.get_localized(utils.BERLIN.localize(start), utils.BERLIN.localize(end))) == [] def test_insert(self, coll_vdirs): """insert a localized event""" coll, vdirs = coll_vdirs coll.insert( Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN), cal1) events = list(coll.get_localized(self.astart_berlin, self.aend_berlin)) assert len(events) == 1 assert events[0].color == 'dark blue' assert events[0].calendar == cal1 events = list(coll.get_events_on(aday)) assert len(events) == 1 assert events[0].color == 'dark blue' assert events[0].calendar == cal1 assert len(list(vdirs[cal1].list())) == 1 assert len(list(vdirs[cal2].list())) == 0 assert len(list(vdirs[cal3].list())) == 0 assert list(coll.get_floating(self.astart, self.aend)) == [] def test_insert_d(self, coll_vdirs): """insert a floating event""" coll, vdirs = coll_vdirs event = Event.fromString(_get_text('event_d'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) events = list(coll.get_events_on(aday)) assert len(events) == 1 assert events[0].calendar == cal1 assert events[0].color == 'dark blue' assert len(list(vdirs[cal1].list())) == 1 assert len(list(vdirs[cal2].list())) == 0 assert len(list(vdirs[cal3].list())) == 0 assert list(coll.get_localized(self.bstart_berlin, self.bend_berlin)) == [] def test_insert_d_no_value(self, coll_vdirs): """insert a date event with no VALUE=DATE option""" coll, vdirs = coll_vdirs coll.insert( Event.fromString( _get_text('event_d_no_value'), calendar=cal1, locale=LOCALE_BERLIN), cal1) events = list(coll.get_events_on(aday)) assert len(events) == 1 assert events[0].calendar == cal1 assert len(list(vdirs[cal1].list())) == 1 assert len(list(vdirs[cal2].list())) == 0 assert len(list(vdirs[cal3].list())) == 0 assert list(coll.get_localized(self.bstart_berlin, self.bend_berlin)) == [] def test_get(self, coll_vdirs): """test getting an event by its href""" coll, vdirs = coll_vdirs event = Event.fromString( _get_text('event_dt_simple'), href='xyz.ics', calendar=cal1, locale=LOCALE_BERLIN, ) coll.insert(event, cal1) event_from_db = coll.get_event(SIMPLE_EVENT_UID + '.ics', cal1) with freeze_time('2016-1-1'): assert normalize_component(event_from_db.raw) == \ normalize_component(_get_text('event_dt_simple_inkl_vtimezone')) assert event_from_db.etag def test_change(self, coll_vdirs): """moving an event from one calendar to another""" coll, vdirs = coll_vdirs event = Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) event = list(coll.get_events_on(aday))[0] assert event.calendar == cal1 coll.change_collection(event, cal2) events = list(coll.get_events_on(aday)) assert len(events) == 1 assert events[0].calendar == cal2 def test_update_event(self, coll_vdirs): """updating one event""" coll, vdirs = coll_vdirs event = Event.fromString( _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) events = coll.get_events_on(aday) event = list(events)[0] event.update_summary('really simple event') event.update_start_end(bday, bday) coll.update(event) events = list(coll.get_localized(self.astart_berlin, self.aend_berlin)) assert len(events) == 0 events = list(coll.get_floating(self.bstart, self.bend)) assert len(events) == 1 assert events[0].summary == 'really simple event' def test_newevent(self, coll_vdirs): coll, vdirs = coll_vdirs bday = dt.datetime.combine(aday, dt.time.min) anend = bday + dt.timedelta(hours=1) event = icalendar_helpers.new_vevent( dtstart=bday, dtend=anend, summary="hi", timezone=utils.BERLIN, locale=LOCALE_BERLIN, ) event = coll.create_event_from_ics(event.to_ical(), coll.default_calendar_name) assert event.allday is False def test_modify_readonly_calendar(self, coll_vdirs): coll, vdirs = coll_vdirs coll._calendars[cal1]['readonly'] = True coll._calendars[cal3]['readonly'] = True event = Event.fromString(_get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN) with pytest.raises(khal.khalendar.exceptions.ReadOnlyCalendarError): coll.insert(event, cal1) with pytest.raises(khal.khalendar.exceptions.ReadOnlyCalendarError): # params don't really matter here coll.delete('href', 'eteg', cal1) def test_search(self, coll_vdirs): coll, vdirs = coll_vdirs assert len(list(coll.search('Event'))) == 0 event = Event.fromString( _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) assert len(list(coll.search('Event'))) == 1 event = Event.fromString( _get_text('event_dt_floating'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) assert len(list(coll.search('Search for me'))) == 1 assert len(list(coll.search('Event'))) == 2 def test_search_recurrence_id_only(self, coll_vdirs): """test searching for recurring events which only have a recuid event, and no master""" coll, vdirs = coll_vdirs assert len(list(coll.search('Event'))) == 0 event = Event.fromString( _get_text('event_dt_recuid_no_master'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) assert len(list(coll.search('Event'))) == 1 def test_search_recurrence_id_only_multi(self, coll_vdirs): """test searching for recurring events which only have a recuid event, and no master""" coll, vdirs = coll_vdirs assert len(list(coll.search('Event'))) == 0 event = Event.fromString( _get_text('event_dt_multi_recuid_no_master'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) events = sorted(coll.search('Event')) assert len(events) == 2 assert human_formatter('{start} {end} {title}')(events[0].attributes( dt.date.today())) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m' assert human_formatter('{start} {end} {title}')(events[1].attributes( dt.date.today())) == '07.07. 08:30 07.07. 12:00 Arbeit\x1b[0m' def test_delete_two_events(self, coll_vdirs, sleep_time): """testing if we can delete any of two events in two different calendars with the same filename""" coll, vdirs = coll_vdirs event1 = Event.fromString( _get_text('event_dt_simple'), calendar=cal1, locale=LOCALE_BERLIN) event2 = Event.fromString( _get_text('event_dt_simple'), calendar=cal2, locale=LOCALE_BERLIN) coll.insert(event1, cal1) sleep(sleep_time) # make sure the etags are different coll.insert(event2, cal2) etag1 = list(vdirs[cal1].list())[0][1] etag2 = list(vdirs[cal2].list())[0][1] events = list(coll.get_localized(self.astart_berlin, self.aend_berlin)) assert len(events) == 2 assert events[0].calendar != events[1].calendar for event in events: if event.calendar == cal1: assert event.etag == etag1 if event.calendar == cal2: assert event.etag == etag2 def test_delete_recuid(self, coll_vdirs: CollVdirType): """Testing if we can delete a recuid (add it to exdate)""" coll, _ = coll_vdirs event_str = _get_text('event_rrule_recuid') event = Event.fromString(event_str, calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) event = coll.get_event('event_rrule_recurrence_id.ics', cal1) event = coll.delete_instance( 'event_rrule_recurrence_id.ics', event.etag, calendar=cal1, rec_id=BERLIN.localize(dt.datetime(2014, 7, 14, 5)), ) assert 'EXDATE;TZID=Europe/Berlin:20140714T050000' in event.raw.split() event = coll.delete_instance( 'event_rrule_recurrence_id.ics', event.etag, calendar=cal1, rec_id=BERLIN.localize(dt.datetime(2014, 7, 21, 5)), ) assert 'EXDATE;TZID=Europe/Berlin:20140714T050000,20140721T050000' in event.raw.split() def test_invalid_timezones(self, coll_vdirs): """testing if we can delete any of two events in two different calendars with the same filename""" coll, vdirs = coll_vdirs event = Event.fromString( _get_text('invalid_tzoffset'), calendar=cal1, locale=LOCALE_BERLIN) coll.insert(event, cal1) events = sorted(coll.search('Event')) assert len(events) == 1 assert human_formatter('{start} {end} {title}')(events[0].attributes(dt.date.today())) == \ '02.12. 08:00 02.12. 09:30 Some event\x1b[0m' def test_multi_uid_vdir(self, coll_vdirs, caplog, fix_caplog, sleep_time): coll, vdirs = coll_vdirs caplog.set_level(logging.WARNING) sleep(sleep_time) # Make sure we get a new ctag on upload vdirs[cal1].upload(DumbItem(_get_text('event_dt_multi_uid'), uid='12345')) coll.update_db() assert list(coll.search('')) == [] messages = [rec.message for rec in caplog.records] assert messages[0].startswith( "The .ics file at foobar/12345.ics contains multiple UIDs.\n" ) assert messages[1].startswith( "Skipping foobar/12345.ics: \nThis event will not be available in khal." ) class TestDbCreation: def test_create_db(self, tmpdir): vdirpath = str(tmpdir) + '/' + cal1 os.makedirs(vdirpath, mode=0o770) dbdir = str(tmpdir) + '/subdir/' dbpath = dbdir + 'khal.db' assert not os.path.isdir(dbdir) calendars = {cal1: {'name': cal1, 'path': vdirpath}} CalendarCollection(calendars, dbpath=dbpath, locale=LOCALE_BERLIN) assert os.path.isdir(dbdir) def test_failed_create_db(self, tmpdir): dbdir = str(tmpdir) + '/subdir/' dbpath = dbdir + 'khal.db' os.chmod(str(tmpdir), 400) calendars = {cal1: {'name': cal1, 'path': str(tmpdir)}} with pytest.raises(CouldNotCreateDbDir): CalendarCollection(calendars, dbpath=dbpath, locale=LOCALE_BERLIN) os.chmod(str(tmpdir), 777) def test_event_different_timezones(coll_vdirs, sleep_time): coll, vdirs = coll_vdirs sleep(sleep_time) # Make sure we get a new ctag on upload vdirs[cal1].upload(DumbItem(_get_text('event_dt_london'), uid='12345')) coll.update_db() events = coll.get_localized( BERLIN.localize(dt.datetime(2014, 4, 9, 0, 0)), BERLIN.localize(dt.datetime(2014, 4, 9, 23, 59)), ) events = list(events) assert len(events) == 1 event = events[0] assert event.start_local == LONDON.localize(dt.datetime(2014, 4, 9, 14)) assert event.end_local == LONDON.localize(dt.datetime(2014, 4, 9, 19)) assert event.start == LONDON.localize(dt.datetime(2014, 4, 9, 14)) assert event.end == LONDON.localize(dt.datetime(2014, 4, 9, 19)) # no event scheduled on the next day events = coll.get_localized( BERLIN.localize(dt.datetime(2014, 4, 10, 0, 0)), BERLIN.localize(dt.datetime(2014, 4, 10, 23, 59)), ) events = list(events) assert len(events) == 0 # now setting the local_timezone to Sydney coll.locale = LOCALE_SYDNEY events = coll.get_localized( SYDNEY.localize(dt.datetime(2014, 4, 9, 0, 0)), SYDNEY.localize(dt.datetime(2014, 4, 9, 23, 59)), ) events = list(events) assert len(events) == 1 event = events[0] assert event.start_local == SYDNEY.localize(dt.datetime(2014, 4, 9, 23)) assert event.end_local == SYDNEY.localize(dt.datetime(2014, 4, 10, 4)) assert event.start == LONDON.localize(dt.datetime(2014, 4, 9, 14)) assert event.end == LONDON.localize(dt.datetime(2014, 4, 9, 19)) # the event spans midnight Sydney, therefor it should also show up on the # next day events = coll.get_localized(SYDNEY.localize(dt.datetime(2014, 4, 10, 0, 0)), SYDNEY.localize(dt.datetime(2014, 4, 10, 23, 59))) events = list(events) assert len(events) == 1 assert event.start_local == SYDNEY.localize(dt.datetime(2014, 4, 9, 23)) assert event.end_local == SYDNEY.localize(dt.datetime(2014, 4, 10, 4)) def test_default_calendar(coll_vdirs, sleep_time): """test if an update to the vdir is detected by the CalendarCollection""" coll, vdirs = coll_vdirs vdir = vdirs['foobar'] event = coll.create_event_from_ics(event_today, 'foobar') assert len(list(coll.get_events_on(today))) == 0 sleep(sleep_time) # Make sure we get a new ctag on upload vdir.upload(event) sleep(sleep_time) href, etag = list(vdir.list())[0] assert len(list(coll.get_events_on(today))) == 0 coll.update_db() sleep(sleep_time) assert len(list(coll.get_events_on(today))) == 1 vdir.delete(href, etag) sleep(sleep_time) assert len(list(coll.get_events_on(today))) == 1 coll.update_db() sleep(sleep_time) assert len(list(coll.get_events_on(today))) == 0 def test_only_update_old_event(coll_vdirs, monkeypatch, sleep_time): coll, vdirs = coll_vdirs href_one, etag_one = vdirs[cal1].upload(coll.create_event_from_ics(dedent(""" BEGIN:VEVENT UID:meeting-one DTSTART;VALUE=DATE:20140909 DTEND;VALUE=DATE:20140910 SUMMARY:first meeting END:VEVENT """), cal1)) sleep(sleep_time) # Make sure we get a new etag for meeting-two href_two, etag_two = vdirs[cal1].upload(coll.create_event_from_ics(dedent(""" BEGIN:VEVENT UID:meeting-two DTSTART;VALUE=DATE:20140910 DTEND;VALUE=DATE:20140911 SUMMARY:second meeting END:VEVENT """), cal1)) sleep(sleep_time) coll.update_db() sleep(sleep_time) assert not coll._needs_update(cal1) old_update_vevent = coll._update_vevent updated_hrefs = [] def _update_vevent(href, calendar): updated_hrefs.append(href) return old_update_vevent(href, calendar) monkeypatch.setattr(coll, '_update_vevent', _update_vevent) href_three, etag_three = vdirs[cal1].upload(coll.create_event_from_ics(dedent(""" BEGIN:VEVENT UID:meeting-three DTSTART;VALUE=DATE:20140911 DTEND;VALUE=DATE:20140912 SUMMARY:third meeting END:VEVENT """), cal1)) sleep(sleep_time) assert coll._needs_update(cal1) coll.update_db() sleep(sleep_time) assert updated_hrefs == [href_three] card = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:19710311 END:VCARD """ card_29thfeb = """BEGIN:VCARD VERSION:3.0 FN:leapyear BDAY:20000229 END:VCARD """ card_no_year = """BEGIN:VCARD VERSION:3.0 FN:Unix BDAY:--0311 END:VCARD """ def test_birthdays(coll_vdirs_birthday, sleep_time): coll, vdirs = coll_vdirs_birthday assert list( coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59)) ) == [] sleep(sleep_time) # Make sure we get a new ctag on upload vdirs[cal1].upload(DumbItem(card, 'unix')) coll.update_db() assert 'Unix\'s 41st birthday' == list( coll.get_floating(dt.datetime(2012, 3, 11), dt.datetime(2012, 3, 11)))[0].summary assert 'Unix\'s 42nd birthday' == list( coll.get_floating(dt.datetime(2013, 3, 11), dt.datetime(2013, 3, 11)))[0].summary assert 'Unix\'s 43rd birthday' == list( coll.get_floating(dt.datetime(2014, 3, 11), dt.datetime(2014, 3, 11)))[0].summary def test_birthdays_29feb(coll_vdirs_birthday, sleep_time): """test how we deal with birthdays on 29th of feb in leap years""" coll, vdirs = coll_vdirs_birthday sleep(sleep_time) # Make sure we get a new ctag on upload vdirs[cal1].upload(DumbItem(card_29thfeb, 'leap')) assert coll.needs_update() coll.update_db() events = list( coll.get_floating(dt.datetime(2004, 1, 1, 0, 0), dt.datetime(2004, 12, 31)) ) assert len(events) == 1 assert events[0].summary == 'leapyear\'s 4th birthday (29th of Feb.)' assert events[0].start == dt.date(2004, 2, 29) events = list( coll.get_floating(dt.datetime(2005, 1, 1, 0, 0), dt.datetime(2005, 12, 31)) ) assert len(events) == 1 assert events[0].summary == 'leapyear\'s 5th birthday (29th of Feb.)' assert events[0].start == dt.date(2005, 3, 1) assert list( coll.get_floating(dt.datetime(2001, 1, 1), dt.datetime(2001, 12, 31)) )[0].summary == 'leapyear\'s 1st birthday (29th of Feb.)' assert list( coll.get_floating(dt.datetime(2002, 1, 1), dt.datetime(2002, 12, 31)) )[0].summary == 'leapyear\'s 2nd birthday (29th of Feb.)' assert list( coll.get_floating(dt.datetime(2003, 1, 1), dt.datetime(2003, 12, 31)) )[0].summary == 'leapyear\'s 3rd birthday (29th of Feb.)' assert list( coll.get_floating(dt.datetime(2023, 1, 1), dt.datetime(2023, 12, 31)) )[0].summary == 'leapyear\'s 23rd birthday (29th of Feb.)' assert events[0].start == dt.date(2005, 3, 1) def test_birthdays_no_year(coll_vdirs_birthday, sleep_time): coll, vdirs = coll_vdirs_birthday assert list( coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59)) ) == [] sleep(sleep_time) # Make sure we get a new ctag on upload vdirs[cal1].upload(DumbItem(card_no_year, 'vcard.vcf')) coll.update_db() events = list(coll.get_floating(dt.datetime(1971, 3, 11), dt.datetime(1971, 3, 11, 23, 59, 59))) assert len(events) == 1 assert 'Unix\'s birthday' == events[0].summary khal-0.11.4/tests/khalendar_utils_test.py000066400000000000000000000631531477603436700204710ustar00rootroot00000000000000import datetime as dt import icalendar import pytz from khal import icalendar as icalendar_helpers from khal import utils from .utils import _get_text, _get_vevent_file # FIXME this file is in urgent need of a clean up BERLIN = pytz.timezone('Europe/Berlin') BOGOTA = pytz.timezone('America/Bogota') # datetime event_dt = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T140000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T160000 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 UID:datetime123 END:VEVENT END:VCALENDAR""" event_dt_norr = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T140000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T160000 UID:datetime123 END:VEVENT END:VCALENDAR""" # datetime zulu (in utc time) event_dttz = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime Zulu Event DTSTART;VALUE=DATE-TIME:20130301T140000Z DTEND;VALUE=DATE-TIME:20130301T160000Z RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 UID:datetimezulu123 END:VEVENT END:VCALENDAR""" event_dttz_norr = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime Zulu Event DTSTART;VALUE=DATE-TIME:20130301T140000Z DTEND;VALUE=DATE-TIME:20130301T160000Z UID:datetimezulu123 END:VEVENT END:VCALENDAR""" # datetime floating (no time zone information) event_dtf = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime floating Event DTSTART;VALUE=DATE-TIME:20130301T140000 DTEND;VALUE=DATE-TIME:20130301T160000 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 UID:datetimefloating123 END:VEVENT END:VCALENDAR""" event_dtf_norr = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT SUMMARY:Datetime floating Event DTSTART;VALUE=DATE-TIME:20130301T140000 DTEND;VALUE=DATE-TIME:20130301T160000 UID:datetimefloating123 END:VEVENT END:VCALENDAR""" # datetime broken (as in we don't understand the timezone information) event_dtb = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VTIMEZONE TZID:/freeassociation.sourceforge.net/Tzfile/Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:STANDARD TZNAME:CET DTSTART:19701027T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT TZNAME:CEST DTSTART:19700331T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:broken123 DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20130301T140000 DTEND;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20130301T160000 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 TRANSP:OPAQUE SEQUENCE:2 SUMMARY:Broken Event END:VEVENT END:VCALENDAR """ event_dtb_norr = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VTIMEZONE TZID:/freeassociation.sourceforge.net/Tzfile/Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:STANDARD TZNAME:CET DTSTART:19701027T030000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT TZNAME:CEST DTSTART:19700331T020000 RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT UID:broken123 DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20130301T140000 DTEND;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20130301T160000 TRANSP:OPAQUE SEQUENCE:2 SUMMARY:Broken Event END:VEVENT END:VCALENDAR """ # all day (date) event event_d = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT UID:date123 DTSTART;VALUE=DATE:20130301 DTEND;VALUE=DATE:20130302 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 SUMMARY:Event END:VEVENT END:VCALENDAR """ # all day (date) event with timezone information event_dtz = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT UID:datetz123 DTSTART;TZID=Berlin/Europe;VALUE=DATE:20130301 DTEND;TZID=Berlin/Europe;VALUE=DATE:20130302 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 SUMMARY:Event END:VEVENT END:VCALENDAR """ event_dtzb = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VTIMEZONE TZID:Pacific Time (US & Canada), Tijuana BEGIN:STANDARD TZNAME:PST DTSTART:20071104T020000 TZOFFSETTO:-0800 TZOFFSETFROM:-0700 RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU END:STANDARD BEGIN:DAYLIGHT TZNAME:PDT DTSTART:20070311T020000 TZOFFSETTO:-0700 TZOFFSETFROM:-0800 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT DTSTART;VALUE=DATE;TZID="Pacific Time (US & Canada), Tijuana":20130301 DTEND;VALUE=DATE;TZID="Pacific Time (US & Canada), Tijuana":20130302 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 SUMMARY:Event UID:eventdtzb123 END:VEVENT END:VCALENDAR """ event_d_norr = """BEGIN:VCALENDAR CALSCALE:GREGORIAN VERSION:2.0 BEGIN:VEVENT UID:date123 DTSTART;VALUE=DATE:20130301 DTEND;VALUE=DATE:20130302 SUMMARY:Event END:VEVENT END:VCALENDAR """ berlin = pytz.timezone('Europe/Berlin') new_york = pytz.timezone('America/New_York') def _get_vevent(event): ical = icalendar.Event.from_ical(event) for component in ical.walk(): if component.name == 'VEVENT': return component class TestExpand: dtstartend_berlin = [ (berlin.localize(dt.datetime(2013, 3, 1, 14, 0, )), berlin.localize(dt.datetime(2013, 3, 1, 16, 0, ))), (berlin.localize(dt.datetime(2013, 5, 1, 14, 0, )), berlin.localize(dt.datetime(2013, 5, 1, 16, 0, ))), (berlin.localize(dt.datetime(2013, 7, 1, 14, 0, )), berlin.localize(dt.datetime(2013, 7, 1, 16, 0, ))), (berlin.localize(dt.datetime(2013, 9, 1, 14, 0, )), berlin.localize(dt.datetime(2013, 9, 1, 16, 0, ))), (berlin.localize(dt.datetime(2013, 11, 1, 14, 0,)), berlin.localize(dt.datetime(2013, 11, 1, 16, 0,))), (berlin.localize(dt.datetime(2014, 1, 1, 14, 0, )), berlin.localize(dt.datetime(2014, 1, 1, 16, 0, ))) ] dtstartend_utc = [ (dt.datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)), (dt.datetime(2013, 5, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 5, 1, 16, 0, tzinfo=pytz.utc)), (dt.datetime(2013, 7, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 7, 1, 16, 0, tzinfo=pytz.utc)), (dt.datetime(2013, 9, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 9, 1, 16, 0, tzinfo=pytz.utc)), (dt.datetime(2013, 11, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 11, 1, 16, 0, tzinfo=pytz.utc)), (dt.datetime(2014, 1, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2014, 1, 1, 16, 0, tzinfo=pytz.utc)) ] dtstartend_float = [ (dt.datetime(2013, 3, 1, 14, 0), dt.datetime(2013, 3, 1, 16, 0)), (dt.datetime(2013, 5, 1, 14, 0), dt.datetime(2013, 5, 1, 16, 0)), (dt.datetime(2013, 7, 1, 14, 0), dt.datetime(2013, 7, 1, 16, 0)), (dt.datetime(2013, 9, 1, 14, 0), dt.datetime(2013, 9, 1, 16, 0)), (dt.datetime(2013, 11, 1, 14, 0), dt.datetime(2013, 11, 1, 16, 0)), (dt.datetime(2014, 1, 1, 14, 0), dt.datetime(2014, 1, 1, 16, 0)) ] dstartend = [ (dt.date(2013, 3, 1,), dt.date(2013, 3, 2,)), (dt.date(2013, 5, 1,), dt.date(2013, 5, 2,)), (dt.date(2013, 7, 1,), dt.date(2013, 7, 2,)), (dt.date(2013, 9, 1,), dt.date(2013, 9, 2,)), (dt.date(2013, 11, 1), dt.date(2013, 11, 2)), (dt.date(2014, 1, 1,), dt.date(2014, 1, 2,)) ] offset_berlin = [ dt.timedelta(0, 3600), dt.timedelta(0, 7200), dt.timedelta(0, 7200), dt.timedelta(0, 7200), dt.timedelta(0, 3600), dt.timedelta(0, 3600) ] offset_utc = [ dt.timedelta(0, 0), dt.timedelta(0, 0), dt.timedelta(0, 0), dt.timedelta(0, 0), dt.timedelta(0, 0), dt.timedelta(0, 0), ] offset_none = [None, None, None, None, None, None] def test_expand_dt(self): vevent = _get_vevent(event_dt) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_berlin assert [start.utcoffset() for start, _ in dtstart] == self.offset_berlin assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin def test_expand_dtb(self): vevent = _get_vevent(event_dtb) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_berlin assert [start.utcoffset() for start, _ in dtstart] == self.offset_berlin assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin def test_expand_dttz(self): vevent = _get_vevent(event_dttz) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_utc assert [start.utcoffset() for start, _ in dtstart] == self.offset_utc assert [end.utcoffset() for _, end in dtstart] == self.offset_utc def test_expand_dtf(self): vevent = _get_vevent(event_dtf) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_float assert [start.utcoffset() for start, _ in dtstart] == self.offset_none assert [end.utcoffset() for _, end in dtstart] == self.offset_none def test_expand_d(self): vevent = _get_vevent(event_d) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dstartend def test_expand_dtz(self): vevent = _get_vevent(event_dtz) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dstartend def test_expand_dtzb(self): vevent = _get_vevent(event_dtzb) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dstartend def test_expand_invalid_exdate(self): """testing if we can expand an event with EXDATEs that do not much its RRULE""" vevent = _get_vevent_file('event_invalid_exdate') dtstartl = icalendar_helpers.expand(vevent, berlin) # TODO test for logging message assert dtstartl == [ (new_york.localize(dt.datetime(2011, 11, 12, 15, 50)), new_york.localize(dt.datetime(2011, 11, 12, 17, 0))), (new_york.localize(dt.datetime(2011, 11, 19, 15, 50)), new_york.localize(dt.datetime(2011, 11, 19, 17, 0))), (new_york.localize(dt.datetime(2011, 12, 3, 15, 50)), new_york.localize(dt.datetime(2011, 12, 3, 17, 0))), ] class TestExpandNoRR: dtstartend_berlin = [ (berlin.localize(dt.datetime(2013, 3, 1, 14, 0)), berlin.localize(dt.datetime(2013, 3, 1, 16, 0))), ] dtstartend_utc = [ (dt.datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc), dt.datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)), ] dtstartend_float = [ (dt.datetime(2013, 3, 1, 14, 0), dt.datetime(2013, 3, 1, 16, 0)), ] offset_berlin = [ dt.timedelta(0, 3600), ] offset_utc = [ dt.timedelta(0, 0), ] offset_none = [None] def test_expand_dt(self): vevent = _get_vevent(event_dt_norr) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_berlin assert [start.utcoffset() for start, _ in dtstart] == self.offset_berlin assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin def test_expand_dtb(self): vevent = _get_vevent(event_dtb_norr) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_berlin assert [start.utcoffset() for start, _ in dtstart] == self.offset_berlin assert [end.utcoffset() for _, end in dtstart] == self.offset_berlin def test_expand_dttz(self): vevent = _get_vevent(event_dttz_norr) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_utc assert [start.utcoffset() for start, _ in dtstart] == self.offset_utc assert [end.utcoffset() for _, end in dtstart] == self.offset_utc def test_expand_dtf(self): vevent = _get_vevent(event_dtf_norr) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == self.dtstartend_float assert [start.utcoffset() for start, _ in dtstart] == self.offset_none assert [end.utcoffset() for _, end in dtstart] == self.offset_none def test_expand_d(self): vevent = _get_vevent(event_d_norr) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == [ (dt.date(2013, 3, 1,), dt.date(2013, 3, 2,)), ] def test_expand_dtr_exdatez(self): """a recurring event with an EXDATE in Zulu time while DTSTART is localized""" vevent = _get_vevent_file('event_dtr_exdatez') dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 3 def test_expand_rrule_exdate_z(self): """event with not understood timezone for dtstart and zulu time form exdate """ vevent = _get_vevent_file('event_dtr_no_tz_exdatez') vevent = icalendar_helpers.sanitize(vevent, berlin, '', '') dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 5 dtstarts = [start for start, end in dtstart] assert dtstarts == [ berlin.localize(dt.datetime(2012, 4, 3, 10, 0)), berlin.localize(dt.datetime(2012, 5, 3, 10, 0)), berlin.localize(dt.datetime(2012, 7, 3, 10, 0)), berlin.localize(dt.datetime(2012, 8, 3, 10, 0)), berlin.localize(dt.datetime(2012, 9, 3, 10, 0)), ] def test_expand_rrule_notz_until_z(self): """event with not understood timezone for dtstart and zulu time form exdate """ vevent = _get_vevent_file('event_dtr_notz_untilz') vevent = icalendar_helpers.sanitize(vevent, new_york, '', '') dtstart = icalendar_helpers.expand(vevent, new_york) assert len(dtstart) == 7 dtstarts = [start for start, end in dtstart] assert dtstarts == [ new_york.localize(dt.datetime(2012, 7, 26, 13, 0)), new_york.localize(dt.datetime(2012, 8, 9, 13, 0)), new_york.localize(dt.datetime(2012, 8, 23, 13, 0)), new_york.localize(dt.datetime(2012, 9, 6, 13, 0)), new_york.localize(dt.datetime(2012, 9, 20, 13, 0)), new_york.localize(dt.datetime(2012, 10, 4, 13, 0)), new_york.localize(dt.datetime(2012, 10, 18, 13, 0)), ] vevent_until_notz = """BEGIN:VEVENT SUMMARY:until 20. Februar DTSTART;TZID=Europe/Berlin:20140203T070000 DTEND;TZID=Europe/Berlin:20140203T090000 UID:until_notz RRULE:FREQ=DAILY;UNTIL=20140220T060000Z;WKST=SU END:VEVENT """ vevent_count = """BEGIN:VEVENT SUMMARY:until 20. Februar DTSTART:20140203T070000 DTEND:20140203T090000 UID:until_notz RRULE:FREQ=DAILY;UNTIL=20140220T070000;WKST=SU END:VEVENT """ event_until_d_notz = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:d470ef6d08 DTSTART;VALUE=DATE:20140110 DURATION:P1D RRULE:FREQ=WEEKLY;UNTIL=20140215;INTERVAL=1;BYDAY=FR SUMMARY:Fri END:VEVENT END:VCALENDAR """ event_exdate_dt = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event_exdate_dt123 DTSTAMP:20140627T162546Z DTSTART;TZID=Europe/Berlin:20140702T190000 DTEND;TZID=Europe/Berlin:20140702T193000 SUMMARY:Test event RRULE:FREQ=DAILY;COUNT=10 EXDATE:20140703T190000 END:VEVENT END:VCALENDAR """ event_exdates_dt = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event_exdates_dt123 DTSTAMP:20140627T162546Z DTSTART;TZID=Europe/Berlin:20140702T190000 DTEND;TZID=Europe/Berlin:20140702T193000 SUMMARY:Test event RRULE:FREQ=DAILY;COUNT=10 EXDATE:20140703T190000 EXDATE:20140705T190000 END:VEVENT END:VCALENDAR """ event_exdatesl_dt = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:event_exdatesl_dt123 DTSTAMP:20140627T162546Z DTSTART;TZID=Europe/Berlin:20140702T190000 DTEND;TZID=Europe/Berlin:20140702T193000 SUMMARY:Test event RRULE:FREQ=DAILY;COUNT=10 EXDATE:20140703T190000 EXDATE:20140705T190000,20140707T190000 END:VEVENT END:VCALENDAR """ latest_bug = """BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT SUMMARY:Reformationstag RRULE:FREQ=YEARLY;BYMONTHDAY=31;BYMONTH=10 DTSTART;VALUE=DATE:20091031 DTEND;VALUE=DATE:20091101 END:VEVENT END:VCALENDAR """ recurrence_id_with_timezone = """BEGIN:VEVENT SUMMARY:PyCologne DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20131113T190000 DTEND;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20131113T210000 DTSTAMP:20130610T160635Z UID:another_problem RECURRENCE-ID;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20131113T190000 RRULE:FREQ=MONTHLY;BYDAY=2WE;WKST=SU TRANSP:OPAQUE END:VEVENT """ class TestSpecial: """collection of strange test cases that don't fit anywhere else really""" def test_count(self): vevent = _get_vevent(vevent_count) dtstart = icalendar_helpers.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 18 assert dtstart[0][0] == dt.datetime(2014, 2, 3, 7, 0) assert dtstart[-1][0] == dt.datetime(2014, 2, 20, 7, 0) def test_until_notz(self): vevent = _get_vevent(vevent_until_notz) dtstart = icalendar_helpers.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 18 assert dtstart[0][0] == berlin.localize( dt.datetime(2014, 2, 3, 7, 0)) assert dtstart[-1][0] == berlin.localize( dt.datetime(2014, 2, 20, 7, 0)) def test_until_d_notz(self): vevent = _get_vevent(event_until_d_notz) dtstart = icalendar_helpers.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 6 assert dtstart[0][0] == dt.date(2014, 1, 10) assert dtstart[-1][0] == dt.date(2014, 2, 14) def test_latest_bug(self): vevent = _get_vevent(latest_bug) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart[0][0] == dt.date(2009, 10, 31) assert dtstart[-1][0] == dt.date(2037, 10, 31) def test_recurrence_id_with_timezone(self): vevent = _get_vevent(recurrence_id_with_timezone) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 1 assert dtstart[0][0] == berlin.localize( dt.datetime(2013, 11, 13, 19, 0)) def test_event_exdate_dt(self): """recurring event, one date excluded via EXCLUDE""" vevent = _get_vevent(event_exdate_dt) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 9 assert dtstart[0][0] == berlin.localize( dt.datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( dt.datetime(2014, 7, 11, 19, 0)) def test_event_exdates_dt(self): """recurring event, two dates excluded via EXCLUDE""" vevent = _get_vevent(event_exdates_dt) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 8 assert dtstart[0][0] == berlin.localize( dt.datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( dt.datetime(2014, 7, 11, 19, 0)) def test_event_exdatesl_dt(self): """recurring event, three dates exclude via two EXCLUDEs""" vevent = _get_vevent(event_exdatesl_dt) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 7 assert dtstart[0][0] == berlin.localize( dt.datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( dt.datetime(2014, 7, 11, 19, 0)) def test_event_exdates_remove(self): """check if we can remove one more instance""" vevent = _get_vevent(event_exdatesl_dt) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 7 exdate1 = pytz.UTC.localize(dt.datetime(2014, 7, 11, 17, 0)) icalendar_helpers.delete_instance(vevent, exdate1) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 6 exdate2 = berlin.localize(dt.datetime(2014, 7, 9, 19, 0)) icalendar_helpers.delete_instance(vevent, exdate2) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 5 def test_event_dt_rrule_invalid_until(self): """DTSTART and RRULE:UNTIL should be of the same type, but might not be""" vevent = _get_vevent(_get_text('event_dt_rrule_invalid_until')) dtstart = icalendar_helpers.expand(vevent, berlin) assert dtstart == [(dt.date(2007, 12, 1), dt.date(2007, 12, 2)), (dt.date(2008, 1, 1), dt.date(2008, 1, 2)), (dt.date(2008, 2, 1), dt.date(2008, 2, 2))] def test_event_dt_rrule_invalid_until2(self): """same as above, but now dtstart is of type date and until is datetime """ vevent = _get_vevent(_get_text('event_dt_rrule_invalid_until2')) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 35 assert dtstart[0] == (berlin.localize(dt.datetime(2014, 4, 9, 9, 30)), berlin.localize(dt.datetime(2014, 4, 9, 10, 30))) assert dtstart[-1] == (berlin.localize(dt.datetime(2014, 12, 3, 9, 30)), berlin.localize(dt.datetime(2014, 12, 3, 10, 30))) def test_event_dt_rrule_until_before_start(self): """test handling if an RRULE's UNTIL is before the event's DTSTART""" vevent = _get_vevent(_get_text('event_dt_rrule_until_before_start')) dtstart = icalendar_helpers.expand(vevent, berlin) # TODO test for logging message assert dtstart is None def test_event_invalid_rrule(self): """test handling if an event with RRULE will never occur""" vevent = _get_vevent(_get_text('event_rrule_no_occurence')) dtstart = icalendar_helpers.expand(vevent, berlin) # TODO test for logging message assert dtstart is None simple_rdate = """BEGIN:VEVENT SUMMARY:Simple Rdate DTSTART;TZID=Europe/Berlin:20131113T190000 DTEND;TZID=Europe/Berlin:20131113T210000 UID:simple_rdate RDATE:20131213T190000 RDATE:20140113T190000,20140213T190000 END:VEVENT """ rrule_and_rdate = """BEGIN:VEVENT SUMMARY:Datetime Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T140000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20130301T160000 RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=6 RDATE:20131213T190000 UID:datetime123 END:VEVENT""" class TestRDate: """Testing expanding of recurrence rules""" def test_simple_rdate(self): vevent = _get_vevent(simple_rdate) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 4 def test_rrule_and_rdate(self): vevent = _get_vevent(rrule_and_rdate) dtstart = icalendar_helpers.expand(vevent, berlin) assert len(dtstart) == 7 def test_rrule_past(self): vevent = _get_vevent_file('event_r_past') assert vevent is not None dtstarts = icalendar_helpers.expand(vevent, berlin) assert len(dtstarts) == 73 assert dtstarts[0][0] == dt.date(1965, 4, 23) assert dtstarts[-1][0] == dt.date(2037, 4, 23) def test_rdate_date(self): vevent = _get_vevent_file('event_d_rdate') dtstarts = icalendar_helpers.expand(vevent, berlin) assert len(dtstarts) == 4 assert dtstarts == [(dt.date(2015, 8, 12), dt.date(2015, 8, 13)), (dt.date(2015, 8, 13), dt.date(2015, 8, 14)), (dt.date(2015, 8, 14), dt.date(2015, 8, 15)), (dt.date(2015, 8, 15), dt.date(2015, 8, 16))] noend_date = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:noend123 DTSTART;VALUE=DATE:20140829 SUMMARY:No DTEND END:VEVENT END:VCALENDAR """ noend_datetime = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:noend123 DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140829T080000 SUMMARY:No DTEND END:VEVENT END:VCALENDAR """ instant = """ BEGIN:VCALENDAR BEGIN:VEVENT UID:instant123 DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20170113T010000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20170113T010000 SUMMARY:Really fast event END:VEVENT END:VCALENDAR """ class TestSanitize: def test_noend_date(self): vevent = _get_vevent(noend_date) vevent = icalendar_helpers.sanitize(vevent, berlin, '', '') assert vevent['DTSTART'].dt == dt.date(2014, 8, 29) assert vevent['DTEND'].dt == dt.date(2014, 8, 30) def test_noend_datetime(self): vevent = _get_vevent(noend_datetime) vevent = icalendar_helpers.sanitize(vevent, berlin, '', '') assert vevent['DTSTART'].dt == BERLIN.localize(dt.datetime(2014, 8, 29, 8)) assert vevent['DTEND'].dt == BERLIN.localize(dt.datetime(2014, 8, 29, 9)) def test_duration(self): vevent = _get_vevent_file('event_dtr_exdatez') vevent = icalendar_helpers.sanitize(vevent, berlin, '', '') def test_instant(self): vevent = _get_vevent(instant) assert vevent['DTEND'].dt - vevent['DTSTART'].dt == dt.timedelta() vevent = icalendar_helpers.sanitize(vevent, berlin, '', '') assert vevent['DTEND'].dt - vevent['DTSTART'].dt == dt.timedelta(hours=1) class TestIsAware: def test_naive(self): assert utils.is_aware(dt.datetime.now()) is False def test_berlin(self): assert utils.is_aware(BERLIN.localize(dt.datetime.now())) is True def test_bogota(self): assert utils.is_aware(BOGOTA.localize(dt.datetime.now())) is True def test_utc(self): assert utils.is_aware(pytz.UTC.localize(dt.datetime.now())) is True khal-0.11.4/tests/parse_datetime_test.py000066400000000000000000000536201477603436700203040ustar00rootroot00000000000000import datetime as dt from collections import OrderedDict import pytest from freezegun import freeze_time from khal.exceptions import DateTimeParseError, FatalError from khal.icalendar import new_vevent from khal.parse_datetime import ( construct_daynames, eventinfofstr, guessdatetimefstr, guessrangefstr, guesstimedeltafstr, timedelta2str, weekdaypstr, ) from .utils import ( LOCALE_BERLIN, LOCALE_FLOATING, LOCALE_NEW_YORK, _replace_uid, normalize_component, ) def _create_testcases(*cases): return [(userinput, ('\r\n'.join(output) + '\r\n').encode('utf-8')) for userinput, output in cases] def _construct_event(info, locale, defaulttimelen=60, defaultdatelen=1, description=None, location=None, categories=None, repeat=None, until=None, alarm=None, **kwargs): info = eventinfofstr(' '.join(info), locale, default_event_duration=dt.timedelta(hours=1), default_dayevent_duration=dt.timedelta(days=1), adjust_reasonably=True, ) if description is not None: info["description"] = description event = new_vevent( locale=locale, location=location, categories=categories, repeat=repeat, until=until, alarms=alarm, **info) return event def _create_vevent(*args): """ Adapt and return a default vevent for testing. Accepts an arbitrary amount of strings like 'DTSTART;VALUE=DATE:2013015'. Updates the default vevent if the key (the first word) is found and appends the value otherwise. """ def_vevent = OrderedDict( [('BEGIN', 'BEGIN:VEVENT'), ('SUMMARY', 'SUMMARY:Äwesöme Event'), ('DTSTART', 'DTSTART;VALUE=DATE:20131025'), ('DTEND', 'DTEND;VALUE=DATE:20131026'), ('DTSTAMP', 'DTSTAMP:20140216T120000Z'), ('UID', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA')]) for row in args: key = row.replace(':', ';').split(';')[0] def_vevent[key] = row def_vevent['END'] = 'END:VEVENT' return list(def_vevent.values()) class TestTimeDelta2Str: def test_single(self): assert timedelta2str(dt.timedelta(minutes=10)) == '10m' def test_negative(self): assert timedelta2str(dt.timedelta(minutes=-10)) == '-10m' def test_days(self): assert timedelta2str(dt.timedelta(days=2)) == '2d' def test_multi(self): assert timedelta2str( dt.timedelta(days=6, hours=-3, minutes=10, seconds=-3) ) == '5d 21h 9m 57s' def test_weekdaypstr(): for string, weekdayno in [ ('monday', 0), ('tue', 1), ('wednesday', 2), ('thursday', 3), ('fri', 4), ('saturday', 5), ('sun', 6), ]: assert weekdaypstr(string) == weekdayno def test_weekdaypstr_invalid(): with pytest.raises(ValueError): weekdaypstr('foobar') def test_construct_daynames(): with freeze_time('2016-9-19'): assert construct_daynames(dt.date(2016, 9, 19)) == 'Today' assert construct_daynames(dt.date(2016, 9, 20)) == 'Tomorrow' assert construct_daynames(dt.date(2016, 9, 21)) == 'Wednesday' class TestGuessDatetimefstr: @freeze_time('2016-9-19T8:00') def test_today(self): assert (dt.datetime(2016, 9, 19, 13), False) == \ guessdatetimefstr(['today', '13:00'], LOCALE_BERLIN) assert dt.date.today() == guessdatetimefstr(['today'], LOCALE_BERLIN)[0].date() @freeze_time('2016-9-19T8:00') def test_tomorrow(self): assert (dt.datetime(2016, 9, 20, 16), False) == \ guessdatetimefstr('tomorrow 16:00 16:00'.split(), locale=LOCALE_BERLIN) @freeze_time('2016-9-19T8:00') def test_time_tomorrow(self): assert (dt.datetime(2016, 9, 20, 16), False) == \ guessdatetimefstr( '16:00'.split(), locale=LOCALE_BERLIN, default_day=dt.date(2016, 9, 20)) @freeze_time('2016-9-19T8:00') def test_time_yesterday(self): assert (dt.datetime(2016, 9, 18, 16), False) == guessdatetimefstr( 'Yesterday 16:00'.split(), locale=LOCALE_BERLIN, default_day=dt.datetime.today()) @freeze_time('2016-9-19') def test_time_weekday(self): assert (dt.datetime(2016, 9, 23, 16), False) == guessdatetimefstr( 'Friday 16:00'.split(), locale=LOCALE_BERLIN, default_day=dt.datetime.today()) @freeze_time('2016-9-19 17:53') def test_time_now(self): assert (dt.datetime(2016, 9, 19, 17, 53), False) == guessdatetimefstr( 'now'.split(), locale=LOCALE_BERLIN, default_day=dt.datetime.today()) @freeze_time('2016-12-30 17:53') def test_long_not_configured(self): """long version is not configured, but short contains the year""" locale = { 'timeformat': '%H:%M', 'dateformat': '%Y-%m-%d', 'longdateformat': '', 'datetimeformat': '%Y-%m-%d %H:%M', 'longdatetimeformat': '', } assert (dt.datetime(2017, 1, 1), True) == guessdatetimefstr( '2017-1-1'.split(), locale=locale, default_day=dt.datetime.today()) assert (dt.datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr( '2017-1-1 16:30'.split(), locale=locale, default_day=dt.datetime.today()) @freeze_time('2016-12-30 17:53') def test_short_format_contains_year(self): """if the non long versions of date(time)format contained a year, the current year would be used instead of the given one, see #545""" locale = { 'timeformat': '%H:%M', 'dateformat': '%Y-%m-%d', 'longdateformat': '%Y-%m-%d', 'datetimeformat': '%Y-%m-%d %H:%M', 'longdatetimeformat': '%Y-%m-%d %H:%M', } assert (dt.datetime(2017, 1, 1), True) == guessdatetimefstr( '2017-1-1'.split(), locale=locale, default_day=dt.datetime.today()) assert (dt.datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr( '2017-1-1 16:30'.split(), locale=locale, default_day=dt.datetime.today()) class TestGuessTimedeltafstr: def test_single(self): assert dt.timedelta(minutes=10) == guesstimedeltafstr('10m') def test_seconds(self): assert dt.timedelta(seconds=10) == guesstimedeltafstr('10s') def test_single_plus(self): assert dt.timedelta(minutes=10) == guesstimedeltafstr('+10m') def test_seconds_plus(self): assert dt.timedelta(seconds=10) == guesstimedeltafstr('+10s') def test_days_plus(self): assert dt.timedelta(days=10) == guesstimedeltafstr('+10days') def test_negative(self): assert dt.timedelta(minutes=-10) == guesstimedeltafstr('-10m') def test_multi(self): assert dt.timedelta(days=1, hours=-3, minutes=10) == \ guesstimedeltafstr(' 1d -3H 10min ') def test_multi_plus(self): assert dt.timedelta(days=1, hours=3, minutes=10) == \ guesstimedeltafstr(' 1d +3H 10min ') def test_multi_plus_minus(self): assert dt.timedelta(days=0, hours=21, minutes=10) == \ guesstimedeltafstr('+1d -3H 10min ') def test_multi_nospace(self): assert dt.timedelta(days=1, hours=-3, minutes=10) == \ guesstimedeltafstr('1D-3hour10m') def test_garbage(self): with pytest.raises(ValueError): guesstimedeltafstr('10mbar') def test_moregarbage(self): with pytest.raises(ValueError): guesstimedeltafstr('foo10m') def test_same(self): assert dt.timedelta(minutes=20) == \ guesstimedeltafstr('10min 10minutes') class TestGuessRangefstr: @freeze_time('2016-9-19') def test_today(self): assert (dt.datetime(2016, 9, 19, 13), dt.datetime(2016, 9, 19, 14), False) == \ guessrangefstr('13:00 14:00', locale=LOCALE_BERLIN) assert (dt.datetime(2016, 9, 19), dt.datetime(2016, 9, 21), True) == \ guessrangefstr('today tomorrow', LOCALE_BERLIN) @freeze_time('2016-9-19 16:34') def test_tomorrow(self): # XXX remove this funtionality, we shouldn't support this anyway assert (dt.datetime(2016, 9, 19), dt.datetime(2016, 9, 21, 16), True) == \ guessrangefstr('today tomorrow 16:00', locale=LOCALE_BERLIN) @freeze_time('2016-9-19 13:34') def test_time_tomorrow(self): assert (dt.datetime(2016, 9, 19, 16), dt.datetime(2016, 9, 19, 17), False) == \ guessrangefstr('16:00', locale=LOCALE_BERLIN) assert (dt.datetime(2016, 9, 19, 16), dt.datetime(2016, 9, 19, 17), False) == \ guessrangefstr('16:00 17:00', locale=LOCALE_BERLIN) def test_start_and_end_date(self): assert (dt.datetime(2016, 1, 1), dt.datetime(2017, 1, 2), True) == \ guessrangefstr('1.1.2016 1.1.2017', locale=LOCALE_BERLIN) def test_start_and_no_end_date(self): assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 2), True) == \ guessrangefstr('1.1.2016', locale=LOCALE_BERLIN) def test_start_and_end_date_time(self): assert (dt.datetime(2016, 1, 1, 10), dt.datetime(2017, 1, 1, 22), False) == \ guessrangefstr( '1.1.2016 10:00 1.1.2017 22:00', locale=LOCALE_BERLIN) def test_start_and_eod(self): start, end = dt.datetime(2016, 1, 1, 10), dt.datetime(2016, 1, 1, 23, 59, 59, 999999) assert (start, end, False) == guessrangefstr('1.1.2016 10:00 eod', locale=LOCALE_BERLIN) def test_start_and_week(self): assert (dt.datetime(2015, 12, 28), dt.datetime(2016, 1, 5), True) == \ guessrangefstr('1.1.2016 week', locale=LOCALE_BERLIN) def test_start_and_delta_1d(self): assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 2), True) == \ guessrangefstr('1.1.2016 1d', locale=LOCALE_BERLIN) def test_start_and_delta_3d(self): assert (dt.datetime(2016, 1, 1), dt.datetime(2016, 1, 4), True) == \ guessrangefstr('1.1.2016 3d', locale=LOCALE_BERLIN) def test_start_dt_and_delta(self): assert (dt.datetime(2016, 1, 1, 10), dt.datetime(2016, 1, 4, 10), False) == \ guessrangefstr('1.1.2016 10:00 3d', locale=LOCALE_BERLIN) def test_start_allday_and_delta_datetime(self): with pytest.raises(FatalError): guessrangefstr('1.1.2016 3d3m', locale=LOCALE_BERLIN) def test_start_zero_day_delta(self): with pytest.raises(FatalError): guessrangefstr('1.1.2016 0d', locale=LOCALE_BERLIN) @freeze_time('20160216') def test_week(self): assert (dt.datetime(2016, 2, 15), dt.datetime(2016, 2, 23), True) == \ guessrangefstr('week', locale=LOCALE_BERLIN) def test_invalid(self): with pytest.raises(DateTimeParseError): guessrangefstr('3d', locale=LOCALE_BERLIN) with pytest.raises(DateTimeParseError): guessrangefstr('35.1.2016', locale=LOCALE_BERLIN) with pytest.raises(DateTimeParseError): guessrangefstr('1.1.2016 2x', locale=LOCALE_BERLIN) with pytest.raises(DateTimeParseError): guessrangefstr('1.1.2016x', locale=LOCALE_BERLIN) with pytest.raises(DateTimeParseError): guessrangefstr('xxx yyy zzz', locale=LOCALE_BERLIN) @freeze_time('2016-12-30 17:53') def test_short_format_contains_year(self): """if the non long versions of date(time)format contained a year, the current year would be used instead of the given one, see #545 same as above, but for guessrangefstr """ locale = { 'timeformat': '%H:%M', 'dateformat': '%Y-%m-%d', 'longdateformat': '%Y-%m-%d', 'datetimeformat': '%Y-%m-%d %H:%M', 'longdatetimeformat': '%Y-%m-%d %H:%M', } assert (dt.datetime(2017, 1, 1), dt.datetime(2017, 1, 2), True) == \ guessrangefstr('2017-1-1 2017-1-1', locale=locale) test_set_format_de = _create_testcases( # all-day-events # one day only ('25.10.2013 Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20131025', 'DTEND;VALUE=DATE:20131026')), # 2 day ('15.08.2014 16.08. Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20140815', 'DTEND;VALUE=DATE:20140817')), # XXX # end date in next year and not specified ('29.12.2014 03.01. Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20141229', 'DTEND;VALUE=DATE:20150104')), # end date in next year ('29.12.2014 03.01.2015 Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20141229', 'DTEND;VALUE=DATE:20150104')), # datetime events # start and end date same, no explicit end date given ('25.10.2013 18:00 20:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131025T180000', 'DTEND;TZID=Europe/Berlin:20131025T200000')), # start and end date same, ends 24:00 which should be 00:00 (start) of next # day ('25.10.2013 18:00 24:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131025T180000', 'DTEND;TZID=Europe/Berlin:20131026T000000')), # start and end date same, explicit end date (but no year) given ('25.10.2013 18:00 26.10. 20:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131025T180000', 'DTEND;TZID=Europe/Berlin:20131026T200000')), ('30.12.2013 18:00 2.1. 20:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131230T180000', 'DTEND;TZID=Europe/Berlin:20140102T200000')), # only start date given (no year, past day and month) ('25.01. 18:00 20:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20150125T180000', 'DTEND;TZID=Europe/Berlin:20150125T200000')), # date ends next day, but end date not given ('25.10.2013 23:00 0:30 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131025T230000', 'DTEND;TZID=Europe/Berlin:20131026T003000')), ('2.2. 23:00 0:30 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20150202T230000', 'DTEND;TZID=Europe/Berlin:20150203T003000')), # only start datetime given ('25.10.2013 06:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20131025T060000', 'DTEND;TZID=Europe/Berlin:20131025T070000')), # timezone given ('25.10.2013 06:00 America/New_York Äwesöme Event', _create_vevent( 'DTSTART;TZID=America/New_York:20131025T060000', 'DTEND;TZID=America/New_York:20131025T070000')) ) @freeze_time('20140216T120000') def test_construct_event_format_de(): for data_list, vevent_expected in test_set_format_de: vevent = _construct_event(data_list.split(), locale=LOCALE_BERLIN) assert _replace_uid(vevent).to_ical() == vevent_expected test_set_format_us = _create_testcases( ('1999/12/31-06:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=America/New_York:19991231T060000', 'DTEND;TZID=America/New_York:19991231T070000')), ('2014/12/18 2014/12/20 Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20141218', 'DTEND;VALUE=DATE:20141221')), ) @freeze_time('2014-02-16 12:00:00') def test__construct_event_format_us(): for data_list, vevent in test_set_format_us: event = _construct_event(data_list.split(), locale=LOCALE_NEW_YORK) assert _replace_uid(event).to_ical() == vevent test_set_format_de_complexer = _create_testcases( # now events where the start date has to be inferred, too # today ('8:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T080000', 'DTEND;TZID=Europe/Berlin:20140216T090000')), # today until tomorrow ('22:00 1:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T220000', 'DTEND;TZID=Europe/Berlin:20140217T010000')), # other timezone ('22:00 1:00 Europe/London Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/London:20140216T220000', 'DTEND;TZID=Europe/London:20140217T010000')), ('15.06. Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20140615', 'DTEND;VALUE=DATE:20140616')), ) @freeze_time('2014-02-16 12:00:00') def test__construct_event_format_de_complexer(): for data_list, vevent in test_set_format_de_complexer: event = _construct_event(data_list.split(), locale=LOCALE_BERLIN) assert _replace_uid(event).to_ical() == vevent test_set_leap_year = _create_testcases( ('29.02. Äwesöme Event', _create_vevent( 'DTSTART;VALUE=DATE:20160229', 'DTEND;VALUE=DATE:20160301', 'DTSTAMP:20160101T202122Z')), ) def test_leap_year(): for data_list, vevent in test_set_leap_year: with freeze_time('1999-1-1'): with pytest.raises(DateTimeParseError): event = _construct_event(data_list.split(), locale=LOCALE_BERLIN) with freeze_time('2016-1-1 20:21:22'): event = _construct_event(data_list.split(), locale=LOCALE_BERLIN) assert _replace_uid(event).to_ical() == vevent test_set_description = _create_testcases( # now events where the start date has to be inferred, too # today ('8:00 Äwesöme Event :: this is going to be awesome', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T080000', 'DTEND;TZID=Europe/Berlin:20140216T090000', 'DESCRIPTION:this is going to be awesome')), # today until tomorrow ('22:00 1:00 Äwesöme Event :: Will be even better', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T220000', 'DTEND;TZID=Europe/Berlin:20140217T010000', 'DESCRIPTION:Will be even better')), ('15.06. Äwesöme Event :: and again', _create_vevent('DTSTART;VALUE=DATE:20140615', 'DTEND;VALUE=DATE:20140616', 'DESCRIPTION:and again')), ) def test_description(): for data_list, vevent in test_set_description: with freeze_time('2014-02-16 12:00:00'): event = _construct_event(data_list.split(), locale=LOCALE_BERLIN) assert _replace_uid(event).to_ical() == vevent test_set_repeat_floating = _create_testcases( # now events where the start date has to be inferred, too # today ('8:00 Äwesöme Event', _create_vevent( 'DTSTART:20140216T080000', 'DTEND:20140216T090000', 'DESCRIPTION:please describe the event', 'RRULE:FREQ=DAILY;UNTIL=20150604T000000'))) def test_repeat_floating(): for data_list, vevent in test_set_repeat_floating: with freeze_time('2014-02-16 12:00:00'): event = _construct_event(data_list.split(), description='please describe the event', repeat='daily', until='04.06.2015', locale=LOCALE_FLOATING) assert normalize_component(_replace_uid(event).to_ical()) == \ normalize_component(vevent) test_set_repeat_localized = _create_testcases( # now events where the start date has to be inferred, too # today ('8:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T080000', 'DTEND;TZID=Europe/Berlin:20140216T090000', 'DESCRIPTION:please describe the event', 'RRULE:FREQ=DAILY;UNTIL=20150604T230000Z'))) def test_repeat_localized(): for data_list, vevent in test_set_repeat_localized: with freeze_time('2014-02-16 12:00:00'): event = _construct_event(data_list.split(), description='please describe the event', repeat='daily', until='05.06.2015', locale=LOCALE_BERLIN) assert normalize_component(_replace_uid(event).to_ical()) == \ normalize_component(vevent) test_set_alarm = _create_testcases( ('8:00 Äwesöme Event', ['BEGIN:VEVENT', 'SUMMARY:Äwesöme Event', 'DTSTART;TZID=Europe/Berlin:20140216T080000', 'DTEND;TZID=Europe/Berlin:20140216T090000', 'DTSTAMP:20140216T120000Z', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA', 'DESCRIPTION:please describe the event', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:please describe the event', 'TRIGGER:-PT23M', 'END:VALARM', 'END:VEVENT'])) @freeze_time('2014-02-16 12:00:00') def test_alarm(): for data_list, vevent in test_set_alarm: event = _construct_event(data_list.split(), description='please describe the event', alarm='23m', locale=LOCALE_BERLIN) assert _replace_uid(event).to_ical() == vevent test_set_description_and_location_and_categories = _create_testcases( # now events where the start date has to be inferred, too # today ('8:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin:20140216T080000', 'DTEND;TZID=Europe/Berlin:20140216T090000', 'CATEGORIES:boring meeting', 'DESCRIPTION:please describe the event', 'LOCATION:in the office'))) @freeze_time('2014-02-16 12:00:00') def test_description_and_location_and_categories(): for data_list, vevent in test_set_description_and_location_and_categories: event = _construct_event(data_list.split(), description='please describe the event', location='in the office', categories=['boring meeting'], locale=LOCALE_BERLIN) assert _replace_uid(event).to_ical() == vevent khal-0.11.4/tests/settings_test.py000066400000000000000000000242161477603436700171550ustar00rootroot00000000000000import datetime as dt import os.path import pytest from tzlocal import get_localzone as _get_localzone from khal.settings import get_config from khal.settings.exceptions import CannotParseConfigFileError, InvalidSettingsError from khal.settings.utils import ( config_checks, get_all_vdirs, get_color_from_vdir, get_unique_name, is_color, ) try: # Available from configobj 5.1.0 from configobj.validate import VdtValueError except ModuleNotFoundError: from validate import VdtValueError from .utils import LOCALE_BERLIN PATH = __file__.rsplit('/', 1)[0] + '/configs/' def get_localzone(): # this reproduces the code in settings.util for the time being import pytz return pytz.timezone(str(_get_localzone())) class TestSettings: def test_simple_config(self): config = get_config( PATH + 'simple.conf', _get_color_from_vdir=lambda x: None, _get_vdir_type=lambda x: 'calendar', ) comp_config = { 'calendars': { 'home': { 'path': os.path.expanduser('~/.calendars/home/'), 'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''], }, 'work': { 'path': os.path.expanduser('~/.calendars/work/'), 'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''], }, }, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, 'locale': LOCALE_BERLIN, 'default': { 'default_calendar': None, 'print_new': 'False', 'highlight_event_days': False, 'timedelta': dt.timedelta(days=2), 'default_event_duration': dt.timedelta(hours=1), 'default_dayevent_duration': dt.timedelta(days=1), 'default_event_alarm': dt.timedelta(0), 'default_dayevent_alarm': dt.timedelta(0), 'show_all_days': False, 'enable_mouse': True, } } for key in comp_config: assert config[key] == comp_config[key] def test_nocalendars(self): with pytest.raises(InvalidSettingsError): get_config(PATH + 'nocalendars.conf') def test_one_level_calendar(self): with pytest.raises(InvalidSettingsError): get_config(PATH + 'one_level_calendars.conf') def test_small(self): config = get_config( PATH + 'small.conf', _get_color_from_vdir=lambda x: None, _get_vdir_type=lambda x: 'calendar', ) comp_config = { 'calendars': { 'home': {'path': os.path.expanduser('~/.calendars/home/'), 'color': 'dark green', 'readonly': False, 'priority': 20, 'type': 'calendar', 'addresses': ['']}, 'work': {'path': os.path.expanduser('~/.calendars/work/'), 'readonly': True, 'color': None, 'priority': 10, 'type': 'calendar', 'addresses': ['user@example.com']}}, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, 'locale': { 'local_timezone': get_localzone(), 'default_timezone': get_localzone(), 'timeformat': '%X', 'dateformat': '%x', 'longdateformat': '%x', 'datetimeformat': '%c', 'longdatetimeformat': '%c', 'firstweekday': 0, 'unicode_symbols': True, 'weeknumbers': False, }, 'default': { 'default_calendar': None, 'print_new': 'False', 'highlight_event_days': False, 'timedelta': dt.timedelta(days=2), 'default_event_duration': dt.timedelta(hours=1), 'default_dayevent_duration': dt.timedelta(days=1), 'show_all_days': False, 'enable_mouse': True, 'default_event_alarm': dt.timedelta(0), 'default_dayevent_alarm': dt.timedelta(0), } } for key in comp_config: assert config[key] == comp_config[key] def test_old_config(self, tmpdir): old_config = """ [Calendar home] path: ~/.khal/calendars/home/ color: dark blue [sqlite] path: ~/.khal/khal.db [locale] timeformat: %H:%M dateformat: %d.%m. longdateformat: %d.%m.%Y [default] """ conf_path = str(tmpdir.join('old.conf')) with open(conf_path, 'w+') as conf: conf.write(old_config) with pytest.raises(CannotParseConfigFileError): get_config(conf_path) def test_extra_sections(self, tmpdir): config = """ [calendars] [[home]] path = ~/.khal/calendars/home/ color = dark blue unknown = 42 [unknownsection] foo = bar """ conf_path = str(tmpdir.join('old.conf')) with open(conf_path, 'w+') as conf: conf.write(config) get_config(conf_path) # FIXME test for log entries def test_default_calendar_readonly(self, tmpdir): config = """ [calendars] [[home]] path = ~/.khal/calendars/home/ color = dark blue readonly = True [default] default_calendar = home """ conf_path = str(tmpdir.join('old.conf')) with open(conf_path, 'w+') as conf: conf.write(config) with pytest.raises(InvalidSettingsError): config_checks(get_config(conf_path)) def test_broken_color(metavdirs): path = metavdirs newvdir = path + '/cal5/' os.makedirs(newvdir) with open(newvdir + 'color', 'w') as metafile: metafile.write('xxx') assert get_color_from_vdir(newvdir) is None def test_discover(metavdirs): test_vdirs = { '/cal1/public', '/cal1/private', '/cal2/public', '/cal3/home', '/cal3/public', '/cal3/work', '/cal4/cfgcolor', '/cal4/dircolor', '/cal4/cfgcolor_again', '/cal4/cfgcolor_once_more', '/singlecollection', } path = metavdirs assert test_vdirs == {vdir[len(path):] for vdir in get_all_vdirs(path + '/**/*/')} assert test_vdirs == {vdir[len(path):] for vdir in get_all_vdirs(path + '/**/')} assert test_vdirs == {vdir[len(path):] for vdir in get_all_vdirs(path + '/**/*')} def test_get_unique_name(metavdirs): path = metavdirs vdirs = list(get_all_vdirs(path + '/**/')) names = [] for vdir in sorted(vdirs): names.append(get_unique_name(vdir, names)) assert sorted(names) == sorted([ 'my private calendar', 'my calendar', 'public', 'home', 'public1', 'work', 'cfgcolor', 'cfgcolor_again', 'cfgcolor_once_more', 'dircolor', 'singlecollection', ]) def test_config_checks(metavdirs): path = metavdirs config = { 'calendars': { 'default': {'path': path + '/cal[1-3]/*', 'type': 'discover'}, 'calendars_color': {'path': path + '/cal4/*', 'type': 'discover', 'color': 'dark blue'}, }, 'sqlite': {'path': '/tmp'}, 'locale': {'default_timezone': 'Europe/Berlin', 'local_timezone': 'Europe/Berlin'}, 'default': {'default_calendar': None}, } config_checks(config) # cut off the part of the path that changes on each run for cal in config['calendars']: config['calendars'][cal]['path'] = config['calendars'][cal]['path'][len(metavdirs):] test_config = { 'calendars': { 'home': { 'color': None, 'path': '/cal3/home', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'my calendar': { 'color': 'dark blue', 'path': '/cal1/public', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'my private calendar': { 'color': '#FF00FF', 'path': '/cal1/private', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'public1': { 'color': None, 'path': '/cal3/public', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'public': { 'color': None, 'path': '/cal2/public', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'work': { 'color': None, 'path': '/cal3/work', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'cfgcolor': { 'color': 'dark blue', 'path': '/cal4/cfgcolor', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'dircolor': { 'color': 'dark blue', 'path': '/cal4/dircolor', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'cfgcolor_again': { 'color': 'dark blue', 'path': '/cal4/cfgcolor_again', 'readonly': False, 'type': 'calendar', 'priority': 10, }, 'cfgcolor_once_more': { 'color': 'dark blue', 'path': '/cal4/cfgcolor_once_more', 'readonly': False, 'type': 'calendar', 'priority': 10, }, }, 'default': {'default_calendar': None}, 'locale': {'default_timezone': 'Europe/Berlin', 'local_timezone': 'Europe/Berlin'}, 'sqlite': {'path': '/tmp'}, } assert config['calendars'] == test_config['calendars'] assert config == test_config def test_is_color(): assert is_color('dark blue') == 'dark blue' assert is_color('#123456') == '#123456' assert is_color('123') == '123' with pytest.raises(VdtValueError): assert is_color('red') == 'red' khal-0.11.4/tests/terminal_test.py000066400000000000000000000026061477603436700171270ustar00rootroot00000000000000from khal.terminal import colored, merge_columns def test_colored(): assert colored('test', 'light cyan') == '\33[1;36mtest\x1b[0m' assert colored('täst', 'white') == '\33[37mtäst\x1b[0m' assert colored('täst', 'white', 'dark green') == '\x1b[37m\x1b[42mtäst\x1b[0m' assert colored('täst', 'light magenta', 'dark green', True) == '\x1b[1;35m\x1b[42mtäst\x1b[0m' assert colored('täst', 'light magenta', 'dark green', False) == '\x1b[95m\x1b[42mtäst\x1b[0m' assert colored('täst', 'light magenta', 'light green', True) == '\x1b[1;35m\x1b[42mtäst\x1b[0m' assert colored('täst', 'light magenta', 'light green', False) == '\x1b[95m\x1b[102mtäst\x1b[0m' assert colored('täst', '5', '20') == '\x1b[38;5;5m\x1b[48;5;20mtäst\x1b[0m' assert colored('täst', '#F0F', '#00AABB') == \ '\x1b[38;2;255;0;255m\x1b[48;2;0;170;187mtäst\x1b[0m' class TestMergeColumns: def test_longer_right(self): left = ['uiae', 'nrtd'] right = ['123456', '234567', '345678'] out = ['uiae 123456', 'nrtd 234567', ' 345678'] assert merge_columns(left, right, width=4) == out def test_longer_left(self): left = ['uiae', 'nrtd', 'xvlc'] right = ['123456', '234567'] out = ['uiae 123456', 'nrtd 234567', 'xvlc '] assert merge_columns(left, right, width=4) == out khal-0.11.4/tests/ui/000077500000000000000000000000001477603436700143145ustar00rootroot00000000000000khal-0.11.4/tests/ui/__init__.py000066400000000000000000000000001477603436700164130ustar00rootroot00000000000000khal-0.11.4/tests/ui/canvas_render.py000066400000000000000000000036431477603436700175060ustar00rootroot00000000000000from __future__ import annotations import io import click class CanvasTranslator: """Translates a canvas object into a printable string.""" def __init__(self, canvas, palette: dict[str, str] | None = None) -> None: """currently only support foreground colors, so palette is a dictionary of attributes and foreground colors""" self._canvas = canvas self._palette : dict[str, tuple[bool, str]]= {} if palette: for key, color in palette.items(): self.add_color(key, color) def add_color(self, key: str, color: str) -> None: if color.startswith('#'): # RGB colour r = color[1:3] g = color[3:5] b = color[5:8] rgb = int(r, 16), int(g, 16), int(b, 16) value = True, '\33[38;2;{!s};{!s};{!s}m'.format(*rgb) else: color = color.split(' ')[-1] if color == 'gray': color = 'white' # click will insist on US-english value = False, color self._palette[key] = value # (is_ansi, color) def transform(self) -> str: self.output = io.StringIO() for row in self._canvas.content(): # self.spaces = 0 for col in row[:-1]: self._process_char(*col) # the last column has all the trailing whitespace, which deforms # everything if the terminal is resized: col = row[-1] self._process_char(col[0], col[1], col[2].rstrip()) self.output.write('\n') return self.output.getvalue() def _process_char(self, fmt, _, b): text = b.decode() if not fmt: self.output.write(text) else: fmt = self._palette[fmt] if fmt[0]: self.output.write(f'{fmt[1]}{click.style(text)}') else: self.output.write(click.style(text, fg=fmt[1])) khal-0.11.4/tests/ui/test_calendarwidget.py000066400000000000000000000054641477603436700207130ustar00rootroot00000000000000import datetime as dt from freezegun import freeze_time from khal.ui.calendarwidget import CalendarWidget on_press: dict = {} keybindings = { 'today': ['T'], 'left': ['left', 'h', 'backspace'], 'up': ['up', 'k'], 'right': ['right', 'l', ' '], 'down': ['down', 'j'], } def test_initial_focus_today(): today = dt.date.today() frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, weeknumbers='right') assert frame.focus_date == today def test_set_focus_date(): today = dt.date.today() for diff in range(-10, 10, 1): frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, weeknumbers='right') day = today + dt.timedelta(days=diff) frame.set_focus_date(day) assert frame.focus_date == day def test_set_focus_date_weekstart_6(): with freeze_time('2016-04-10'): today = dt.date.today() for diff in range(-21, 21, 1): frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, firstweekday=6, weeknumbers='right') day = today + dt.timedelta(days=diff) frame.set_focus_date(day) assert frame.focus_date == day with freeze_time('2016-04-23'): today = dt.date.today() for diff in range(10): frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, firstweekday=6, weeknumbers='right') day = today + dt.timedelta(days=diff) frame.set_focus_date(day) assert frame.focus_date == day def test_set_focus_far_future(): future_date = dt.date.today() + dt.timedelta(days=1000) frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, weeknumbers='right') frame.set_focus_date(future_date) assert frame.focus_date == future_date def test_set_focus_far_past(): future_date = dt.date.today() - dt.timedelta(days=1000) frame = CalendarWidget(on_date_change=lambda _: None, keybindings=keybindings, on_press=on_press, weeknumbers='right') frame.set_focus_date(future_date) assert frame.focus_date == future_date khal-0.11.4/tests/ui/test_editor.py000066400000000000000000000112321477603436700172120ustar00rootroot00000000000000import datetime as dt import icalendar from khal.ui.editor import RecurrenceEditor, StartEndEditor from ..utils import BERLIN, LOCALE_BERLIN from .canvas_render import CanvasTranslator CONF = {'locale': LOCALE_BERLIN, 'keybindings': {}, 'view': {'monthdisplay': 'firstday'}} START = BERLIN.localize(dt.datetime(2015, 4, 26, 22, 23)) END = BERLIN.localize(dt.datetime(2015, 4, 27, 23, 23)) palette = { 'date header focused': 'blue', 'date header': 'green', 'default': 'black', 'edit focused': 'red', 'edit': 'blue', } def test_popup(monkeypatch): """making sure the popup calendar gets callend with the right inital value #405 """ class FakeCalendar: def store(self, *args, **kwargs): self.args = args self.kwargs = kwargs fake = FakeCalendar() monkeypatch.setattr( 'khal.ui.calendarwidget.CalendarWidget.__init__', fake.store) see = StartEndEditor(START, END, CONF) see.widgets.startdate.keypress((22, ), 'enter') assert fake.kwargs['initial'] == dt.date(2015, 4, 26) see.widgets.enddate.keypress((22, ), 'enter') assert fake.kwargs['initial'] == dt.date(2015, 4, 27) def test_check_understood_rrule(): assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=1SU') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYMONTHDAY=1') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYSETPOS=1') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TU,TH;BYSETPOS=1') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=MO,TU,WE,TH,FR,SA,SU;BYSETPOS=1') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=WE,SU,MO,TH,FR,TU,SA;BYSETPOS=1') ) assert RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;INTERVAL=2;BYDAY=WE,MO,TH,FR,TU,SA;BYSETPOS=1') ) assert not RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=-1SU') ) assert not RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYMONTHDAY=1,2,3,4,5,6,7') ) assert not RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYMONTHDAY=-1') ) assert not RecurrenceEditor.check_understood_rrule( icalendar.vRecur.from_ical('FREQ=MONTHLY;BYDAY=TH;BYSETPOS=3') ) def test_editor(): """test for the issue in #666""" editor = StartEndEditor( BERLIN.localize(dt.datetime(2017, 10, 2, 13)), BERLIN.localize(dt.datetime(2017, 10, 4, 18)), conf=CONF ) assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13)) assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18)) assert editor.changed is False for _ in range(3): editor.keypress((10, ), 'tab') for _ in range(3): editor.keypress((10, ), 'shift tab') assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13)) assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18)) assert editor.changed is False def test_convert_to_date(): """test for the issue in #666""" editor = StartEndEditor( BERLIN.localize(dt.datetime(2017, 10, 2, 13)), BERLIN.localize(dt.datetime(2017, 10, 4, 18)), conf=CONF ) canvas = editor.render((50, ), True) assert CanvasTranslator(canvas, palette).transform() == ( '[ ] Allday\nFrom: \x1b[31m2.10.2017 \x1b[0m \x1b[34m13:00 \x1b[0m\n' 'To: \x1b[34m04.10.2017\x1b[0m \x1b[34m18:00 \x1b[0m\n' ) assert editor.startdt == BERLIN.localize(dt.datetime(2017, 10, 2, 13)) assert editor.enddt == BERLIN.localize(dt.datetime(2017, 10, 4, 18)) assert editor.changed is False assert editor.allday is False # set to all day event editor.keypress((10, ), 'shift tab') editor.keypress((10, ), ' ') for _ in range(3): editor.keypress((10, ), 'tab') for _ in range(3): editor.keypress((10, ), 'shift tab') canvas = editor.render((50, ), True) assert CanvasTranslator(canvas, palette).transform() == ( '[X] Allday\nFrom: \x1b[34m02.10.2017\x1b[0m \n' 'To: \x1b[34m04.10.2017\x1b[0m \n' ) assert editor.changed is True assert editor.allday is True assert editor.startdt == dt.date(2017, 10, 2) assert editor.enddt == dt.date(2017, 10, 4) khal-0.11.4/tests/ui/test_walker.py000066400000000000000000000051761477603436700172230ustar00rootroot00000000000000import datetime as dt from freezegun import freeze_time from khal.ui import DayWalker, DListBox, StaticDayWalker from ..utils import LOCALE_BERLIN from .canvas_render import CanvasTranslator CONF = {'locale': LOCALE_BERLIN, 'keybindings': {}, 'view': {'monthdisplay': 'firstday'}, 'default': {'timedelta': dt.timedelta(days=3)}, } palette = { 'date header focused': 'blue', 'date header': 'green', 'default': 'black', } @freeze_time('2017-6-7') def test_daywalker(coll_vdirs): collection, _ = coll_vdirs this_date = dt.date.today() daywalker = DayWalker(this_date, None, CONF, collection, delete_status={}) elistbox = DListBox( daywalker, parent=None, conf=CONF, delete_status=lambda: False, toggle_delete_all=None, toggle_delete_instance=None, dynamic_days=True, ) canvas = elistbox.render((50, 6), True) assert CanvasTranslator(canvas, palette).transform() == \ """\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m \x1b[32mTomorrow (Thursday, 08.06.2017)\x1b[0m \x1b[32mFriday, 09.06.2017 (2 days from now)\x1b[0m \x1b[32mSaturday, 10.06.2017 (3 days from now)\x1b[0m \x1b[32mSunday, 11.06.2017 (4 days from now)\x1b[0m \x1b[32mMonday, 12.06.2017 (5 days from now)\x1b[0m """ @freeze_time('2017-6-7') def test_staticdaywalker(coll_vdirs): collection, _ = coll_vdirs this_date = dt.date.today() daywalker = StaticDayWalker(this_date, None, CONF, collection, delete_status={}) elistbox = DListBox( daywalker, parent=None, conf=CONF, delete_status=lambda: False, toggle_delete_all=None, toggle_delete_instance=None, dynamic_days=False, ) canvas = elistbox.render((50, 10), True) assert CanvasTranslator(canvas, palette).transform() == \ """\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m \x1b[32mTomorrow (Thursday, 08.06.2017)\x1b[0m \x1b[32mFriday, 09.06.2017 (2 days from now)\x1b[0m """ @freeze_time('2017-6-7') def test_staticdaywalker_3(coll_vdirs): collection, _ = coll_vdirs this_date = dt.date.today() conf = {} conf.update(CONF) conf['default'] = {'timedelta': dt.timedelta(days=1)} daywalker = StaticDayWalker(this_date, None, conf, collection, delete_status={}) elistbox = DListBox( daywalker, parent=None, conf=conf, delete_status=lambda: False, toggle_delete_all=None, toggle_delete_instance=None, dynamic_days=False, ) canvas = elistbox.render((50, 10), True) assert CanvasTranslator(canvas, palette).transform() == \ '\x1b[34mToday (Wednesday, 07.06.2017)\x1b[0m\n\n\n\n\n\n\n\n\n\n' khal-0.11.4/tests/ui/test_widgets.py000066400000000000000000000017171477603436700174010ustar00rootroot00000000000000from khal.ui.widgets import delete_last_word def test_delete_last_word(): tests = [ ('Fü1ü Bär!', 'Fü1ü Bär', 1), ('Füü Bär1', 'Füü ', 1), ('Füü1 Bär1', 'Füü1 ', 1), (' Füü Bär', ' Füü ', 1), ('Füü Bär.Füü', 'Füü Bär.', 1), ('Füü Bär.(Füü)', 'Füü Bär.(Füü', 1), ('Füü ', '', 1), ('Füü ', '', 1), ('Füü', '', 1), ('', '', 1), ('Füü Bär.(Füü)', 'Füü Bär.', 3), ('Füü Bär1', '', 2), ('Lorem ipsum dolor sit amet, consetetur sadipscing elitr, ' 'sed diam nonumy eirmod tempor invidunt ut labore et dolore ' 'magna aliquyam erat, sed diam volest.', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, ' 'sed diam nonumy eirmod tempor invidunt ut labore ', 10) ] for org, short, number in tests: assert delete_last_word(org, number) == short khal-0.11.4/tests/utils.py000066400000000000000000000075171477603436700154230ustar00rootroot00000000000000import os from typing import Dict, Tuple import icalendar import pytz from khal.custom_types import LocaleConfiguration from khal.khalendar import CalendarCollection from khal.khalendar.vdir import Vdir CollVdirType = Tuple[CalendarCollection, Dict[str, Vdir]] cal0 = 'a_calendar' cal1 = 'foobar' cal2 = "Dad's calendar" cal3 = 'private' example_cals = [cal0, cal1, cal2, cal3] BERLIN = pytz.timezone('Europe/Berlin') NEW_YORK = pytz.timezone('America/New_York') LONDON = pytz.timezone('Europe/London') SAMOA = pytz.timezone('Pacific/Samoa') SYDNEY = pytz.timezone('Australia/Sydney') GMTPLUS3 = pytz.timezone('Etc/GMT+3') # the lucky people in Bogota don't know the pain that is DST BOGOTA = pytz.timezone('America/Bogota') LOCALE_BERLIN: LocaleConfiguration = { 'default_timezone': BERLIN, 'local_timezone': BERLIN, 'dateformat': '%d.%m.', 'longdateformat': '%d.%m.%Y', 'timeformat': '%H:%M', 'datetimeformat': '%d.%m. %H:%M', 'longdatetimeformat': '%d.%m.%Y %H:%M', 'unicode_symbols': True, 'firstweekday': 0, 'weeknumbers': False, } LOCALE_NEW_YORK: LocaleConfiguration = { 'default_timezone': NEW_YORK, 'local_timezone': NEW_YORK, 'timeformat': '%H:%M', 'dateformat': '%Y/%m/%d', 'longdateformat': '%Y/%m/%d', 'datetimeformat': '%Y/%m/%d-%H:%M', 'longdatetimeformat': '%Y/%m/%d-%H:%M', 'firstweekday': 6, 'unicode_symbols': True, 'weeknumbers': False, } LOCALE_SAMOA = { 'local_timezone': SAMOA, 'default_timezone': SAMOA, 'unicode_symbols': True, } LOCALE_SYDNEY = {'local_timezone': SYDNEY, 'default_timezone': SYDNEY} LOCALE_BOGOTA = LOCALE_BERLIN.copy() LOCALE_BOGOTA['local_timezone'] = BOGOTA LOCALE_BOGOTA['default_timezone'] = BOGOTA LOCALE_MIXED = LOCALE_BERLIN.copy() LOCALE_MIXED['local_timezone'] = BOGOTA LOCALE_FLOATING = LOCALE_BERLIN.copy() LOCALE_FLOATING['default_timezone'] = None # type: ignore LOCALE_FLOATING['local_timezone'] = None # type: ignore def normalize_component(x): x = icalendar.cal.Component.from_ical(x) def inner(c): contentlines = icalendar.cal.Contentlines() for name, value in c.property_items(sorted=True, recursive=False): contentlines.append(c.content_line(name, value, sorted=True)) contentlines.append('') return (c.name, contentlines.to_ical(), frozenset(inner(sub) for sub in c.subcomponents)) return inner(x) def _get_text(event_name): directory = '/'.join(__file__.split('/')[:-1]) + '/ics/' if directory == '/ics/': directory = './ics/' with open(os.path.join(directory, event_name + '.ics'), 'rb') as f: rv = f.read().decode('utf-8') return rv def _get_vevent_file(event_path): directory = '/'.join(__file__.split('/')[:-1]) + '/ics/' with open(os.path.join(directory, event_path + '.ics'), 'rb') as f: ical = icalendar.Calendar.from_ical( f.read() ) for component in ical.walk(): if component.name == 'VEVENT': return component def _get_ics_filepath(event_name): directory = '/'.join(__file__.split('/')[:-1]) + '/ics/' if directory == '/ics/': directory = './ics/' return os.path.join(directory, event_name + '.ics') def _get_all_vevents_file(event_path): directory = '/'.join(__file__.split('/')[:-1]) + '/ics/' ical = icalendar.Calendar.from_ical( open(os.path.join(directory, event_path + '.ics'), 'rb').read() ) for component in ical.walk(): if component.name == 'VEVENT': yield component def _replace_uid(event): """ Replace an event's UID with E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA. """ event.pop('uid') event.add('uid', 'E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA') return event class DumbItem: def __init__(self, raw, uid) -> None: self.raw = raw self.uid = uid khal-0.11.4/tests/utils_test.py000066400000000000000000000123101477603436700164450ustar00rootroot00000000000000"""testing functions from the khal.utils""" import datetime as dt from click import style from freezegun import freeze_time from khal import utils def test_relative_timedelta_str(): with freeze_time('2016-9-19'): assert utils.relative_timedelta_str(dt.date(2016, 9, 24)) == '5 days from now' assert utils.relative_timedelta_str(dt.date(2016, 9, 29)) == '~1 week from now' assert utils.relative_timedelta_str(dt.date(2017, 9, 29)) == '~1 year from now' assert utils.relative_timedelta_str(dt.date(2016, 7, 29)) == '~7 weeks ago' weekheader = """ Mo Tu We Th Fr Sa Su """ today_line = """Today""" calendarline = ( "Nov 31  1  2 " " 3  4  5  6" ) def test_last_reset(): assert utils.find_last_reset(weekheader) == (31, 35, '\x1b[0m') assert utils.find_last_reset(today_line) == (13, 17, '\x1b[0m') assert utils.find_last_reset(calendarline) == (99, 103, '\x1b[0m') assert utils.find_last_reset('Hello World') == (-2, -1, '') def test_last_sgr(): assert utils.find_last_sgr(weekheader) == (0, 4, '\x1b[1m') assert utils.find_last_sgr(today_line) == (0, 4, '\x1b[1m') assert utils.find_last_sgr(calendarline) == (92, 97, '\x1b[32m') assert utils.find_last_sgr('Hello World') == (-2, -1, '') def test_find_unmatched_sgr(): assert utils.find_unmatched_sgr(weekheader) is None assert utils.find_unmatched_sgr(today_line) is None assert utils.find_unmatched_sgr(calendarline) is None assert utils.find_unmatched_sgr('\x1b[31mHello World') == '\x1b[31m' assert utils.find_unmatched_sgr('\x1b[31mHello\x1b[0m \x1b[32mWorld') == '\x1b[32m' assert utils.find_unmatched_sgr('foo\x1b[1;31mbar') == '\x1b[1;31m' assert utils.find_unmatched_sgr('\x1b[0mfoo\x1b[1;31m') == '\x1b[1;31m' def test_color_wrap(): text = ( "Lorem ipsum \x1b[31mdolor sit amet, consetetur sadipscing " "elitr, sed diam nonumy\x1b[0m eirmod tempor" ) expected = [ "Lorem ipsum \x1b[31mdolor sit amet,\x1b[0m", "\x1b[31mconsetetur sadipscing elitr, sed\x1b[0m", "\x1b[31mdiam nonumy\x1b[0m eirmod tempor", ] assert utils.color_wrap(text, 35) == expected def test_color_wrap_256(): text = ( "\x1b[38;2;17;255;0mLorem ipsum dolor sit amet, consetetur sadipscing " "elitr, sed diam nonumy\x1b[0m" ) expected = [ "\x1b[38;2;17;255;0mLorem ipsum\x1b[0m", "\x1b[38;2;17;255;0mdolor sit amet, consetetur\x1b[0m", "\x1b[38;2;17;255;0msadipscing elitr, sed diam\x1b[0m", "\x1b[38;2;17;255;0mnonumy\x1b[0m" ] assert utils.color_wrap(text, 30) == expected def test_color_wrap_multiple_colors_and_tabs(): text = ( "\x1b[31m14:00-14:50 AST-1002-102 INTRO AST II/STAR GALAX (R) Classes", "15:30-16:45 PHL-2000-104 PHILOSOPHY, SOCIETY & ETHICS (R) Classes", "\x1b[38;2;255;0m17:00-18:00 Pay Ticket Deadline Calendar", "09:30-10:45 PHL-1501-101 MIND, KNOWLEDGE & REALITY (R) Classes", "\x1b[38;2;255;0m11:00-14:00 Rivers Street (noodles and pizza) (R) Calendar", ) expected = [ '\x1b[31m14:00-14:50 AST-1002-102 INTRO AST II/STAR GALAX (R)\x1b[0m', '\x1b[31mClasses\x1b[0m', '15:30-16:45 PHL-2000-104 PHILOSOPHY, SOCIETY & ETHICS (R)', 'Classes', '\x1b[38;2;255;0m17:00-18:00 Pay Ticket Deadline Calendar\x1b[0m', '09:30-10:45 PHL-1501-101 MIND, KNOWLEDGE & REALITY (R)', 'Classes', '\x1b[38;2;255;0m11:00-14:00 Rivers Street (noodles and\x1b[0m', '\x1b[38;2;255;0mpizza) (R) Calendar\x1b[0m' ] actual = [] for line in text: actual += utils.color_wrap(line, 60) assert actual == expected def test_get_weekday_occurrence(): assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 1)) == (2, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 2)) == (3, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 3)) == (4, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 4)) == (5, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 5)) == (6, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 6)) == (0, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 7)) == (1, 1) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 8)) == (2, 2) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 9)) == (3, 2) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 10)) == (4, 2) assert utils.get_weekday_occurrence(dt.datetime(2017, 3, 31)) == (4, 5) assert utils.get_weekday_occurrence(dt.date(2017, 5, 1)) == (0, 1) assert utils.get_weekday_occurrence(dt.date(2017, 5, 7)) == (6, 1) assert utils.get_weekday_occurrence(dt.date(2017, 5, 8)) == (0, 2) assert utils.get_weekday_occurrence(dt.date(2017, 5, 28)) == (6, 4) assert utils.get_weekday_occurrence(dt.date(2017, 5, 29)) == (0, 5) def test_human_formatter_width(): formatter = utils.human_formatter('{red}{title}', width=10) output = formatter({'title': 'morethan10characters', 'red': style('', reset=False, fg='red')}) assert output.startswith('\x1b[31mmoret\x1b[0m') khal-0.11.4/tests/vdir_test.py000066400000000000000000000045521477603436700162620ustar00rootroot00000000000000# Copyright (c) 2013-2022 khal contributors # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import os from time import sleep from khal.khalendar import vdir def test_etag(tmpdir, sleep_time): fpath = os.path.join(str(tmpdir), 'foo') file_ = open(fpath, 'w') file_.write('foo') file_.close() old_etag = vdir.get_etag_from_file(fpath) sleep(sleep_time) file_ = open(fpath, 'w') file_.write('foo') file_.close() new_etag = vdir.get_etag_from_file(fpath) assert old_etag != new_etag def test_etag_sync(tmpdir, sleep_time): fpath = os.path.join(str(tmpdir), 'foo') file_ = open(fpath, 'w') file_.write('foo') file_.close() os.sync() old_etag = vdir.get_etag_from_file(fpath) sleep(sleep_time) file_ = open(fpath, 'w') file_.write('foo') file_.close() new_etag = vdir.get_etag_from_file(fpath) assert old_etag != new_etag def test_get_href_from_uid(): # Test UID with unsafe characters uid = "V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWÈÉ@pimutils.org" first_href = vdir._generate_href(uid) second_href = vdir._generate_href(uid) assert first_href == second_href # test UID with safe characters uid = "V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU@pimutils.org" href = vdir._generate_href(uid) assert href == uid href = vdir._generate_href() assert href is not None khal-0.11.4/tests/vtimezone_test.py000066400000000000000000000044341477603436700173350ustar00rootroot00000000000000import datetime as dt import pytz from packaging import version from khal.khalendar.event import create_timezone berlin = pytz.timezone('Europe/Berlin') bogota = pytz.timezone('America/Bogota') atime = dt.datetime(2014, 10, 28, 10, 10) btime = dt.datetime(2016, 10, 28, 10, 10) def test_berlin(): vberlin_std = b'\r\n'.join( [b'BEGIN:STANDARD', b'DTSTART:20141026T020000', b'TZNAME:CET', b'TZOFFSETFROM:+0200', b'TZOFFSETTO:+0100', b'END:STANDARD', ]) vberlin_dst = b'\r\n'.join( [b'BEGIN:DAYLIGHT', b'DTSTART:20150329T030000', b'TZNAME:CEST', b'TZOFFSETFROM:+0100', b'TZOFFSETTO:+0200', b'END:DAYLIGHT', ]) vberlin = create_timezone(berlin, atime, atime).to_ical() assert b'TZID:Europe/Berlin' in vberlin assert vberlin_std in vberlin assert vberlin_dst in vberlin def test_berlin_rdate(): vberlin_std = b'\r\n'.join( [b'BEGIN:STANDARD', b'DTSTART:20141026T020000', b'RDATE:20151025T020000,20161030T020000', b'TZNAME:CET', b'TZOFFSETFROM:+0200', b'TZOFFSETTO:+0100', b'END:STANDARD', ]) vberlin_dst = b'\r\n'.join( [b'BEGIN:DAYLIGHT', b'DTSTART:20150329T030000', b'RDATE:20160327T030000', b'TZNAME:CEST', b'TZOFFSETFROM:+0100', b'TZOFFSETTO:+0200', b'END:DAYLIGHT', ]) vberlin = create_timezone(berlin, atime, btime).to_ical() assert b'TZID:Europe/Berlin' in vberlin assert vberlin_std in vberlin assert vberlin_dst in vberlin def test_bogota(): vbogota = [b'BEGIN:VTIMEZONE', b'TZID:America/Bogota', b'BEGIN:STANDARD', b'DTSTART:19930206T230000', b'TZNAME:COT', b'TZOFFSETFROM:-0400', b'TZOFFSETTO:-0500', b'END:STANDARD', b'END:VTIMEZONE', b''] if version.parse(pytz.__version__) > version.Version('2017.1'): vbogota[4] = b'TZNAME:-05' if version.parse(pytz.__version__) < version.Version('2022.7'): vbogota.insert(4, b'RDATE:20380118T221407') assert create_timezone(bogota, atime, atime).to_ical().split(b'\r\n') == vbogota khal-0.11.4/tox.ini000066400000000000000000000012741477603436700140540ustar00rootroot00000000000000[tox] envlist = {py38,py39,py310,py311,py312,py313}-tests,py39-tests-{pytz2018.7,pytz_latest} skip_missing_interpreters = True [testenv] ignore_errors = True passenv = LANG CI extras = pytz20187: pytz==2018.7 pytz_latest: pytz commands_pre = pip install '.[test]' commands = py.test {posargs} [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 [testenv:docs] allowlist_externals = make extras = docs commands = make -C doc html make -C doc man [mypy] # Silence warnings given by using untyped libraries: ignore_missing_imports = True # See https://github.com/python/mypy/issues/7511: warn_no_return = False