khal-0.9.10/0000755000076600000240000000000013357150673014656 5ustar christiangeierstaff00000000000000khal-0.9.10/PKG-INFO0000644000076600000240000001253713357150673015763 0ustar christiangeierstaff00000000000000Metadata-Version: 2.1 Name: khal Version: 0.9.10 Summary: A standards based terminal calendar Home-page: http://lostpackets.de/khal/ Author: Christian Geier et. al. Author-email: khal@lostpackets.de License: Expat/MIT Description: khal ==== .. image:: https://travis-ci.org/pimutils/khal.svg?branch=master :target: https://travis-ci.org/pimutils/khal .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master :target: https://codecov.io/github/pimutils/khal?branch=master *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.3+ - 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 IRC (#pimutils on Freenode), otherwise you can reach the original author via email at khal (at) lostpackets (dot) de or via jabber/XMPP at geier (at) jabber (dot) ccc (dot) de. .. _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 the website_ or readthedocs_. .. _website: https://lostpackets.de/khal/ .. _readthedocs: http://khal.readthedocs.org/ 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. If you don't have the time or the means to contribute in any of the above mentioned ways, donations are appreciated, too. .. image:: https://api.flattr.com/button/flattr-badge-large.png :alt: flattr button :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/ License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2017 Christian Geier et al. 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. Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console :: Curses Classifier: Intended Audience :: End Users/Desktop Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Topic :: Utilities Classifier: Topic :: Communications Provides-Extra: proctitle khal-0.9.10/khal.egg-info/0000755000076600000240000000000013357150672017266 5ustar christiangeierstaff00000000000000khal-0.9.10/khal.egg-info/PKG-INFO0000644000076600000240000001253713357150672020373 0ustar christiangeierstaff00000000000000Metadata-Version: 2.1 Name: khal Version: 0.9.10 Summary: A standards based terminal calendar Home-page: http://lostpackets.de/khal/ Author: Christian Geier et. al. Author-email: khal@lostpackets.de License: Expat/MIT Description: khal ==== .. image:: https://travis-ci.org/pimutils/khal.svg?branch=master :target: https://travis-ci.org/pimutils/khal .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master :target: https://codecov.io/github/pimutils/khal?branch=master *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.3+ - 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 IRC (#pimutils on Freenode), otherwise you can reach the original author via email at khal (at) lostpackets (dot) de or via jabber/XMPP at geier (at) jabber (dot) ccc (dot) de. .. _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 the website_ or readthedocs_. .. _website: https://lostpackets.de/khal/ .. _readthedocs: http://khal.readthedocs.org/ 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. If you don't have the time or the means to contribute in any of the above mentioned ways, donations are appreciated, too. .. image:: https://api.flattr.com/button/flattr-badge-large.png :alt: flattr button :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/ License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2017 Christian Geier et al. 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. Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: License :: OSI Approved :: MIT License Classifier: Environment :: Console :: Curses Classifier: Intended Audience :: End Users/Desktop Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Topic :: Utilities Classifier: Topic :: Communications Provides-Extra: proctitle khal-0.9.10/khal.egg-info/not-zip-safe0000644000076600000240000000000113243067242021506 0ustar christiangeierstaff00000000000000 khal-0.9.10/khal.egg-info/SOURCES.txt0000644000076600000240000001030513357150672021151 0ustar christiangeierstaff00000000000000.coveragerc .gitignore .travis.yml AUTHORS.txt CHANGELOG.rst CODE_OF_CONDUCT.rst CONTRIBUTING.rst COPYING MANIFEST.in README.rst codecov.yml khal.conf.sample setup.py tox.ini bin/ikhal bin/khal doc/Makefile doc/requirements.txt doc/source/changelog.rst doc/source/conf.py doc/source/configspec.rst doc/source/configure.rst doc/source/faq.rst doc/source/feedback.rst doc/source/hacking.rst doc/source/index.rst doc/source/install.rst doc/source/license.rst doc/source/man.rst doc/source/news.rst doc/source/standards.rst doc/source/usage.rst doc/source/images/rss.png doc/source/news/30c3.rst doc/source/news/31c3.rst doc/source/news/callfortesting.rst doc/source/news/khal01.rst doc/source/news/khal011.rst doc/source/news/khal02.rst doc/source/news/khal03.rst doc/source/news/khal031.rst doc/source/news/khal04.rst doc/source/news/khal05.rst doc/source/news/khal06.rst doc/source/news/khal07.rst doc/source/news/khal071.rst doc/source/news/khal08.rst doc/source/news/khal081.rst doc/source/news/khal082.rst doc/source/news/khal083.rst doc/source/news/khal084.rst doc/source/news/khal09.rst doc/source/news/khal091.rst doc/source/news/khal092.rst doc/source/news/khal093.rst doc/source/news/khal094.rst doc/source/news/khal095.rst doc/source/news/khal096.rst doc/source/news/khal097.rst doc/source/news/khal098.rst doc/source/news/khal099.rst doc/source/ystatic/.gitignore doc/source/ytemplates/layout.html doc/webpage/src/new_rss_url.rst khal/__init__.py khal/__main__.py khal/calendar_display.py khal/cli.py khal/configwizard.py khal/controllers.py khal/exceptions.py khal/log.py khal/terminal.py khal/utils.py khal/version.py khal.egg-info/PKG-INFO khal.egg-info/SOURCES.txt khal.egg-info/dependency_links.txt khal.egg-info/entry_points.txt khal.egg-info/not-zip-safe khal.egg-info/requires.txt khal.egg-info/top_level.txt khal/khalendar/__init__.py khal/khalendar/backend.py khal/khalendar/event.py khal/khalendar/exceptions.py khal/khalendar/khalendar.py khal/khalendar/utils.py khal/khalendar/vdir.py khal/settings/__init__.py khal/settings/exceptions.py khal/settings/khal.spec khal/settings/settings.py khal/settings/utils.py khal/ui/__init__.py khal/ui/base.py khal/ui/calendarwidget.py khal/ui/colors.py khal/ui/editor.py khal/ui/widgets.py misc/__khal misc/mutt2khal tests/__init__.py tests/backend_test.py tests/cal_display_test.py tests/cli_test.py tests/configwizard_test.py tests/conftest.py tests/controller_test.py tests/event_test.py tests/khalendar_test.py tests/khalendar_utils_test.py tests/settings_test.py tests/terminal_test.py tests/utils.py tests/utils_test.py tests/vdir_test.py tests/vtimezone_test.py tests/configs/nocalendars.conf tests/configs/simple.conf tests/configs/small.conf tests/ics/cal_d.ics tests/ics/cal_dt_two_tz.ics tests/ics/cal_lots_of_timezones.ics tests/ics/cal_no_dst.ics tests/ics/event_d.ics tests/ics/event_d_15.ics tests/ics/event_d_long.ics tests/ics/event_d_no_value.ics tests/ics/event_d_rdate.ics tests/ics/event_d_rr.ics tests/ics/event_d_same_start_end.ics tests/ics/event_dt_duration.ics tests/ics/event_dt_floating.ics tests/ics/event_dt_local_missing_tz.ics tests/ics/event_dt_london.ics tests/ics/event_dt_long.ics tests/ics/event_dt_mixed_awareness.ics tests/ics/event_dt_multi_recuid_no_master.ics tests/ics/event_dt_no_end.ics tests/ics/event_dt_rd.ics tests/ics/event_dt_recuid_no_master.ics tests/ics/event_dt_rr.ics tests/ics/event_dt_rrule_invalid_until.ics tests/ics/event_dt_rrule_invalid_until2.ics tests/ics/event_dt_simple.ics tests/ics/event_dt_simple_inkl_vtimezone.ics tests/ics/event_dt_simple_nocat.ics tests/ics/event_dt_simple_updated.ics tests/ics/event_dt_simple_zulu.ics tests/ics/event_dt_two_rd.ics tests/ics/event_dt_two_tz.ics tests/ics/event_dtr_exdatez.ics tests/ics/event_dtr_no_tz_exdatez.ics tests/ics/event_dtr_notz_untilz.ics tests/ics/event_invalid_exdate.ics tests/ics/event_no_dst.ics tests/ics/event_r_past.ics tests/ics/event_rdate_no_value.ics tests/ics/event_rrule_recuid.ics tests/ics/event_rrule_recuid_cancelled.ics tests/ics/event_rrule_recuid_invalid_tzid.ics tests/ics/event_rrule_recuid_update.ics tests/ics/mult_uids_and_recuid_no_order.ics tests/ics/part0.ics tests/ics/part1.ics tests/ui/__init__.py tests/ui/test_calendarwidget.py tests/ui/test_editor.py tests/ui/test_widgets.pykhal-0.9.10/khal.egg-info/entry_points.txt0000644000076600000240000000011113357150672022555 0ustar christiangeierstaff00000000000000[console_scripts] ikhal = khal.cli:main_ikhal khal = khal.cli:main_khal khal-0.9.10/khal.egg-info/requires.txt0000644000076600000240000000017313357150672021667 0ustar christiangeierstaff00000000000000click>=3.2 icalendar urwid pyxdg pytz python-dateutil configobj atomicwrites>=0.1.7 tzlocal>=1.0 [proctitle] setproctitle khal-0.9.10/khal.egg-info/top_level.txt0000644000076600000240000000005213357150672022015 0ustar christiangeierstaff00000000000000khal khal/khalendar khal/settings khal/ui khal-0.9.10/khal.egg-info/dependency_links.txt0000644000076600000240000000000113357150672023334 0ustar christiangeierstaff00000000000000 khal-0.9.10/misc/0000755000076600000240000000000013357150672015610 5ustar christiangeierstaff00000000000000khal-0.9.10/misc/__khal0000644000076600000240000000553213357150322016745 0ustar christiangeierstaff00000000000000#compdef khal # install by copying to file where your zsh looks for completion scripts # e.g. add this to your zshrc: # fpath=(~/.zsh/completion $fpath) _calendars() { local expl _wanted calendars expl calendar compadd \ ${(f)"$(_call_program calendars khal printcalendars)"} } local curcontext="$curcontext" hlp="--help" ret=1 local -a args state line local -A opt_args args=( '(- *)--help[show help information]' ) _arguments -C $args \ {-c+,--config=}'[specify config file]:config file:_files' \ {-v,--verbose}"[give more output]" \ '(- *)--version[show version]' \ ':subcommand:->subcommand' \ '*::options:->options' && ret=0 case $state in subcommand) local -a subcommands subcommands=( 'at:show all events for given time' "calendar:show calendar" "configure: intitial configuration" "edit:edit (or delete) an event" "import:import an ics file into a calendar" "interactive:open the interactive calendar" "list:show list of events" "new:add a new event" "printcalendars:print all configured calendars" "printformats:print a date in all formats" "printics:print ics file without importing" "search:search for events" ) _describe -t subcommands 'khal subcommands' subcommands && ret=0 ;; options) curcontext="${curcontext%:*}-${words[1]}:" case $words[1] in at | calendar | list | interactive | search | printcalendars | edit) args+=( "(-d --exclude-calendar $hlp)*"{-a+,--include-calendar=}'[specify calendar to use]:calendar:_calendars' "(-a --include-calendar $hlp)*"{-d+,--exclude-calendar=}"[don't use this calendar]:calendar:_calendars" ) ;| list | calendar) args+=( "($hlp)--days=[specify how many days to include]:days:_dates -f d -F" "($hlp)--events=[specify how many events to include]:events" ) ;| at | list) args+=( '*:date/time' ) ;; new | import) args+=( "($hlp)-a+[specify calendar]:calendar:_calendars" ) ;| import) args+=( "(-r --random_uid $hlp)"{-r,--random_uid}'[select a random uid]' "($hlp)--batch[don't ask for any confirmation]" '*:file:_files -g "*.ics(-.)"' ) ;; new) args+=( "(-l --location $hlp)"{-l,--location=}'[specify location of event]:location' "(-r --repeat $hlp)"{-r,--repeat=}'[repeat an event]:frequency:compadd -E0 - daily weekly monthly yearly' "(-u --until $hlp)"{-u,--until=}'[specify date to stop an event repeating]:date' "(-i --interactive $hlp)"{-i,--interactive=}'[interactively create a new event]' ':start date/time' '::end date/time' '::timezone' ':summary' ':description' ) ;; esac _arguments -A "-*" $args && ret=0 ;; esac return ret khal-0.9.10/misc/mutt2khal0000644000076600000240000000220013357150322017430 0ustar christiangeierstaff00000000000000#!/usr/bin/awk -f # mutt2khal is designed to be used in conjunction with vcalendar-filter (https://github.com/datamuc/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[ ]*:[ ]*/, "") summ = $0 next } /^Location/ { sub(/^Location[ ]*:[ ]*/,"") loc = sprintf("-l \"%s\"", $0) next } /^Desc/ { sub(/^Description[ ]*:[ ]*/, "") desc = ":: " $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 { print "khal new", 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.9.10/codecov.yml0000644000076600000240000000010713243067215017012 0ustar christiangeierstaff00000000000000coverage: status: patch: false project: false comment: false khal-0.9.10/khal/0000755000076600000240000000000013357150672015574 5ustar christiangeierstaff00000000000000khal-0.9.10/khal/ui/0000755000076600000240000000000013357150672016211 5ustar christiangeierstaff00000000000000khal-0.9.10/khal/ui/__init__.py0000644000076600000240000013537513357150322020330 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 datetime import date, datetime, time, timedelta import signal import sys import click import urwid from .. import utils from ..khalendar.event import Event from ..khalendar.exceptions import ReadOnlyCalendarError from . import colors from .widgets import ExtendedEdit as Edit, NPile, NColumns, NListBox, linebox from .base import Pane, Window from .editor import EventEditor, ExportDialog from .calendarwidget import CalendarWidget # 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 | | | | | | # | | | | | | | +------------------+ | | | | | # | | | | | | +----------------------+ | | | | # | | | | | +--------------------------+ | | | # | | | | +------------------------------+ | | # | +-----------------+ +----------------------------------+ | # +----------------------------------------------------------+ ALL = 1 INSTANCES = 2 class DateConversionError(Exception): pass class SelectableText(urwid.Text): def selectable(self): return True def keypress(self, size, key): return key def get_cursor_coords(self, size): 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, dateformat, conf): """ :type day: datetime.date :type dateformat: format to print `day` in :type dateformat: str """ self._day = day self._dateformat = dateformat self._conf = conf super().__init__('') self.update_date_line() def update_date_line(self): """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, dtformat): """convert day into a string with its weekday and relative distance to today :param day: day to be converted :type: day: datetime.day :param dtformat: the format day is to be printed in, passed to strftime :type dtformat: str :rtype: str """ weekday = day.strftime('%A') daystr = day.strftime(dtformat) if day == date.today(): return 'Today ({}, {})'.format(weekday, daystr) elif day == date.today() + timedelta(days=1): return 'Tomorrow ({}, {})'.format(weekday, daystr) elif day == date.today() - timedelta(days=1): return 'Yesterday ({}, {})'.format(weekday, daystr) approx_delta = utils.relative_timedelta_str(day) return '{weekday}, {day} ({approx_delta})'.format( weekday=weekday, approx_delta=approx_delta, day=daystr, ) def keypress(self, _, key): 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): """representation of an event in EventList :param event: the encapsulated event :type event: khal.event.Event """ if relative: if isinstance(this_date, datetime) or not isinstance(this_date, date): raise ValueError('`this_date` is of type `{}`, sould be ' '`datetime.date`'.format(type(this_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): 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): return True @property def uid(self): return self.event.calendar + '\n' + \ str(self.event.href) + '\n' + str(self.event.etag) @property def recuid(self): return (self.uid, self.event.recurrence_id) def set_title(self, mark=' '): mark = {ALL: 'D', INSTANCES: 'd', False: ''}[self.delete_status(self.recuid)] if self.relative: format_ = self._conf['view']['agenda_event_format'] else: format_ = self._conf['view']['event_format'] if self.this_date: date_ = self.this_date elif self.event.allday: date_ = self.event.start else: date_ = self.event.start.date() text = self.event.format(format_, 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, _, key): 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): self._init = True self.parent = 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, key): return super().keypress(size, key) @property def focus_event(self): 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): dynamic_days = kwargs.pop('dynamic_days', True) super().__init__(*args, **kwargs) self._init = dynamic_days def render(self, size, focus=False): if self._init: while 'bottom' in self.ends_visible(size): self.body._autoextend() self._init = False return super().render(size, focus) def clean(self): """reset event most recently in focus""" if self._old_focus is not None: self.body[self._old_focus].body[0].set_attr_map({None: 'date'}) def ensure_date(self, day): """ensure an entry for `day` exists and bring it into focus""" try: self._old_focus = self.focus_position except IndexError: pass rval = self.body.ensure_date(day) self.clean() return rval def keypress(self, size, key): 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(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): 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): 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__(list()) self.ensure_date(this_date) def ensure_date(self, day): """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 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 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, end, everything): """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` :type start: datetime.date :type end: datetime.date :type bool: bool """ start = start.date() if isinstance(start, datetime) else start end = end.date() if isinstance(end, 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, end, everything=False): """refresh contents of all days between start and end (inclusive) :type start: datetime.date :type end: datetime.date """ start = start.date() if isinstance(start, datetime) else start end = end.date() if isinstance(end, 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 += 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 += 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 -= timedelta(days=1) pile = self._get_events(self._first_day) self.insert(0, pile) def _get_events(self, day): """get all events on day, return a DateListBox of `U_Event()`s :type day: datetime.date """ event_list = 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): """mark this widget as selectable""" return True @property def focus_event(self): return self[self.focus].original_widget.focus_event @property def current_day(self): return self[self.focus].original_widget.date class StaticDayWalker(DayWalker): """Only show events for a fixed number of days.""" def ensure_date(self, day): """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 + 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, end, everything): """refresh events' titles if `everything` is True, reset all titles, otherwise only those between `start` and `end` :type start: datetime.date :type end: datetime.date :type bool: bool """ for one in self: one.refresh_titles() def update_range(self, start, end, everything=False): """refresh contents of all days between start and end (inclusive) :type start: datetime.date :type end: datetime.date """ start = start.date() if isinstance(start, datetime) else start end = end.date() if isinstance(end, 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(NListBox): """A ListBox container for a SimpleFocusListWalker, that contains one day worth of events""" selected_date = None def __init__(self, content, date): self.date = date super().__init__(content) def __repr__(self): return ''.format(self.date) __str__ = __repr__ def render(self, size, focus): if focus: self.body[0].set_attr_map({None: 'date focused'}) elif DateListBox.selected_date == self.date: self.body[0].set_attr_map({None: 'date selected'}) else: self.reset_style() return super().render(size, focus) def reset_style(self): self.body[0].set_attr_map({None: 'date'}) def set_selected_date(self, day): """Mark `day` as selected :param day: day to mark as selected :type day: datetime.date """ 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): 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): self.pane = pane self._conf = pane._conf self.divider = urwid.Divider('─') self.editor = False self._current_date = None self._eventshown = False 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 = elistbox self.container = urwid.Pile([self.dlistbox]) urwid.WidgetWrap.__init__(self, self.container) @property def focus_event(self): """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): return self._current_date @focus_date.setter def focus_date(self, date): self._current_date = date self.dlistbox.ensure_date(date) def update(self, min_date, max_date, everything): """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) self.dlistbox.body.update_range(min_date, max_date) def refresh_titles(self, min_date, max_date, everything): """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): """refresh titles in DateListBoxes""" self.dlistbox.update_date_line() def edit(self, event, always_save=False, external_edit=False): """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 :type always_save: bool """ if event.readonly: self.pane.window.alert( ('alert', 'Calendar `{}` is read-only.'.format(event.calendar))) return if isinstance(event.start_local, datetime): original_start = event.start_local.date() else: original_start = event.start_local if isinstance(event.end_local, datetime): original_end = event.end_local.date() else: original_end = event.end_local def update_colors(new_start, new_end, everything=False): """reset colors in the calendar widget and dates in DayWalker between min(new_start, original_start) :type new_start: datetime.date :type new_end: datetime.date :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, datetime): new_start = new_start.date() if isinstance(new_end, 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.screen.stop() text = click.edit(event.raw) self.pane.window.loop.screen.start() if text is None: return # KeyErrors can occurr here when we destroy DTSTART, # otherwise, even broken .ics files seem to be no problem new_event = Event.fromString( text, locale=self._conf['locale'], href=event.href, calendar=event.calendar, etag=event.etag, ) 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, ContainerWidget(editor)), ('weight', 1, ContainerWidget(self.dlistbox)) ], 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', 'Calendar {} is read-only.'.format(event.event.calendar)), ) return status = self.delete_status(event.recuid) refresh = True if status == ALL: self.toggle_delete_all(event.recuid) elif status == 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): """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 event = self.focus_event.event.duplicate() try: self.pane.collection.new(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, datetime): start_date = start_date.date() if isinstance(end_date, 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, end=None): """create a new event on `date` at the next full hour and edit it :param date: default date for new event :type date: datetime.date :param end: optional, date the event ends on (inclusive) :type end: datetime.date """ if not self.pane.collection.writable_names: self.pane.window.alert(('alert', 'No writable calendar.')) return if end is None: start = datetime.combine(date, time(datetime.now().hour)) end = start + timedelta(minutes=60) event = utils.new_event( dtstart=start, dtend=end, summary="new event", timezone=self._conf['locale']['default_timezone'], locale=self._conf['locale'], ) else: event = utils.new_event( dtstart=date, dtend=end + timedelta(days=1), summary="new event", allday=True, locale=self._conf['locale'], ) event = self.pane.collection.new_event( event.to_ical(), self.pane.collection.default_calendar_name) self.edit(event) def selectable(self): return True def keypress(self, size, key): prev_shown = self._eventshown self._eventshown = False 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): 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)) # 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( '{} {}'.format(self._conf['locale']['dateformat'], 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( '{} {}'.format(self._conf['locale']['dateformat'], 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): class Search(Edit): def keypress(self, size, key): if key == 'enter': search_func(self.text) 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(search_field) buttons = NColumns([urwid.Button('Search', on_press=this_func), urwid.Button('Abort', on_press=abort_func)]) 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='', description=''): self.init = True # Will be set when opening the view inside a Window self.window = None self._conf = conf self.collection = collection self._deleted = {ALL: [], INSTANCES: []} ContainerWidget = linebox[self._conf['view']['frame']] if self._conf['view']['dynamic_days']: Walker = DayWalker else: Walker = StaticDayWalker daywalker = Walker( 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(EventColumn(pane=self, elistbox=elistbox)) calendar = 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'], get_styles=collection.get_styles ) 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 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): if uid[0] in self._deleted[ALL]: return ALL elif uid in self._deleted[INSTANCES]: return INSTANCES else: return False def toggle_delete_all(self, recuid): uid, _ = recuid if uid in self._deleted[ALL]: self._deleted[ALL].remove(uid) else: self._deleted[ALL].append(uid) def toggle_delete_instance(self, uid): if uid in self._deleted[INSTANCES]: self._deleted[INSTANCES].remove(uid) else: self._deleted[INSTANCES].append(uid) def cleanup(self, data): """delete all events marked for deletion""" for part in self._deleted[ALL]: account, href, etag = part.split('\n', 2) self.collection.delete(href, etag, account) for part, rec_id in self._deleted[INSTANCES]: account, href, etag = part.split('\n', 2) event = self.collection.get_event(href, account) event.delete_instance(rec_id) self.collection.update(event) def keypress(self, size, key): 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): """search for events matching `search_term""" self.window.backtrack() events = list(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="Search results for \"{}\" (Esc for backtrack)".format(search_term), ) pane._conf = self._conf columns.set_focus_column(1) self.window.open(pane) def render(self, size, focus=False): rval = super(ClassicView, self).render(size, focus) if self.init: # starting with today's events self.eventscolumn.current_date = 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, color, hmethod): """Create an urwid compatible palette entry. :param name: name of the new attribute in the palette :type name: string :param color: color for the new attribute :type color: string :returns: an urwid palette entry :rtype: tuple """ 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 else: # 24-bit colors are not supported by urwid. # Convert it 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, '') else: return (name, '', '', '', '', color) def _add_calendar_colors(palette, collection): """Add the colors for the defined calendars to the palette. :param palette: the base palette :type palette: list :param collection: :type collection: CalendarCollection :returns: the modified palette :rtype: list """ 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'] palette.append(_urwid_palette_entry('calendar ' + cal['name'], color, collection.hmethod)) palette.append(_urwid_palette_entry('highlight_days_color', collection.color, collection.hmethod)) palette.append(_urwid_palette_entry('highlight_days_multiple', collection.multiple, collection.hmethod)) return palette def start_pane(pane, callback, program_info='', quit_keys=['q']): """Open the user interface with the given initial pane.""" frame = Window( footer=program_info + ' | {}: quit, ?: help'.format(quit_keys[0]), quit_keys=quit_keys, ) frame.open(pane, callback) palette = _add_calendar_colors( getattr(colors, pane._conf['view']['theme']), pane.collection) loop = urwid.MainLoop( frame, palette, unhandled_input=frame.on_key_press, pop_ups=True) frame.loop = loop def redraw_today(loop, pane, meta={'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 = date.today() if meta['last_today'] != today: meta['last_today'] = today pane.calendar.original_widget.reset_styles_range(today - 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) # Make urwid use 256 color mode. loop.screen.set_terminal_properties( colors=256, 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.9.10/khal/ui/widgets.py0000644000076600000240000005652713357150322020240 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 """ from datetime import date, datetime, timedelta import re import urwid 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, key): 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(ExtendedEdit, self).keypress(size, key) 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, on_date_change=lambda x: None, **kwargs): 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'] 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(DateTimeWidget, self).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): """set a new value for this widget :type new_date: datetime.date """ self.set_edit_text(new_date.strftime(self.dateformat)) class DateWidget(DateTimeWidget): dtype = date timedelta = timedelta(days=1) def _get_current_value(self): try: new_date = datetime.strptime(self.get_edit_text(), self.dateformat).date() except ValueError: raise DateConversionError else: return new_date class TimeWidget(DateTimeWidget): dtype = datetime timedelta = timedelta(minutes=15) def _get_current_value(self): try: new_datetime = datetime.strptime(self.get_edit_text(), self.dateformat) except ValueError: raise DateConversionError else: return new_datetime class Choice(urwid.PopUpLauncher): def __init__( self, choices, active, decorate_func=None, overlay_width=32, callback=lambda: 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): self.parent = parent self._callback = callback buttons = [] for c in parent.choices: buttons.append( urwid.Button(parent._decorate(c), 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.set_focus(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(object): """classes inheriting from SupportsNext must implement the following methods: _select_first_selectable _select_last_selectable """ def __init__(self, *args, **kwargs): self.outermost = kwargs.get('outermost', False) if 'outermost' in kwargs: kwargs.pop('outermost') super(SupportsNext, self).__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.set_focus(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.set_focus(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(NextMixin, self).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.set_focus(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.set_focus(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.set_focus(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.set_focus(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): assert validate self._validate_func = validate self._original_widget = urwid.AttrMap(EditWidget(*args, **kwargs), 'edit', 'editf') 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'] 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): """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, dt): days, hours, minutes, seconds = self._convert_timedelta(dt) 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): return 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, delete_handler): 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) 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, urwid.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): self.event = event self.pile = NPile( [urwid.Text('Alarms:')] + [self.AlarmEditor(a, self.remove_alarm) for a in event.alarms] + [urwid.Columns([(12, urwid.Button('Add', on_press=self.add_alarm))])]) urwid.WidgetWrap.__init__(self, self.pile) def add_alarm(self, button): self.pile.contents.insert( len(self.pile.contents) - 1, (self.AlarmEditor((timedelta(0), 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): hline = urwid.Divider('─') hline_focus = urwid.Divider('━') self._vline = urwid.SolidFill('│') self._vline_focus = urwid.SolidFill('┃') self._topline = urwid.Columns([ ('fixed', 1, urwid.Text('┌')), hline, ('fixed', 1, urwid.Text('┐')), ]) self._topline_focus = urwid.Columns([ ('fixed', 1, urwid.Text('┏')), hline_focus, ('fixed', 1, urwid.Text('┓')), ]) self._bottomline = urwid.Columns([ ('fixed', 1, urwid.Text('└')), hline, ('fixed', 1, urwid.Text('┘')), ]) self._bottomline_focus = urwid.Columns([ ('fixed', 1, urwid.Text('┗')), hline_focus, ('fixed', 1, urwid.Text('┛')), ]) 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): 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): 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, } khal-0.9.10/khal/ui/editor.py0000644000076600000240000006564113357150322020055 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 datetime import datetime, time import datetime as dt import urwid from ..utils import get_weekday_occurrence from .widgets import DateWidget, TimeWidget, NColumns, NPile, ValidatedEdit, \ DateConversionError, Choice, PositiveIntEdit, AlarmsEditor, NListBox, ExtendedEdit from .calendarwidget import CalendarWidget class StartEnd(object): def __init__(self, startdate, starttime, enddate, endtime): """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=False, firstweekday=0, keybindings=None): self._on_date_change = on_date_change self._weeknumbers = weeknumbers self._firstweekday = firstweekday self._keybindings = {} if keybindings is None else keybindings self.__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, initial=initial_date) pop_up = urwid.LineBox(pop_up) 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=None, dateformat='%Y-%m-%d', on_date_change=lambda _: None, weeknumbers=False, firstweekday=0, keybindings=None, ): datewidth = len(startdt.strftime(dateformat)) + 1 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, keybindings) padded = urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1) super().__init__(padded) def _validate(self, text): try: _date = 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(date.strftime(self._dateformat)) class StartEndEditor(urwid.WidgetWrap): """Widget for editing start and end times (of an event).""" def __init__(self, start, end, conf, on_start_date_change=lambda x: None, on_end_date_change=lambda x: None, ): """ :type start: datetime.datetime :type end: datetime.datetime :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 """ self.allday = not isinstance(start, datetime) self.conf = conf self._startdt, self._original_start = start, start self._enddt, self._original_end = end, end self.on_start_date_change = on_start_date_change self.on_end_date_change = on_end_date_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, datetime): return self._startdt.date() else: return self._startdt @property def _start_time(self): try: return self._startdt.time() except AttributeError: return 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, datetime): return self._enddt.date() else: return self._enddt @property def _end_time(self): try: return self._enddt.time() except AttributeError: return time(0) def _validate_start_time(self, text): try: startval = datetime.strptime(text, self.conf['locale']['timeformat']) self._startdt = self.localize_start( 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(datetime.combine(date, self._start_time)) self.on_start_date_change(date) def _validate_end_time(self, text): try: endval = datetime.strptime(text, self.conf['locale']['timeformat']) self._enddt = self.localize_end(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(datetime.combine(date, self._end_time)) self.on_end_date_change(date) def toggle(self, checkbox, state): """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 :type state: bool """ if self.allday is True and state is False: self._startdt = datetime.combine(self._startdt, datetime.min.time()) self._enddt = datetime.combine(self._enddt, datetime.min.time()) elif self.allday is False and state is True: 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['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['keybindings'], ) if state is True: timewidth = 1 self.widgets.starttime = urwid.Text('') self.widgets.endtime = urwid.Text('') elif state is False: timewidth = self._timewidth + 1 edit = ValidatedEdit( dateformat=self.conf['locale']['timeformat'], EditWidget=TimeWidget, validate=self._validate_start_time, edit_text=self.startdt.strftime(self.conf['locale']['timeformat']), ) edit = urwid.Padding( edit, align='left', width=self._timewidth + 1, left=1) self.widgets.starttime = edit edit = ValidatedEdit( dateformat=self.conf['locale']['timeformat'], EditWidget=TimeWidget, validate=self._validate_end_time, edit_text=self.enddt.strftime(self.conf['locale']['timeformat']), ) edit = urwid.Padding( edit, align='left', width=self._timewidth + 1, left=1) self.widgets.endtime = edit 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, save_callback=None, always_save=False): """ :type event: khal.event.Event :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 :type always_save: bool """ 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.categories = event.categories self.startendeditor = StartEndEditor( event.start_local, event.end_local, self._conf, self.start_datechange, self.end_datechange, ) # 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 = ExtendedEdit(caption='Title: ', edit_text=event.summary) divider = urwid.Divider(' ') def decorate_choice(c): return ('calendar ' + c['name'], c['name']) self.calendar_chooser = Choice( [self.collection._calendars[c] for c in self.collection.writable_names], self.collection._calendars[self.event.calendar], decorate_choice ) self.description = ExtendedEdit( caption='Description: ', edit_text=self.description, multiline=True, ) self.location = ExtendedEdit(caption='Location: ', edit_text=self.location) self.categories = ExtendedEdit(caption='Categories: ', edit_text=self.categories) self.alarms = AlarmsEditor(self.event) self.pile = NListBox(urwid.SimpleFocusListWalker([ NColumns([self.summary, self.calendar_chooser], dividechars=2), divider, self.location, self.categories, self.description, divider, self.startendeditor, self.recurrenceeditor, divider, self.alarms, divider, urwid.Button('Save', on_press=self.save), urwid.Button('Export', on_press=self.export) ]), 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) @property def title(self): # Window title return 'Edit: {}'.format(self.summary.get_edit_text()) @classmethod def selectable(cls): return True @property def changed(self): if self.summary.get_edit_text() != self.event.summary: return True if self.description.get_edit_text() != self.event.description: return True if self.location.get_edit_text() != self.event.location: return True if self.categories.get_edit_text() != self.event.categories: return True if self.startendeditor.changed or self.calendar_chooser.changed: return True if self.recurrenceeditor.changed: return True if self.alarms.changed: return True return False def update_vevent(self): self.event.update_summary(self.summary.get_edit_text()) self.event.update_description(self.description.get_edit_text()) self.event.update_location(self.location.get_edit_text()) self.event.update_categories(self.categories.get_edit_text()) 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.alarms.changed: self.event.update_alarms(self.alarms.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.active['name'] self.collection.new(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, key): 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 else: self._abort_confirmed = False if key in self.pane._conf['keybindings']['save']: self.save(None) return return super().keypress(size, key) WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] # TODO use locale and respect weekdaystart class WeekDaySelector(urwid.WidgetWrap): def __init__(self, startdt, selected_days): 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): 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 = Choice( ["daily", "weekly", "monthly", "yearly"], recurrence, callback=self.rebuild, ) self.interval_edit = PositiveIntEdit( caption='every:', edit_text=str(self._rrule.get('INTERVAL', [1])[0]), ) self.until_choice = Choice( ["Forever", "Until", "Repetitions"], self._until, callback=self.rebuild, ) 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, datetime): until = self._startdt.date() elif until is None: until = self._startdt if isinstance(until, datetime): until = until.date() self.until_edit = DateEdit( until, self._conf['locale']['longdateformat'], lambda _: None, self._conf['locale']['weeknumbers'], self._conf['locale']['firstweekday'], ) 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 = 'on every {}{} {}'.format( xth, ords.get(xth, 'th'), WEEKDAYS[weekday], ) self._xth_monthday = 'on every {}{} of the month'.format( self._startdt.day, ords.get(self._startdt.day, 'th'), ) 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', 'BYSETPOS', } 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 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), (11, self.recurrence_choice), (11, 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 = [(16, self.until_choice)] if self.until_choice.active == "Until": nextline.append((20, 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): return self._rrule != self.rrule() # TODO do this properly def rrule(self): rrule = dict() 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'] = ['{}{}'.format(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): 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( urwid.Button('Save', on_press=this_func, user_data=export_location) ) content = NPile(lines) urwid.WidgetWrap.__init__(self, urwid.LineBox(content)) khal-0.9.10/khal/ui/calendarwidget.py0000644000076600000240000005757413357150322021552 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 from collections import defaultdict from datetime import date from locale import getlocale, setlocale, LC_ALL, LC_TIME from khal.utils import get_month_abbr_len import urwid setlocale(LC_ALL, '') def getweeknumber(day): """return iso week number for datetime.date object :param day: date :type day: datetime.date() :return: weeknumber :rtype: int """ return date.isocalendar(day)[1] class DatePart(urwid.Text): """used in the Date widget (single digit)""" def __init__(self, digit): super(DatePart, self).__init__(digit) @classmethod def selectable(cls): return True def keypress(self, _, key): return key def get_cursor_coords(self, size): return 1, 0 def render(self, size, focus=False): 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, get_styles=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(Date, self).__init__(urwid.Columns(self.halves)) def set_styles(self, styles): """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=False): self.set_styles(self._get_styles(self.date, focus)) @property def marked(self): if 'mark' in [self.halves[0].attr_map[None], self.halves[1].attr_map[None]]: return True else: return False @classmethod def selectable(cls): return True def keypress(self, _, key): 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, on_press, keybindings, get_styles=None, **kwargs): self.on_date_change = on_date_change self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles self._init = True super(DateCColumns, self).__init__(widget_list, **kwargs) def __repr__(self): return ''.format(self[1].date, self[7].date) def _clear_cursor(self): old_pos = self.focus_position self.contents[old_pos][0].set_styles( self.get_styles(self.contents[old_pos][0].date, False)) def _set_focus_position(self, position): """calls on_date_change before calling super()._set_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) super(DateCColumns, self)._set_focus_position(position) def set_focus_date(self, a_date): for num, day in enumerate(self.contents[1:8], 1): if day[0].date == a_date: self._set_focus_position(num) return None raise ValueError('%s not found in this week' % a_date) def get_date_column(self, a_date): """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) focus_position = property( urwid.Columns._get_focus_position, _set_focus_position, doc=('Index of child widget in focus. Raises IndexError if read when ' 'CColumns is empty, or when set to an invalid index.') ) def keypress(self, size, key): """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(DateCColumns, self).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']: # XXX 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): self._init = True self.keybindings = walker.keybindings self.on_press = walker.on_press self._marked = False self._pos_old = False super(CListBox, self).__init__(walker) def render(self, size, focus=False): if self._init: while 'bottom' in self.ends_visible(size): self.body._autoextend() self.set_focus_valign('middle') self._init = False return super(CListBox, self).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, column): """return the date at row `row` and column `column`""" return self.body[row].contents[column][0].date def _unmark_one(self, row, column): """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, column): """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=None): """make sure everything between the marked entry and `a_date` is visually marked, and nothing else""" if a_date is None: a_date = self.body.focus_date def toggle(row, 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): 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): 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, key): if key in self.keybindings['mark'] + ['esc'] and self._marked: self._unmark_all() self._marked = False return 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(date.today()) self.set_focus_valign(('relative', 10)) key = super(CListBox, self).keypress(size, key) if self._marked: self._mark() return key class CalendarWalker(urwid.SimpleFocusListWalker): def __init__(self, on_date_change, on_press, keybindings, firstweekday=0, weeknumbers=False, get_styles=None, initial=None): if initial is None: initial = date.today() self.firstweekday = firstweekday self.weeknumbers = weeknumbers self.on_date_change = on_date_change self.on_press = on_press self.keybindings = keybindings self.get_styles = get_styles weeks = self._construct_month(initial.year, initial.month) urwid.SimpleFocusListWalker.__init__(self, weeks) def set_focus(self, position): """set focus by item number""" while position >= len(self) - 1: self._autoextend() while position <= 0: no_additional_weeks = self._autoprepend() position += no_additional_weeks return urwid.SimpleFocusListWalker.set_focus(self, position) @property def focus_date(self): """return the date the focus is currently set to :rtype: datetime.date """ return self[self.focus].focus.date def set_focus_date(self, a_day): """set the focus to `a_day` :type: a_day: datetime.date """ row, column = self.get_date_pos(a_day) self.set_focus(row) self[self.focus]._set_focus_position(column) @property def earliest_date(self): """return earliest day that is already loaded into the CalendarWidget""" return self[0][1].date @property def latest_date(self): """return latest day that is already loaded into the CalendarWidget""" return self[-1][7].date def reset_styles_range(self, min_date, max_date): """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): """get row and column where `a_day` is located :type: a_day: datetime.date :rtype: tuple(int, int) """ # 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 if 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: if row >= len(self): self._autoextend() column = self[row].get_date_column(a_day) return row, column except ValueError: pass # we didn't find the date we were looking for... raise ValueError('something is wrong') def _autoextend(self): """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): """prepends the previous month :returns: number of weeks prepended :rtype: int """ 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): """ 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 and True or False depending on if today is in this week :rtype: tuple(urwid.CColumns, bool) """ if 1 in [day.day for day in week]: month_name = calendar.month_abbr[week[-1].month].ljust(4) attr = 'monthname' elif self.weeknumbers == 'left': month_name = ' {:2} '.format(getweeknumber(week[0])) attr = 'weeknumber_left' else: month_name = ' ' attr = None 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('{:2}'.format(getweeknumber(week[0]))), '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=date.today().year, month=date.today().month, clean_first_row=False, clean_last_row=False): """construct one month of DateCColumns :param year: the year this month is set in :type year: int :param month: the number of the month to be constructed :type month: int (1-12) :param clean_first_row: 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 :type clean_first_row: bool :param clean_last_row: 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 :type clean_last_row: bool :returns: list of DateCColumns and the number of the list element which contains today (or None if it isn't in there) :rtype: tuple(list(dateCColumns, int or None)) """ plain_weeks = calendar.Calendar( self.firstweekday).monthdatescalendar(year, month) weeks = list() for number, week in enumerate(plain_weeks): week = self._construct_week(week) weeks.append(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, keybindings, on_press, firstweekday=0, weeknumbers=False, get_styles=None, initial=None): """ :param on_date_change: a function that is called every time the selected date is changed with the newly selected date as a first (and only argument) :type on_date_change: function :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 direction 'today': refocus on today 'mark': toggles selection mode :type keybindings: dict :param on_press: dictonary of functions that are called when the key is pressed and is not already bound to one of the internal functionions 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. :type on_press: dict """ if initial is None: self._initial = date.today() else: self._initial = initial default_keybindings = { 'left': ['left'], 'down': ['down'], 'right': ['right'], 'up': ['up'], 'today': ['t'], 'view': [], 'mark': ['v'], } on_press = defaultdict(lambda: lambda x: x, on_press) default_keybindings.update(keybindings) calendar.setfirstweekday(firstweekday) try: mylocale = '.'.join(getlocale(LC_TIME)) except TypeError: # language code and encoding may be None mylocale = 'C' _calendar = calendar.LocaleTextCalendar(firstweekday, mylocale) weekheader = _calendar.formatweekheader(2) dnames = weekheader.split(' ') def _get_styles(date, focus): if focus: if date == date.today(): return 'today focus' else: return 'reveal focus' else: if date == 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() dnames = 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_press, default_keybindings, firstweekday, weeknumbers, get_styles, initial=self._initial) self.box = CListBox(self.walker) frame = urwid.Frame(self.box, header=dnames) urwid.WidgetWrap.__init__(self, frame) self.set_focus_date(self._initial) def focus_today(self): self.set_focus_date(date.today()) def reset_styles_range(self, min_date, max_date): self.walker.reset_styles_range(min_date, max_date) @property def focus_date(self): return self.walker.focus_date def set_focus_date(self, a_day): """set the focus to `a_day` :type a_day: datetime.date """ self.box.set_focus_date(a_day) khal-0.9.10/khal/ui/base.py0000644000076600000240000001606213357150322017472 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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""" import urwid import threading import time from .widgets import NColumns class Pane(urwid.WidgetWrap): """An abstract Pane to be used in a Window object.""" def __init__(self, widget, title=None, description=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, buttons): """Open a dialog box. :param text: Text to appear as the body of the Dialog box :type text: str :param buttons: list of tuples button labels and functions to call when the button is pressed :type buttons: list(str, callable) """ 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) self.window.open(overlay) def keypress(self, size, key): """Handle application-wide key strokes.""" if key in ['f1', '?']: self.show_keybindings() else: return super().keypress(size, key) def show_keybindings(self): lines = list() lines.append(' Command Keys') lines.append(' ======= ====') for command, keys in self._conf['keybindings'].items(): lines.append(' {:20} {}'.format(command, keys)) lines.append('') lines.append("Press `Escape` to close this window") self.dialog('\n'.join(lines), []) 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=['q']): self._track = [] 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 self._alert_daemon = AlertDaemon(self.update_header) self._alert_daemon.start() self.alert = self._alert_daemon.alert self.loop = None 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.update_header() def _get_current_pane(self): return self._track[-1][0] if self._track else None def update_header(self, alert=None): """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) """ 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): 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.9.10/khal/ui/colors.py0000644000076600000240000000730313357150322020057 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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. dark = [ ('header', 'white', 'black'), ('footer', 'white', 'black'), ('line header', 'black', 'white', 'bold'), ('bright', 'dark blue', 'white', ('bold', 'standout')), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), ('edit focused', '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', 'light gray', ''), ('date focused', 'black', 'light gray', ('bold', 'standout')), ('date selected', 'white', 'yellow'), ('dayname', 'light gray', ''), ('monthname', 'light gray', ''), ('weeknumber_right', 'light gray', ''), ('edit', 'white', 'dark blue'), ('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'), ('editfc', 'white', 'dark blue', 'bold'), ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), ] light = [ ('header', 'black', 'white'), ('footer', 'black', 'white'), ('line header', 'black', 'white', 'bold'), ('bright', 'dark blue', 'white', ('bold', 'standout')), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), ('edit focused', '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', 'dark cyan'), ('date', '', 'white'), ('date focused', 'white', 'dark gray', ('bold', 'standout')), ('date selected', 'dark gray', 'light cyan'), ('dayname', 'dark gray', 'white'), ('monthname', 'dark gray', 'white'), ('weeknumber_right', 'dark gray', 'white'), ('edit', 'white', 'dark blue'), ('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'), ('editfc', 'white', 'dark blue', 'bold'), ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), ] khal-0.9.10/khal/settings/0000755000076600000240000000000013357150672017434 5ustar christiangeierstaff00000000000000khal-0.9.10/khal/settings/__init__.py0000644000076600000240000000013613243067215021537 0ustar christiangeierstaff00000000000000from .settings import get_config # noqa from .exceptions import InvalidSettingsError # noqa khal-0.9.10/khal/settings/utils.py0000644000076600000240000001733213357150322021144 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 os.path import expandvars, expanduser, join import os import glob import pytz import xdg from tzlocal import get_localzone from validate import VdtValueError from ..log import logger from .exceptions import InvalidSettingsError from ..terminal import COLORS from ..khalendar.vdir import Vdir, CollectionNotFoundError from ..utils import guesstimedeltafstr def is_timezone(tzstring): """tries to convert tzstring into a pytz timezone raises a VdtvalueError if tzstring is not valid """ if not tzstring: return get_localzone() try: return pytz.timezone(tzstring) except pytz.UnknownTimeZoneError: raise VdtValueError("Unknown timezone {}".format(tzstring)) def is_timedelta(string): try: return guesstimedeltafstr(string) except ValueError: raise VdtValueError("Invalid timedelta: {}".format(string)) def weeknumber_option(option): """checks if *option* is a valid value :param option: the option the user set in the config file :type option: str :returns: off, left, right :rtype: str/bool """ 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( "Invalid value '{}' for option 'weeknumber', must be one of " "'off', 'left' or 'right'".format(option)) def expand_path(path): """expands `~` as well as variable names""" return expanduser(expandvars(path)) def expand_db_path(path): """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): """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): """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( "in section [default] {} is not valid for 'default_calendar', " "must be one of {}".format(config['default']['default_calendar'], 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): try: color = Vdir(path, '.ics').get_meta('color') except CollectionNotFoundError: color = None if color is None or color is '': logger.debug('Found no or empty file `color` in {}'.format(path)) return None color = color.strip() try: is_color(color) except VdtValueError: logger.warning("Found invalid color `{}` in {}color".format(color, path)) color = None return color def get_unique_name(path, names): # TODO take care of edge cases, make unique name finding less brain-dead name = Vdir(path, '.ics').get_meta('displayname') if name is None or name == '': logger.debug('Found no or empty file `displayname` in {}'.format(path)) name = os.path.split(path)[-1] if name in names: while name in names: name = name + '1' return name def get_all_vdirs(path): """returns a list of paths, expanded using glob """ items = glob.glob(path) return items def get_vdir_type(_): # TODO implement return 'calendar' def config_checks( config, _get_color_from_vdir=get_color_from_vdir, _get_vdir_type=get_vdir_type): """do some tests on the config we cannot do with configobj's validator""" 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 vdirs_complete = list() vdir_colors_from_config = {} for calendar in list(config['calendars'].keys()): if config['calendars'][calendar]['type'] == 'discover': logger.debug( 'discovering calendars in {}'.format(config['calendars'][calendar]['path']) ) vdirs = get_all_vdirs(config['calendars'][calendar]['path']) vdirs_complete += vdirs if 'color' in config['calendars'][calendar]: for vdir in vdirs: vdir_colors_from_config[vdir] = config['calendars'][calendar]['color'] config['calendars'].pop(calendar) for vdir in sorted(vdirs_complete): calendar = {'path': vdir, 'color': _get_color_from_vdir(vdir), 'type': _get_vdir_type(vdir), 'readonly': False } # get color from config if not defined in vdir if calendar['color'] is None and vdir in vdir_colors_from_config: logger.debug("using collection's color for {}".format(vdir)) calendar['color'] = vdir_colors_from_config[vdir] name = get_unique_name(vdir, config['calendars'].keys()) config['calendars'][name] = calendar 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']) khal-0.9.10/khal/settings/settings.py0000644000076600000240000001534313357150322021644 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 configobj import ConfigObj, flatten_errors, get_extra_values, \ ConfigObjError from validate import Validator import xdg.BaseDirectory from .exceptions import InvalidSettingsError, CannotParseConfigFileError, NoConfigFile from khal import __productname__ from ..log import logger from .utils import is_timezone, is_timedelta, weeknumber_option, config_checks, \ expand_path, expand_db_path, is_color, get_vdir_type, get_color_from_vdir SPECPATH = os.path.join(os.path.dirname(__file__), 'khal.spec') def find_configuration_file(): """Return the configuration filename. This function builds the list of paths known by khal and then return the first one which exists. The first paths searched are the ones described in the XDG Base Directory Standard, e.g. ~/.config/khal/config, additionally ~/.config/khal/khal.conf is searched (deprecated). All other paths end with DEFAULT_PATH/DEFAULT_FILE. On failure, the path DEFAULT_PATH/DEFAULT_FILE, prefixed with a dot, is searched in the home user directory. Ultimately, DEFAULT_FILE is searched in the current directory. """ DEFAULT_FILE = __productname__ + '.conf' DEFAULT_PATH = __productname__ resource = os.path.join(DEFAULT_PATH, DEFAULT_FILE) paths = [] paths = [os.path.join(path, os.path.join(DEFAULT_PATH, 'config')) for path in xdg.BaseDirectory.xdg_config_dirs] for path in paths: if os.path.exists(path): return path # remove this part for v0.10.0 paths = [os.path.join(path, resource) for path in xdg.BaseDirectory.xdg_config_dirs] for path in paths: if os.path.exists(path): logger.warning( 'Deprecation Warning: configuration file path `{}` will not be ' 'supported from khal v0.10.0 onwards, please move it to ' '`{}`.' ''.format(path, path.replace('khal.conf', 'config'))) return path paths = [] paths.append(os.path.expanduser(os.path.join('~', '.' + resource))) paths.append(os.path.expanduser(DEFAULT_FILE)) # remove this part for v0.11.0 for path in paths: if os.path.exists(path): logger.warning( 'Deprecation Warning: configuration file path `{}` will not be ' 'supported from v0.11.0 onwards, please move it to ' '`{}/khal/config`.' ''.format(path, xdg.BaseDirectory.xdg_config_dirs[0])) return path return None def get_config( config_path=None, _get_color_from_vdir=get_color_from_vdir, _get_vdir_type=get_vdir_type): """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 :type config_path: str :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 :rtype: dict """ 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('using the config file at {}'.format(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: ' '{}'.format(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, 'color': is_color, } validator = Validator(fdict) results = user_config.validate(validator, preserve_errors=True) abort = False for section, subsection, error in flatten_errors(user_config, results): abort = True if isinstance(error, Exception): logger.fatal( 'config error:\n' 'in [{}] {}: {}'.format(section[0], subsection, error)) else: for key in error: if isinstance(error[key], Exception): logger.fatal('config error:\nin {} {}: {}'.format( sectionize(section + [subsection]), key, str(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('unknown section "{}" in config file'.format(value)) else: section = sectionize(section) logger.warning( 'unknown key or subsection "{}" in section "{}"'.format(value, section)) return user_config def sectionize(sections, depth=1): """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.9.10/khal/settings/exceptions.py0000644000076600000240000000247613243067215022172 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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.9.10/khal/settings/khal.spec0000644000076600000240000003146013357150322021223 0ustar christiangeierstaff00000000000000[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. # 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 the color is set to *auto* (the default), khal tries to read the file # *color* from this calendar's vdir, if this fails the default_color (see # below) is used. If color is set to '', the default_color is always used. color = color(default='auto') # 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 ``.ics`` 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') [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, were 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``. timeformat = string(default='%H:%M') # khal will display and understand all dates in this format, see :ref:`timeformat ` for the format dateformat = string(default='%d.%m.') # 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='%d.%m.%Y') # khal will display and understand all datetimes in this format, see # :ref:`timeformat ` for the format. datetimeformat = string(default='%d.%m. %H:%M') # 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='%d.%m.%Y %H:%M') # Enable weeknumbers in `calendar` and `interactive` (ikhal) mode. As those are # iso weeknumbers, they only work properly if `firstweekday` is set to 0 weeknumbers = weeknumbers(default='off') # Keybindings for :command:`ikhal` are set here. You can bind more then 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('/')) # quit quit = force_list(default=list('q', 'Q')) # Some default values and behaviors are set here. [default] # Command to be executed if no command is given when executing khal. default_command = option('calendar', 'list', 'interactive', 'printformats', 'printcalendars', 'printics', '', default='calendar') # 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') # 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) # Choose a color theme for khal. # # This is very much work in progress. Help is really welcome! The two currently # available color schemes (*dark* and *light*) are defined in # *khal/ui/colors.py*, you can either help improve those or create a new one # (see below). As ikhal uses urwid, have a look at `urwid's documentation`__ # for how to set colors and/or at the existing schemes. If you cannot change # the color of an element (or have any other problems) please open an issue on # github_. # # If you want to create your own color scheme, copy the structure of the # existing ones, give it a new and unique name and also add it as an option in # `khal/settings/khal.spec` in the section `[default]` of the property `theme`. # # __ http://urwid.org/manual/displayattributes.html # .. _github: # https://github.com/pimutils/khal/issues theme = option('dark', 'light', 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}{description-separator}{description}{reset}') # Specifies how each *day header* is formatted. agenda_day_format = string(default='{bold}{name}, {date-long}{reset}') # 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}{description-separator}{description}{reset}') # 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='') # 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='') khal-0.9.10/khal/version.py0000644000076600000240000000016513357150672017635 0ustar christiangeierstaff00000000000000# coding: utf-8 # file generated by setuptools_scm # don't change, don't track in version control version = '0.9.10' khal-0.9.10/khal/terminal.py0000644000076600000240000001273613357150322017762 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 collections import namedtuple from itertools import zip_longest NamedColor = namedtuple('NamedColor', ['index', 'light']) RTEXT = '\x1b[7m' # reverse NTEXT = '\x1b[0m' # normal BTEXT = '\x1b[1m' # bold RESET = '\33[0m' COLORS = { 'black': NamedColor(index=0, light=False), 'dark red': NamedColor(index=1, light=False), 'dark green': NamedColor(index=2, light=False), 'brown': NamedColor(index=3, light=False), 'dark blue': NamedColor(index=4, light=False), 'dark magenta': NamedColor(index=5, light=False), 'dark cyan': NamedColor(index=6, light=False), 'white': NamedColor(index=7, light=False), 'light gray': NamedColor(index=7, light=True), 'dark gray': NamedColor(index=0, light=True), # actually light black 'light red': NamedColor(index=1, light=True), 'light green': NamedColor(index=2, light=True), 'yellow': NamedColor(index=3, light=True), 'light blue': NamedColor(index=4, light=True), 'light magenta': NamedColor(index=5, light=True), 'light cyan': NamedColor(index=6, light=True) } def get_color(fg=None, bg=None, bold_for_light_color=False): """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 :type fg: str :param bg: background color :type bg: str :returns: ANSI color code :rtype: str """ 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].index if COLORS[colorstring].light: if bold_for_light_color: color += '1;' else: c += 60 else: # background color c = 40 + COLORS[colorstring].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 += '38;2;{!s};{!s};{!s}'.format(r, g, b) else: color += '48;2;{!s};{!s};{!s}'.format(r, g, b) color += 'm' result += color return result def colored(string, fg=None, bg=None, bold_for_light_color=True): """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 :type string: str :returns: colorized string :rtype: str """ result = get_color(fg, bg, bold_for_light_color) result += string if fg or bg: result += RESET return result def merge_columns(lcolumn, rcolumn, width=25): """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 * ' '] rows = [' '.join(one) for one in zip_longest( lcolumn, rcolumn, fillvalue='')] return rows khal-0.9.10/khal/log.py0000644000076600000240000000421013357150322016714 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 from khal import __productname__ class ColorFormatter(logging.Formatter): colors = { 'error': dict(fg='red'), 'exception': dict(fg='red'), 'critical': dict(fg='red'), 'debug': dict(fg='blue'), 'warning': dict(fg='yellow') } def format(self, record): if not record.exc_info: level = record.levelname.lower() if level in self.colors: prefix = click.style('{}: '.format(level), **self.colors[level]) record.msg = '\n'.join(prefix + x for x in str(record.msg).splitlines()) return logging.Formatter.format(self, record) class ClickStream: def write(self, string): click.echo(string, file=sys.stderr, nl=False) stdout_handler = logging.StreamHandler(ClickStream()) stdout_handler.formatter = ColorFormatter() logger = logging.getLogger(__productname__) logger.setLevel(logging.INFO) logger.addHandler(stdout_handler) khal-0.9.10/khal/controllers.py0000644000076600000240000005574413357150322020523 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 icalendar from click import confirm, echo, style, prompt from .khalendar.vdir import Item from .exceptions import ConfigurationError import pytz from collections import defaultdict, OrderedDict from shutil import get_terminal_size from datetime import time, timedelta, datetime, date import os import textwrap from khal import utils, calendar_display from khal.khalendar.exceptions import ReadOnlyCalendarError, DuplicateUid from khal.exceptions import FatalError from khal.khalendar.event import Event from khal.khalendar.backend import sort_key from khal import __version__, __productname__ from khal.log import logger from .terminal import merge_columns def format_day(day, format_string, locale, attributes=None): if attributes is None: attributes = {} attributes["date"] = day.strftime(locale['dateformat']) attributes["date-long"] = day.strftime(locale['longdateformat']) attributes["name"] = utils.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, agenda_format=None, notstarted=False, once=False, daterange=None, day_format=None, locale=None, conf=None, firstweekday=0, weeknumber=False, hmethod='fg', default_color='', multiple='', color='', highlight_event_days=0, full=False, bold_for_light_color=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, ) calendar_column = calendar_display.vertical_month( month=start.month, year=start.year, count=max(3, (end.year - start.year) * 12 + end.month - start.month + 1), firstweekday=firstweekday, weeknumber=weeknumber, collection=collection, hmethod=hmethod, default_color=default_color, multiple=multiple, 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, locale, default_timedelta_date=timedelta(days=1), default_timedelta_datetime=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` :type daterange: tuple :param locale: locale settings :type locale: dict """ if not daterange: start = datetime(*date.today().timetuple()[:3]) end = start + default_timedelta_date else: start, end, allday = utils.guessrangefstr( daterange, locale, default_timedelta_date=default_timedelta_date, default_timedelta_datetime=default_timedelta_datetime, ) return start, end def get_events_between( collection, locale, start, end, agenda_format=None, notstarted=False, env=None, width=None, seen=None, original_start=None): """returns a list of events scheduled between start and end. Start and end are strings or datetimes (of some kind). :param collection: :type collection: khalendar.CalendarCollection :param start: the start datetime :param end: the end datetime :param agenda_format: a format string that can be used in python string formatting :type agenda_format: str :param env: a collection of "static" values like calendar names and color :type env: dict :param nostarted: True if each event should start after start (instead of be active between start and end) :type nostarted: bool :param original_start: start datetime to compare against of notstarted is set :type original_start: datetime.datetime :returns: a list to be printed as the agenda for the given days :rtype: list(str) """ 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_string = event.format(agenda_format, relative_to=(start, end), env=env) except KeyError as error: raise FatalError(error) if width: event_list += utils.color_wrap(event_string, width) else: event_list.append(event_string) if seen is not None: seen.add(event.uid) return event_list def khal_list(collection, daterange=None, conf=None, agenda_format=None, day_format=None, once=False, notstarted=False, width=False, env=None, datepoint=None): assert daterange is not None or datepoint is not None """returns a list of all events in `daterange`""" # because empty strings are also Falsish if agenda_format is None: agenda_format = conf['view']['agenda_event_format'] 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('Getting all events between {} and {}'.format(start, end)) elif datepoint is not None: if not datepoint: datepoint = ['now'] try: start, allday = utils.guessdatetimefstr(datepoint, conf['locale'], date.today()) except ValueError: raise FatalError('Invalid value of `{}` for a datetime'.format(' '.join(datepoint))) if allday: logger.debug('Got date {}'.format(start)) raise FatalError('Please supply a datetime, not a date.') end = start + timedelta(seconds=1) if day_format is None: day_format = style( start.strftime(conf['locale']['longdatetimeformat']), bold=True, ) logger.debug('Getting all events between {} and {}'.format(start, end)) event_column = [] 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 = datetime.combine(start.date(), time.max) current_events = get_events_between( collection, locale=conf['locale'], agenda_format=agenda_format, start=start, end=day_end, notstarted=notstarted, original_start=original_start, env=env, seen=once, width=width, ) if day_format and (conf['default']['show_all_days'] or current_events): event_column.append(format_day(start.date(), day_format, conf['locale'])) event_column.extend(current_events) start = datetime(*start.date().timetuple()[:3]) + timedelta(days=1) if event_column == []: event_column = [style('No events', bold=True)] return event_column def new_interactive(collection, calendar_name, conf, info, location=None, categories=None, repeat=None, until=None, alarms=None, format=None, env=None): try: info = utils.eventinfofstr(info, conf['locale'], adjust_reasonably=True, localize=False) except ValueError: info = dict() 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 = utils.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'] = '' event = new_from_args( collection, calendar_name, conf, format=format, env=env, location=location, categories=categories, repeat=repeat, until=until, alarms=alarms, **info) 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, format=None, env=None): """construct a new event from a string and add it""" info = utils.eventinfofstr(info, conf['locale'], adjust_reasonably=True, localize=False) new_from_args( collection, calendar_name, conf, format=format, env=env, location=location, categories=categories, repeat=repeat, until=until, alarms=alarms, **info ) def new_from_args(collection, calendar_name, conf, dtstart=None, dtend=None, summary=None, description=None, allday=None, location=None, categories=None, repeat=None, until=None, alarms=None, timezone=None, format=None, env=None): """Create a new event from arguments and add to vdirs""" try: event = utils.new_event( locale=conf['locale'], location=location, categories=categories, repeat=repeat, until=until, alarms=alarms, dtstart=dtstart, dtend=dtend, summary=summary, description=description, timezone=timezone, ) except ValueError as error: raise FatalError(error) event = Event.fromVEvents( [event], calendar=calendar_name, locale=conf['locale']) try: collection.new(event) except ReadOnlyCalendarError: raise FatalError( 'ERROR: Cannot modify calendar "{}" as it is read-only'.format(calendar_name) ) if conf['default']['print_new'] == 'event': if format is None: format = conf['view']['event_format'] echo(event.format(format, datetime.now(), env=env)) elif conf['default']['print_new'] == 'path': 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"} now = 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 = event.format("{start} {end}", relative_to=now) value = prompt("datetime range", default=current) try: start, end, allday = utils.guessrangefstr(value, locale) event.update_start_end(start, end) edited = True except: 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 = utils.rrulefstr(freq, until, locale) event.update_rrule(rrule) edited = True elif choice == "alarm": default_alarms = [] for a in event.alarms: s = utils.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 * utils.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 = "" 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(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(event.format(format, 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='{0} v{1}'.format(__productname__, __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'] vevents = utils.split_ics(ics, random_uid, conf['locale']['default_timezone']) 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 icalendar.Calendar.from_ical(vevent).walk(): if item.name == 'VEVENT': event = Event.fromVEvents( [item], calendar=collection.default_calendar_name, locale=locale) echo(event.format(format, 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( ['{}({})'.format(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" "{}".format(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("Do you want to import this event into `{}`?".format(calendar_name)): try: collection.new(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("Not importing event with UID `{}`".format(event.uid)) def print_ics(conf, name, ics, format): if format is None: format = conf['view']['agenda_event_format'] cal = icalendar.Calendar.from_ical(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 = list() for uid in events_grouped: vevents.append(sorted(events_grouped[uid], key=sort_key)) echo('{} events found in {}'.format(len(vevents), name)) for sub_event in vevents: event = Event.fromVEvents(sub_event, locale=conf['locale']) echo(event.format(format, datetime.now())) khal-0.9.10/khal/khalendar/0000755000076600000240000000000013357150672017525 5ustar christiangeierstaff00000000000000khal-0.9.10/khal/khalendar/event.py0000644000076600000240000007403113357150322021215 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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.""" from collections import defaultdict from datetime import date, datetime, time, timedelta import os import icalendar import pytz from ..utils import generate_random_uid from .utils import to_naive_utc, to_unix_time, invalid_timezone, delete_instance, \ is_aware from ..exceptions import FatalError from ..log import logger from ..terminal import get_color from click import style class Event(object): """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 the 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 = False def __init__(self, vevents, ref=None, **kwargs): """ :param start: start datetime of this event instance :type start: datetime.date :param end: end datetime of this event instance in unix time :type end: datetime.date """ if self.__class__.__name__ == 'Event': raise ValueError('do not initialize this class directly') self._vevents = vevents self._locale = kwargs.pop('locale', None) self.readonly = kwargs.pop('readonly', None) self.href = kwargs.pop('href', None) self.etag = kwargs.pop('etag', None) self.calendar = kwargs.pop('calendar', None) self.ref = ref start = kwargs.pop('start', None) end = kwargs.pop('end', None) 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 + timedelta(days=1) else: self._end = end if kwargs: raise TypeError('%s are invalid keyword arguments to this function' % kwargs.keys()) @classmethod def _get_type_from_vDDD(cls, start): """ :type start: icalendar.prop.vDDDTypes :type start: icalendar.prop.vDDDTypes """ if not isinstance(start.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): if hasattr(start, 'tzinfo') and start.tzinfo is not None: cls = LocalizedEvent elif isinstance(start, datetime): cls = FloatingEvent elif isinstance(start, date): cls = AllDayEvent return cls @classmethod def fromVEvents(cls, events_list, ref=None, **kwargs): """ :type events: list """ assert isinstance(events_list, list) vevents = dict() 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): # flake8: noqa raise ValueError('DTSTART and DTEND should be of the same type (datetime or date)') except KeyError: pass if kwargs.get('start'): instcls = cls._get_type_from_date(kwargs.get('start')) else: instcls = cls._get_type_from_vDDD(vevents[ref]['DTSTART']) return instcls(vevents, ref=ref, **kwargs) @classmethod def fromString(cls, event_str, ref=None, **kwargs): calendar_collection = icalendar.Calendar.from_ical(event_str) events = [item for item in calendar_collection.walk() if item.name == 'VEVENT'] return cls.fromVEvents(events, ref, **kwargs) def __lt__(self, other): start = self.start_local other_start = other.start_local if isinstance(start, date) and not isinstance(start, datetime): start = datetime.combine(start, time.min) if isinstance(other_start, date) and not isinstance(other_start, datetime): other_start = datetime.combine(other_start, time.max) start = start.replace(tzinfo=None) other_start = other_start.replace(tzinfo=None) try: return start <= other_start except TypeError: raise ValueError('Cannot compare events {} and {}'.format(start, other_start)) def update_start_end(self, start, end): """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): # flake8: noqa 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, datetime): end = end + 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): return 'RRULE' in self._vevents[self.ref] or \ 'RECURRENCE-ID' in self._vevents[self.ref] or \ 'RDATE' in self._vevents[self.ref] @property def recurpattern(self): if 'RRULE' in self._vevents[self.ref]: return self._vevents[self.ref]['RRULE'].to_ical().decode('utf-8') else: return '' @property def recurobject(self): if 'RRULE' in self._vevents[self.ref]: return self._vevents[self.ref]['RRULE'] else: return icalendar.vRecur() def update_rrule(self, rrule): self._vevents['PROTO'].pop('RRULE') if rrule is not None: self._vevents['PROTO'].add('RRULE', rrule) @property def recurrence_id(self): """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(datetime.utcfromtimestamp(int(self.ref))) def increment_sequence(self): """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): if self._locale['unicode_symbols']: return dict( recurring='\N{Clockwise gapped circle arrow}', range='\N{Left right arrow}', range_end='\N{Rightwards arrow to bar}', range_start='\N{Rightwards arrow from bar}', right_arrow='\N{Rightwards arrow}' ) else: return dict( recurring='(R)', range='<->', range_end='->|', range_start='|->', right_arrow='->' ) @property def start_local(self): """self.start() localized to local timezone""" return self.start @property def end_local(self): """self.end() localized to local timezone""" return self.end @property def start(self): """this should return the start date(time) as saved in the event""" return self._start @property def end(self): """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): try: return self._vevents[self.ref]['DURATION'].dt except KeyError: return self.end - self.start @property def uid(self): return self._vevents[self.ref]['UID'] @property def organizer(self): 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 '{} ({})'.format(cn, email) else: return email @staticmethod def _create_calendar(): """ create the calendar :returns: calendar :rtype: icalendar.Calendar() """ calendar = icalendar.Calendar() calendar.add('version', '2.0') calendar.add( 'prodid', '-//PIMUTILS.ORG//NONSGML khal / icalendar //EN' ) return calendar @property def raw(self): """needed for vdirsyncer compatibility return text """ calendar = self._create_calendar() tzs = list() 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): """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): bday = self._vevents[self.ref].get('x-birthday', None) if bday: number = self.start_local.year - int(bday[:4]) name = self._vevents[self.ref].get('x-fname', None) if int(bday[4:6]) == 2 and int(bday[6:8]) == 29: return '{name}\'s {number}th birthday (29th of Feb.)'.format(name=name, number=number) else: return '{name}\'s {number}th birthday'.format(name=name, number=number) else: return self._vevents[self.ref].get('SUMMARY', '') def update_summary(self, summary): self._vevents[self.ref]['SUMMARY'] = summary @staticmethod def _can_handle_alarm(alarm): """ Decides whether we can handle a certain alarm. """ return alarm.get('ACTION') == 'DISPLAY' and isinstance(alarm.get('TRIGGER').dt, timedelta) @property def alarms(self): """ 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): """ 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): return self._vevents[self.ref].get('LOCATION', '') def update_location(self, location): if location: self._vevents[self.ref]['LOCATION'] = location else: self._vevents[self.ref].pop('LOCATION') @property def categories(self): return self._vevents[self.ref].get('CATEGORIES', '') def update_categories(self, categories): if categories.strip(): self._vevents[self.ref]['CATEGORIES'] = categories else: self._vevents[self.ref].pop('CATEGORIES', False) @property def description(self): return self._vevents[self.ref].get('DESCRIPTION', '') def update_description(self, description): if description: self._vevents[self.ref]['DESCRIPTION'] = description else: self._vevents[self.ref].pop('DESCRIPTION') @property def _recur_str(self): if self.recurring: recurstr = ' ' + self.symbol_strings['recurring'] else: recurstr = '' return recurstr def format(self, format_string, relative_to, env={}, colors=True): """ :param colors: determines if colors codes should be printed or not :type colors: bool """ attributes = dict() try: relative_to_start, relative_to_end = relative_to except TypeError: relative_to_start = relative_to_end = relative_to if isinstance(relative_to_end, datetime): relative_to_end = relative_to_end.date() if isinstance(relative_to_start, datetime): relative_to_start = relative_to_start.date() if isinstance(self.start_local, datetime): start_local_datetime = self.start_local end_local_datetime = self.end_local else: start_local_datetime = self._locale['local_timezone'].localize( datetime.combine(self.start, time.min)) end_local_datetime = self._locale['local_timezone'].localize( datetime.combine(self.end, time.min)) day_start = self._locale['local_timezone'].localize(datetime.combine(relative_to_start, time.min)) day_end = self._locale['local_timezone'].localize(datetime.combine(relative_to_end, time.max)) next_day_start = day_start + 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']) # 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["title"] = self.summary attributes["description"] = self.description.strip() attributes["description-separator"] = "" if attributes["description"]: attributes["description-separator"] = " :: " attributes["location"] = self.location.strip() attributes["all-day"] = allday attributes["categories"] = self.categories if "calendars" in env and self.calendar in env["calendars"]: cal = env["calendars"][self.calendar] attributes["calendar-color"] = get_color(cal.get('color', '')) attributes["calendar"] = cal.get("displayname", self.calendar) else: attributes["calendar-color"] = attributes["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['status'] = self.status attributes['cancelled'] = 'CANCELLED ' if self.status == 'CANCELLED' else '' return format_string.format(**dict(attributes)) + attributes["reset"] def duplicate(self): """duplicate this event's PROTO event :rtype: 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]) event.calendar = self.calendar event._locale = self._locale return event def delete_instance(self, instance): """delete an instance from this 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 = list() 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): return self._vevents[self.ref].get('STATUS', '') class DatetimeEvent(Event): pass class LocalizedEvent(DatetimeEvent): """ see parent """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) try: starttz = getattr(self._vevents[self.ref]['DTSTART'].dt, 'tzinfo', None) except KeyError: msg = ( "Cannot understand event {} from " "calendar {}, you might want to file an issue at " "https://github.com/pimutils/khal/issues" .format(kwargs.get('href'), kwargs.get('calendar')) ) 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): """ see parent """ return self.start.astimezone(self._locale['local_timezone']) @property def end_local(self): """ see parent """ return self.end.astimezone(self._locale['local_timezone']) class FloatingEvent(DatetimeEvent): """ """ allday = False @property def start_local(self): return self._locale['local_timezone'].localize(self.start) @property def end_local(self): return self._locale['local_timezone'].localize(self.end) class AllDayEvent(Event): allday = True @property def end(self): end = super(AllDayEvent, self).end if end == self.start: # https://github.com/pimutils/khal/issues/129 logger.warning('{} ("{}"): 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 single-day event ' 'on {}'.format(self.href, self.summary, self.start)) end += timedelta(days=1) return end - timedelta(days=1) def create_timezone(tz, first_date=None, last_date=None): """ create an icalendar vtimezone from a pytz.tzinfo object :param tz: the timezone :type tz: pytz.tzinfo :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 :type first_date: datetime.datetime :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 :rtype: icalendar.Timezone() 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, pytz.tzinfo.StaticTzInfo): return _create_timezone_static(tz) # TODO last_date = None, recurring to infinity first_date = datetime.today() if not first_date else to_naive_utc(first_date) last_date = datetime.today() 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()) } bst = { one[2]: 'BST' in two.__repr__() for one, two in iter(tz._tzinfos.items()) } # looking for the first and last transition time we need to include first_num, last_num = 0, len(tz._utc_transition_times) - 1 first_tt = tz._utc_transition_times[0] last_tt = tz._utc_transition_times[-1] for num, dt in enumerate(tz._utc_transition_times): if dt > first_tt and dt < first_date: first_num = num first_tt = dt if dt < last_tt and dt > last_date: last_num = num last_tt = dt timezones = dict() for num in range(first_num, last_num + 1): name = tz._transition_info[num][2] if name in timezones: ttime = tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None) 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]) subcomp.add( 'DTSTART', tz.fromutc(tz._utc_transition_times[num]).replace(tzinfo=None)) subcomp.add('TZOFFSETTO', tz._transition_info[num][0]) subcomp.add('TZOFFSETFROM', tz._transition_info[num - 1][0]) timezones[name] = subcomp for subcomp in timezones.values(): timezone.add_component(subcomp) return timezone def _create_timezone_static(tz): """create an icalendar vtimezone from a pytz.tzinfo.StaticTzInfo :param tz: the timezone :type tz: pytz.tzinfo.StaticTzInfo :returns: timezone information :rtype: icalendar.Timezone() """ timezone = icalendar.Timezone() timezone.add('TZID', tz) subcomp = icalendar.TimezoneStandard() subcomp.add('TZNAME', tz) subcomp.add('DTSTART', datetime(1601, 1, 1)) subcomp.add('RDATE', datetime(1601, 1, 1)) subcomp.add('TZOFFSETTO', tz._utcoffset) subcomp.add('TZOFFSETFROM', tz._utcoffset) timezone.add_component(subcomp) return timezone class EventStandIn(): def __init__(self, calendar): self.calendar = calendar self.color = None self.unicode_symbols = None self.readonly = None khal-0.9.10/khal/khalendar/backend.py0000644000076600000240000006342213357150322021465 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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. note on naming: * every variable name vevent should be of type icalendar.Event * every variable named event should be of type khal.khalendar.Events * variables named vevents/events (plural) should be iterables of their respective types """ # TODO remove creating Events from SQLiteDb # we currently expect str/CALENDAR objects but return Event(), we should # accept and return the same kind of events import contextlib from datetime import datetime, timedelta from os import makedirs, path import sqlite3 from dateutil import parser import icalendar import pytz from .event import Event, EventStandIn from . import utils from .. import log from .exceptions import CouldNotCreateDbDir, OutdatedDbVersionError, UpdateFailed logger = log.logger DB_VERSION = 5 # The current db layout version RECURRENCE_ID = 'RECURRENCE-ID' THISANDFUTURE = 'THISANDFUTURE' THISANDPRIOR = 'THISANDPRIOR' DATE = 0 DATETIME = 1 PROTO = 'PROTO' def sort_key(vevent): """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, utils.to_unix_time(rec_id.dt) else: return uid, 1 class SQLiteDb(object): """ This class should provide a caching database for a calendar, keeping raw vevents in one table but allowing to retrieve events by dates (via the help of some auxiliary tables) :param calendar: the `name` of this calendar, if the same *name* and *dbpath* is given on next creation of an SQLiteDb object the same tables will be used :type calendar: str :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 :type db_path: str or None """ def __init__(self, calendars, db_path, locale): assert db_path is not None self.calendars = calendars self.db_path = path.expanduser(db_path) self._create_dbdir() self.locale = locale self._at_once = 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() @property def _select_calendars(self): return ', '.join(['\'' + cal + '\'' for cal in self.calendars]) @contextlib.contextmanager def at_once(self): assert not self._at_once self._at_once = True try: yield self except: raise else: self.conn.commit() finally: self._at_once = False def _create_dbdir(self): """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.fatal('failed to create {0}: {1}'.format(dbdir, error)) raise CouldNotCreateDbDir() def _check_table_version(self): """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): """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): """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("tables for calendar {0} exist".format(cal)) else: sql_s = 'INSERT INTO calendars (calendar, resource) VALUES (?, ?);' stuple = (cal, '') self.sql_ex(sql_s, stuple) def sql_ex(self, statement, stuple=''): """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, href, etag='', calendar=None): """insert a new or update an existing card in 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 :type vevent: unicode :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. :type href: str() :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 :type etag: str() """ assert calendar is not None assert href is not None ical = icalendar.Event.from_ical(vevent_str) check_for_errors(ical, calendar, href) vevents = (utils.sanitize(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_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_birthday(self, vevent, href, etag='', calendar=None): """ XXX write docstring """ assert calendar is not None assert href is not None self.delete(href, calendar=calendar) ical = icalendar.Event.from_ical(vevent) vcard = ical.walk()[0] if 'BDAY' in vcard.keys(): bday = vcard['BDAY'] if isinstance(bday, list): logger.warning( 'Vcard {0} in collection {1} has more than one ' 'BIRTHDAY, will be skipped and not be available ' 'in khal.'.format(href, calendar) ) return try: if bday[0:2] == '--' and bday[3] != '-': bday = '1900' + bday[2:] orig_bday = False else: orig_bday = True bday = parser.parse(bday).date() except ValueError: logger.warning( 'cannot parse BIRTHDAY in {0} in collection {1}'.format(href, calendar)) return if 'FN' in vcard: name = vcard['FN'] else: n = vcard['N'].split(';') name = ' '.join([n[1], n[2], n[0]]) event = icalendar.Event() event.add('dtstart', bday) event.add('dtend', bday + timedelta(days=1)) if bday.month == 2 and bday.day == 29: # leap year event.add('rrule', {'freq': 'YEARLY', 'BYYEARDAY': 60}) else: event.add('rrule', {'freq': 'YEARLY'}) if orig_bday: event.add('x-birthday', '{:04}{:02}{:02}'.format(bday.year, bday.month, bday.day)) event.add('x-fname', name) event.add('summary', '{0}\'s birthday'.format(name)) event.add('uid', href) event_str = event.to_ical().decode('utf-8') self._update_impl(event, href, calendar) sql_s = ('INSERT INTO events (item, etag, href, calendar) VALUES (?, ?, ?, ?);') stuple = (event_str, etag, href, calendar) self.sql_ex(sql_s, stuple) def _update_impl(self, vevent, href, calendar): """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, datetime): dtype = DATE else: dtype = DATETIME if ('TZID' in vevent['DTSTART'].params and dtype == 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 = start_shift.days * 3600 * 24 + start_shift.seconds duration = duration.days * 3600 * 24 + duration.seconds dtstartend = utils.expand(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 == 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 = dbstart ref = PROTO if thisandfuture: recs_sql_s = ( 'UPDATE {0} SET dtstart = rec_inst + ?, dtend = rec_inst + ?, ref = ? ' 'WHERE rec_inst >= ? AND href = ? AND calendar = ?;'.format(recs_table)) stuple = (start_shift, start_shift + duration, ref, rec_inst, href, calendar) else: recs_sql_s = ( 'INSERT OR REPLACE INTO {0} ' '(dtstart, dtend, href, ref, dtype, rec_inst, calendar)' 'VALUES (?, ?, ?, ?, ?, ?, ?);'.format(recs_table)) stuple = (dbstart, dbend, href, ref, dtype, rec_inst, calendar) self.sql_ex(recs_sql_s, stuple) def get_ctag(self, calendar): 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, calendar): 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, calendar): """get etag for href type href: str() return: etag rtype: str() """ 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, etag=None, calendar=None): """ removes the event from the db, :param etag: only there for compatibility with vdirsyncer's Storage, we always delete :returns: None """ assert calendar is not None for table in ['recs_loc', 'recs_float']: sql_s = 'DELETE FROM {0} WHERE href = ? AND calendar = ?;'.format(table) self.sql_ex(sql_s, (href, calendar)) sql_s = 'DELETE FROM events WHERE href = ? AND calendar = ?;' self.sql_ex(sql_s, (href, calendar)) def list(self, calendar): """ 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(self, start, end, minimal=False): """returns :type start: datetime.datetime :type end: datetime.datetime :param minimal: if set, we do not return an event but a minimal stand in :type minimal: bool """ assert start.tzinfo is not None assert end.tzinfo is not None start = utils.to_unix_time(start) end = utils.to_unix_time(end) if minimal: 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') else: 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 events.calendar in ({0}) ' 'ORDER BY dtstart') stuple = (start, end, start, end, start, end) result = self.sql_ex(sql_s.format(self._select_calendars), stuple) if minimal: for calendar in result: yield EventStandIn(calendar[0]) else: for item, href, start, end, ref, etag, dtype, calendar in result: start = pytz.UTC.localize(datetime.utcfromtimestamp(start)) end = pytz.UTC.localize(datetime.utcfromtimestamp(end)) yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype) def get_floating(self, start, end, minimal=False): """return floating events between `start` and `end` :type start: datetime.datetime :type end: datetime.datetime :param minimal: if set, we do not return an event but a minimal stand in :type minimal: bool """ assert start.tzinfo is None assert end.tzinfo is None strstart = utils.to_unix_time(start) strend = utils.to_unix_time(end) if minimal: 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') else: 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 = (strstart, strend, strstart, strend, strstart, strend) result = self.sql_ex(sql_s.format(self._select_calendars), stuple) if minimal: for calendar in result: yield EventStandIn(calendar[0]) else: for item, href, start, end, ref, etag, dtype, calendar in result: start = datetime.utcfromtimestamp(start) end = datetime.utcfromtimestamp(end) yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype) def get(self, href, start=None, end=None, ref=None, dtype=None, calendar=None): """returns the Event matching href if start and end are given, a specific Event from a Recursion set is returned, otherwise the Event returned exactly as saved in the db """ assert calendar is not None sql_s = 'SELECT href, etag, item FROM events WHERE href = ? AND calendar = ?;' result = self.sql_ex(sql_s, (href, calendar)) href, etag, item = result[0] if dtype == DATE: start = start.date() end = end.date() return Event.fromString(item, locale=self.locale, href=href, calendar=calendar, etag=etag, start=start, end=end, ref=ref, ) def construct_event(self, item, href, start, end, ref, etag, calendar, dtype=None): if dtype == DATE: start = start.date() end = end.date() return Event.fromString(item, locale=self.locale, href=href, calendar=calendar, etag=etag, start=start, end=end, ref=ref, ) def search(self, search_string): """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 = ('%{0}%'.format(search_string), ) result = self.sql_ex(sql_s.format(self._select_calendars), stuple) for item, href, start, end, ref, etag, dtype, calendar in result: start = pytz.UTC.localize(datetime.utcfromtimestamp(start)) end = pytz.UTC.localize(datetime.utcfromtimestamp(end)) yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype) 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 = ('%{0}%'.format(search_string), ) result = self.sql_ex(sql_s.format(self._select_calendars), stuple) for item, href, start, end, ref, etag, dtype, calendar in result: start = datetime.utcfromtimestamp(start) end = datetime.utcfromtimestamp(end) yield self.construct_event(item, href, start, end, ref, etag, calendar, dtype) def check_support(vevent, href, calendar): """test if all icalendar features used in this event are supported, raise `UpdateFailed` otherwise. :param vevent: event to test :type vevent: icalendar.cal.Event :param href: href of this event, only used for logging :type href: str """ 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 ' 'standard MUST NOT create those. Therefore event {0} from ' 'calendar {1} will not be shown in khal' .format(href, calendar) ) 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. ' 'Therefore event {0} from calendar {1} will not be shown in khal.\n' 'Please post exemplary events (please remove any private data) ' 'to https://github.com/pimutils/khal/issues/152 .' .format(href, calendar) ) def check_for_errors(component, calendar, href): """checking if component.errors exists, is not empty and if so warn the user""" if hasattr(component, 'errors') and component.errors: logger.error( 'Errors occurred when parsing {0}/{1} for the following ' 'reasons:'.format(calendar, href)) 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): """calculate an event's duration and by how much its start time has shifted versus its recurrence-id time :param event: an event with an RECURRENCE-ID property :type event: icalendar.Event :returns: time shift and duration :rtype: (datetime.timedelta, datetime.timedelta) """ 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 khal-0.9.10/khal/khalendar/__init__.py0000644000076600000240000000221713243067215021632 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 # flake8: noqa khal-0.9.10/khal/khalendar/vdir.py0000644000076600000240000002142713357150322021041 0ustar christiangeierstaff00000000000000''' Based off https://github.com/pimutils/python-vdir, which is itself based off vdirsyncer. ''' import os import errno import uuid from atomicwrites import atomic_write class cached_property: '''A read-only @property that is only evaluated once. Only usable on class instances' methods. ''' def __init__(self, fget, doc=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 def to_unicode(x, encoding='ascii'): if not isinstance(x, str): return x.decode(encoding) return x def to_bytes(x, encoding='ascii'): if not isinstance(x, bytes): return x.encode(encoding) return x SAFE_UID_CHARS = ('abcdefghijklmnopqrstuvwxyz' 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' '0123456789_.-+') def _href_safe(uid, safe=SAFE_UID_CHARS): return not bool(set(uid) - set(safe)) def _generate_href(uid=None, safe=SAFE_UID_CHARS): if not uid or not _href_safe(uid, safe): return to_unicode(uuid.uuid4().hex) else: return uid def get_etag_from_file(f): '''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 '{:.9f}'.format(mtime) class VdirError(IOError): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if getattr(self, key, object()) is not None: # pragma: no cover raise TypeError('Invalid argument: {}'.format(key)) setattr(self, key, value) super(VdirError, self).__init__(*args) class NotFoundError(VdirError): pass class CollectionNotFoundError(VdirError): pass class WrongEtagError(VdirError): pass class AlreadyExistingError(VdirError): existing_href = None class Item: def __init__(self, raw): assert isinstance(raw, str) self.raw = raw @cached_property def uid(self): 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 def _normalize_meta_value(value): return to_unicode(value or '').strip() class VdirBase: item_class = Item default_mode = 0o750 def __init__(self, path, fileext, encoding='utf-8'): if not os.path.isdir(path): raise CollectionNotFoundError(path) self.path = path self.encoding = encoding self.fileext = fileext @classmethod def discover(cls, path, **kwargs): 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, **kwargs): kwargs = dict(kwargs) path = kwargs['path'] path = os.path.join(path, collection_name) if not os.path.exists(path): os.makedirs(path, mode=cls.default_mode) elif not os.path.isdir(path): raise IOError('{} is not a directory.'.format(repr(path))) kwargs['path'] = path return kwargs def _get_filepath(self, href): return os.path.join(self.path, href) def _get_href(self, uid): return _generate_href(uid) + self.fileext def list(self): 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): 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 IOError as e: if e.errno == errno.ENOENT: raise NotFoundError(href) else: raise def upload(self, item): if not isinstance(item.raw, str): raise TypeError('item.raw must be a unicode string.') try: href = self._get_href(item.uid) fpath, 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) fpath, etag = self._upload_impl(item, href) else: raise return href, etag def _upload_impl(self, item, href): fpath = self._get_filepath(href) try: with atomic_write(fpath, mode='wb', 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, item, etag): 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, mode='wb', overwrite=True) as f: f.write(item.raw.encode(self.encoding)) etag = get_etag_from_file(f) return etag def delete(self, href, etag): 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): fpath = os.path.join(self.path, key) try: with open(fpath, 'rb') as f: return f.read().decode(self.encoding) or None except IOError as e: if e.errno == errno.ENOENT: return None else: raise def set_meta(self, key, value): value = value or '' assert isinstance(value, str) fpath = os.path.join(self.path, key) with atomic_write(fpath, mode='wb', overwrite=True) as f: f.write(value.encode(self.encoding)) class Color: def __init__(self, x): 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 = x.upper() @cached_property def rgb(self): 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('Unable to parse color value: {}' .format(self.value)) class ColorMixin: color_type = Color def get_color(self): try: return self.color_type(self.get_meta('color')) except ValueError: return None def set_color(self, value): self.set_meta('color', self.color_type(value).raw) class DisplayNameMixin: def get_displayname(self): return self.get_meta('displayname') def set_displayname(self, value): self.set_meta('displayname', value) class Vdir(VdirBase, ColorMixin, DisplayNameMixin): pass khal-0.9.10/khal/khalendar/utils.py0000644000076600000240000003043213357150322021231 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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""" from datetime import datetime, timedelta import calendar import dateutil.rrule import pytz from .. import log from .exceptions import UnsupportedRecurrence logger = log.logger def expand(vevent, href=''): """ 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 :type vevent: icalendar.cal.Event :param href: the href of the vevent, used for more informative logging and nothing else :type href: str :returns: list of start and end (date)times of the expanded event :rtype: list(tuple(datetime, datetime)) """ # 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, datetime) def sanitize_datetime(date): if allday and isinstance(date, 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) if events_tz and 'Z' not in rrule_param.to_ical().decode(): logger.warning( "In event {}, DTSTART has a timezone, but UNTIL does not. This " "might lead to errenous repeating instances (like missing the " "last intended instance or adding an extra one)." "".format(href)) elif not events_tz and 'Z' in rrule_param.to_ical().decode(): logger.warning( "In event {}, DTSTART has no timezone, but UNTIL has one. This " "might lead to errenous repeating instances (like missing the " "last intended instance or adding an extra one)." "".format(href)) rrule = dateutil.rrule.rrulestr( rrule_param.to_ical().decode(), dtstart=dtstart, ignoretz=True, ) if rrule._until is None: # 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 = datetime(2037, 12, 31) elif events_tz and 'Z' in rrule_param.to_ical().decode(): rrule._until = pytz.UTC.localize( rrule._until).astimezone(events_tz).replace(tzinfo=None) rrule = map(sanitize_datetime, rrule) logger.debug('calculating recurrence dates for {0}, ' 'this might take some time.'.format(href)) # 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( 'In event {}, excluded instance starting at {} not found, ' 'event might be invalid.'.format(href, date)) dtstartend = [(start, start + duration) for start in dtstartl] # not necessary, but I prefer deterministic output dtstartend.sort() return dtstartend def sanitize(vevent, default_timezone, href='', calendar=''): """ clean up vevents we do not understand :param vevent: the vevent that needs to be cleaned :type vevent: icalendar.cal.Event :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 :type timezone: pytz.timezone :param href: used for logging to inform user which .ics files are problematic :type href: str :param calendar: used for logging to inform user which .ics files are problematic :type calendar: str :returns: clean vevent :rtype: icalendar.cal.Event """ # 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( "{} localized in invalid or incomprehensible timezone `{}` in {}/{}. " "This could lead to this event being wrongly displayed." "".format(prop, timezone, calendar, href) ) 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, datetime) and isinstance(dtend, 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 None and duration is None: if isinstance(dtstart, datetime): dtstart = dtstart.date() dtend = dtstart + 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." ) dtend += timedelta(hours=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, datetime) and isinstance(until, datetime): vevent['rrule']['until'] = until.date() return vevent def localize_strip_tz(dates, timezone): """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): """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 = calendar.timegm(dtime.timetuple()) return unix_time def to_naive_utc(dtime): """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 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 list() 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, instance): """remove a recurrence instance from a VEVENT's RRDATE list or add it to the EXDATE list :type vevent: icalendar.cal.Event :type instance: datetime.datetime """ # 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 is_aware(dtime): """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 khal-0.9.10/khal/khalendar/khalendar.py0000644000076600000240000003277513357150322022036 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 import os import os.path import itertools from .vdir import CollectionNotFoundError, AlreadyExistingError, Vdir, \ get_etag_from_file from . import backend from .event import Event from .. import log from .exceptions import CouldNotCreateDbDir, UnsupportedFeatureError, \ ReadOnlyCalendarError, UpdateFailed, DuplicateUid logger = log.logger def create_directory(path): if not os.path.isdir(path): if os.path.exists(path): raise RuntimeError('{0} is not a directory.'.format(path)) try: os.makedirs(path, mode=0o750) except OSError as error: logger.fatal('failed to create {0}: {1}'.format(path, error)) raise CouldNotCreateDbDir() class CalendarCollection(object): """CalendarCollection allows access to various calendars stored in vdirs all calendars are cached in an sqlitedb for performance reasons""" def __init__(self, calendars=None, hmethod='fg', default_color='', multiple='', color='', highlight_event_days=0, locale=None, dbpath=None, ): assert dbpath is not None assert calendars is not None self._calendars = calendars self._default_calendar_name = None self._storages = dict() 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('created non-existing vdir {}'.format(calendar['path'])) self._storages[name] = Vdir(calendar['path'], file_ext) self.hmethod = hmethod self.default_color = default_color self.multiple = multiple self.color = color self.highlight_event_days = highlight_event_days self._locale = locale self._backend = backend.SQLiteDb( calendars=self.names, db_path=dbpath, locale=self._locale) self._last_ctags = dict() self.update_db() @property def writable_names(self): return [c for c in self._calendars if not self._calendars[c].get('readonly', False)] @property def calendars(self): return self._calendars.values() @property def names(self): return self._calendars.keys() @property def default_calendar_name(self): return self._default_calendar_name @default_calendar_name.setter def default_calendar_name(self, default): if default is None: self._default_calendar_name = default elif default not in self.names: raise ValueError('Unknown calendar: {0}'.format(default)) readonly = self._calendars[default].get('readonly', False) if not readonly: self._default_calendar_name = default else: raise ValueError( 'Calendar "{0}" is read-only and cannot be used as default'.format(default)) def _local_ctag(self, calendar): return get_etag_from_file(self._calendars[calendar]['path']) def _cover_event(self, event): event.color = self._calendars[event.calendar]['color'] event.readonly = self._calendars[event.calendar]['readonly'] event.unicode_symbols = self._locale['unicode_symbols'] return event def get_floating(self, start, end, minimal=False): events = self._backend.get_floating(start, end, minimal) return (self._cover_event(event) for event in events) def get_localized(self, start, end, minimal=False): events = self._backend.get_localized(start, end, minimal) return (self._cover_event(event) for event in events) def get_events_on(self, day, minimal=False): """return all events on `day` :param day: datetime.date :rtype: list() """ start = datetime.datetime.combine(day, datetime.time.min) end = datetime.datetime.combine(day, datetime.time.max) floating_events = self.get_floating(start, end, minimal) localize = self._locale['local_timezone'].localize localized_events = self.get_localized(localize(start), localize(end), minimal) return itertools.chain(floating_events, localized_events) def update(self, event): """update `event` in vdir and db""" assert event.etag 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, collection=None): """update `event` even if an event with the same uid/href already exists""" calendar = collection if collection is not None else event.calendar 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 new(self, event, collection=None): """save a new event to the vdir and the database param event: the event that should be updated, will get a new href and etag properties type event: event.Event """ calendar = collection if collection is not None else event.calendar 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, etag, calendar): if self._calendars[calendar]['readonly']: raise ReadOnlyCalendarError() self._storages[calendar].delete(href, etag) self._backend.delete(href, calendar=calendar) def get_event(self, href, calendar): return self._cover_event(self._backend.get(href, calendar=calendar)) def change_collection(self, event, new_collection): href, etag, calendar = event.href, event.etag, event.calendar event.etag = None self.new(event, new_collection) self.delete(href, etag, calendar=calendar) def new_event(self, ical, collection): """creates and returns (but does not insert) new event from ical string""" calendar = collection or self.writable_names[0] return Event.fromString(ical, locale=self._locale, calendar=calendar) def update_db(self): """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): """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, remember=False): """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): """implements the actual db update on a per calendar base""" local_ctag = self._local_ctag(calendar) db_hrefs = set(href for href, etag in self._backend.list(calendar)) storage_hrefs = set() 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('Updating {0} because {1} != {2}'.format(href, etag, db_etag)) self._update_vevent(href, calendar=calendar) for href in db_hrefs - storage_hrefs: 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, calendar): """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_birthday 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)): logger.exception('Unknown exception happened.') logger.warning( 'Skipping {0}/{1}: {2}\n' 'This event will not be available in khal.'.format(calendar, href, str(e))) return False def search(self, search_string): """search for the db for events matching `search_string`""" return (self._cover_event(event) for event in self._backend.search(search_string)) def get_day_styles(self, day, focus): devents = list(self.get_events_on(day, minimal=True)) if len(devents) == 0: return None if self.color != '': return 'highlight_days_color' dcalendars = list(set(map(lambda event: event.calendar, devents))) if len(dcalendars) == 1: return 'calendar ' + dcalendars[0] if self.multiple != '': return 'highlight_days_multiple' return ('calendar ' + dcalendars[0], 'calendar ' + dcalendars[1]) def get_styles(self, date, focus): 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.9.10/khal/khalendar/exceptions.py0000644000076600000240000000417713357150322022261 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 UnsupportedFeatureError, Error, FatalError class UnsupportedRruleExceptionError(UnsupportedFeatureError): """we do not support exceptions that do not delete events yet""" def __init__(self, message=''): x = 'This kind of recurrence exception is currently unsupported' if message: x += ': {}'.format(message.strip()) UnsupportedFeatureError.__init__(self, x) class ReadOnlyCalendarError(Error): """this calendar is readonly and should not be modifiable from within khal""" 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 UnsupportedRecurrence(Error): """raised if the RRULE is not understood by dateutil.rrule""" pass class DuplicateUid(Error): """an event with this UID already exists""" existing_href = None khal-0.9.10/khal/calendar_display.py0000644000076600000240000001666413357150322021451 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 from locale import getlocale, setlocale, LC_ALL, LC_TIME from click import style from .terminal import colored from .utils import get_month_abbr_len setlocale(LC_ALL, '') def get_weekheader(firstweekday): try: mylocale = '.'.join(getlocale(LC_TIME)) except TypeError: mylocale = 'C' _calendar = calendar.LocaleTextCalendar(firstweekday, locale=mylocale) return _calendar.formatweekheader(2) def getweeknumber(date): """return iso week number for datetime.date object :param date: date :type date: datetime.date() :return: weeknumber :rtype: int """ return datetime.date.isocalendar(date)[1] def get_event_color(event, default_color): """Because multi-line lambdas would be un-Pythonic """ if event.color == '': return default_color return event.color def str_highlight_day(day, devents, hmethod, default_color, multiple, color, bold_for_light_color): """returns a string with day highlighted according to configuration """ dstr = str(day.day).rjust(2) if color == '': dcolors = list(set(map(lambda x: get_event_color(x, default_color), devents))) if len(dcolors) > 1: if multiple == '': 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: if devents[0].color == '': dcolor = default_color else: dcolor = devents[0].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, today, collection=None, hmethod=None, default_color=None, multiple=None, color=None, highlight_event_days=False, locale=None, bold_for_light_color=True): """returns a string representing one week, if for day == today color is reversed :param week: list of 7 datetime.date objects (one week) :type day: list() :param today: the date of today :type today: datetime.date :return: string, which if printed on terminal appears to have length 20, but may contain ascii escape sequences :rtype: str """ strweek = '' for day in week: if day == today: day = style(str(day.day).rjust(2), reverse=True) elif highlight_event_days: devents = list(collection.get_events_on(day, minimal=True)) if len(devents) > 0: day = str_highlight_day(day, devents, hmethod, default_color, multiple, color, bold_for_light_color) else: day = str(day.day).rjust(2) else: day = str(day.day).rjust(2) strweek = strweek + day + ' ' return strweek def vertical_month(month=None, year=None, today=None, weeknumber=False, count=3, firstweekday=0, collection=None, hmethod='fg', default_color='', multiple='', color='', highlight_event_days=False, locale=None, bold_for_light_color=True): """ 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 :type month: int :param year: year of the first month included, if non given, current year is assumed :type year: int :param today: day highlighted, if non is given, current date is assumed :type today: datetime.date() :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 :type weeknumber: str/bool :returns: calendar strings, may also include some ANSI (color) escape strings :rtype: list() of str() """ if month is None: month = datetime.date.today().month if year is None: year = datetime.date.today().year if today is None: today = datetime.date.today() khal = list() 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): new_month = len([day for day in week if day.day == 1]) strweek = str_week(week, today, collection, hmethod, default_color, multiple, 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('{:2}'.format(getweeknumber(week[0])), 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.9.10/khal/__init__.py0000644000076600000240000000327513243067215017706 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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: 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-2017 Christian Geier et al.' __author_email__ = 'khal@lostpackets.de' __description__ = 'A standards based terminal calendar' __license__ = 'Expat/MIT, see COPYING' __homepage__ = 'https://lostpackets.de/khal/' khal-0.9.10/khal/configwizard.py0000644000076600000240000002120113357150322020620 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 click import confirm, prompt, UsageError import xdg from functools import partial from itertools import zip_longest from os.path import expanduser, expandvars, join, normpath, exists, isdir from os import makedirs from datetime import date, datetime from khal.log import logger from .exceptions import FatalError from .settings import settings 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('Input must be between {} and {}'.format(min_value, 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('{}={}'.format(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 = date.today() print("What ordering of year, month, date do you want to use?") for num, (desc, fmt) in enumerate(choices): print('[{}] {} (today: {})'.format(num, desc, 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("Date format: {} " "(today as an example: {})".format(dateformat, 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 = datetime.now() print("Time format: {} " "(current time as an example: {})".format(timeformat, now.strftime(timeformat))) return timeformat def get_vdirs_from_vdirsyncer_config(): """trying to load vdirsyncer's config and read all vdirs from it""" print("If you use vdirsyncer to sync with CalDAV servers, we can try to " "load its config file and add your calendars to khal's config.") if not confirm("Should we try to load vdirsyncer's config?", default='y'): return None try: from vdirsyncer.cli import config from vdirsyncer.exceptions import UserError except ImportError: print("Sorry, cannot import vdirsyncer. Please make sure you have it " "installed.") return None try: vdir_config = config.load_config() except UserError as error: print("Sorry, trying to load vdirsyncer failed with the following " "error message:") print(error) return None vdirs = list() 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 == list(): print("No usable collections were found") return None else: print("The following collections were found:") for name, path, _ in vdirs: print(' {}: {}'.format(name, path)) return vdirs def create_vdir(names=[]): if not confirm("Do you want to create a local calendar? (You can always " "set it up to synchronize with a server in vdirsyncer " "later)."): return None 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("Could not create directory {} because of {}. Exiting".format(path, error)) raise print("Created new vdir at {}".format(path)) return [(name, path, 'calendar')] def create_config(vdirs, dateformat, timeformat): config = ['[calendars]'] for name, path, type_ in sorted(vdirs or ()): config.append('\n[[{name}]]'.format(name=name)) config.append('path = {path}'.format(path=path)) config.append('type = {type}'.format(type=type_)) config.append('\n[locale]') config.append('timeformat = {timeformat}\n' 'dateformat = {dateformat}\n' 'longdateformat = {longdateformat}\n' 'datetimeformat = {dateformat} {timeformat}\n' 'longdatetimeformat = {longdateformat} {timeformat}\n' .format(timeformat=timeformat, dateformat=dateformat, longdateformat=dateformat)) config = '\n'.join(config) return config def configwizard(): config_file = settings.find_configuration_file() if config_file is not None: logger.fatal("Found an existing config file at {}.".format(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() vdirs = get_vdirs_from_vdirsyncer_config() print() if not vdirs: try: vdirs = create_vdir() except OSError as error: raise FatalError(error) if not vdirs: print("\nWARNING: no vdir configured, khal will not be usable like this!\n") config = create_config(vdirs, dateformat=dateformat, timeformat=timeformat) config_path = join(xdg.BaseDirectory.xdg_config_home, 'khal', 'config') if not confirm( "Do you want to write the config to {}? " "(Choosing `No` will abort)".format(config_path), 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( "Could not write config file at {} because of {}. " "Aborting".format(config_dir, error) ) raise FatalError(error) else: print('created directory {}'.format(config_dir)) with open(config_path, 'w') as config_file: config_file.write(config) print("Successfully wrote configuration to {}".format(config_path)) khal-0.9.10/khal/cli.py0000644000076600000240000006020013357150322016703 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 sys import textwrap from shutil import get_terminal_size import datetime try: from setproctitle import setproctitle except ImportError: def setproctitle(x): pass import click from . import controllers, khalendar, __version__ from .log import logger from .settings import get_config, InvalidSettingsError from .settings.exceptions import NoConfigFile from .exceptions import FatalError from .terminal import colored 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_callback(ctx, option, calendars): if not calendars: return if 'calendar_selection' in ctx.obj: raise click.UsageError('Can\'t use both -a and -d.') if not isinstance(calendars, tuple): calendars = (calendars,) mode = option.name selection = ctx.obj['calendar_selection'] = set() if mode == 'include_calendar': for cal_name in calendars: if cal_name not in ctx.obj['conf']['calendars']: raise click.BadParameter( 'Unknown calendar {}, run `khal printcalendars` to get a ' 'list of all configured calendars.'.format(cal_name) ) selection.update(calendars) elif mode == 'exclude_calendar': selection.update(ctx.obj['conf']['calendars'].keys()) for value in calendars: selection.remove(value) else: raise ValueError(mode) def multi_calendar_option(f): a = click.option('--include-calendar', '-a', multiple=True, metavar='CAL', expose_value=False, callback=_multi_calendar_select_callback, help=('Include the given calendar. Can be specified ' 'multiple times.')) d = click.option('--exclude-calendar', '-d', multiple=True, metavar='CAL', expose_value=False, callback=_multi_calendar_select_callback, help=('Exclude the given calendar. Can be specified ' 'multiple times.')) return d(a(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( 'Unknown calendar {}, run `khal printcalendars` to get a ' 'list of all configured calendars.'.format(calendar) ) return calendar def calendar_option(f): return click.option('--calendar', '-a', metavar='CAL', callback=_calendar_select_callback)(f) def global_options(f): def config_callback(ctx, option, config): prepare_context(ctx, config) def verbosity_callback(ctx, option, verbose): if verbose: logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) def color_callback(ctx, option, value): ctx.color = value config = click.option( '--config', '-c', is_eager=True, # make sure other options can access config help='The config file to use.', default=None, metavar='PATH', expose_value=False, callback=config_callback ) verbose = click.option( '--verbose', '-v', is_eager=True, # make sure to log config when debugging help='Output debugging information.', is_flag=True, expose_value=False, callback=verbosity_callback ) 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 ) version = click.version_option(version=__version__) return config(verbose(color(version(f)))) def build_collection(conf, selection): """build and return a khalendar.CalendarCollection from the configuration""" try: props = dict() 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'], 'ctype': cal['type'], } 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'], highlight_event_days=conf['default']['highlight_event_days'], ) except FatalError as error: logger.fatal(error) sys.exit(1) collection._default_calendar_name = conf['default']['default_calendar'] return collection class _NoConfig(object): 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 = list() for key, value in conf.items(): out.append('[{}]'.format(key)) for subkey, subvalue in value.items(): if isinstance(subvalue, dict): out.append(' [[{}]]'.format(subkey)) for subsubkey, subsubvalue in subvalue.items(): out.append(' {}: {}'.format(subsubkey, subsubvalue)) else: out.append(' {}: {}'.format(subkey, subvalue)) return '\n'.join(out) def _get_cli(): @click.group(invoke_without_command=True) @global_options @click.pass_context def cli(ctx): # 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 not ctx.invoked_subcommand: command = ctx.obj['conf']['default']['default_command'] if command: ctx.invoke(cli.commands[command]) else: click.echo(ctx.get_help()) ctx.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( '--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, daterange, once, notstarted, format, day_format): '''Print calendar with agenda.''' try: rows = controllers.calendar( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), 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'], hmethod=ctx.obj['conf']['highlight_days']['method'], default_color=ctx.obj['conf']['highlight_days']['default_color'], multiple=ctx.obj['conf']['highlight_days']['multiple'], 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.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.argument('DATERANGE', nargs=-1, required=False, metavar='[DATETIME [DATETIME | RANGE]]') @click.pass_context def klist(ctx, daterange, once, notstarted, format, day_format): """List all events between a start (default: today) and (optional) end datetime.""" try: event_column = controllers.khal_list( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), agenda_format=format, day_format=day_format, daterange=daterange, once=once, notstarted=notstarted, conf=ctx.obj['conf'], env={"calendars": ctx.obj['conf']['calendars']} ) click.echo('\n'.join(event_column)) except FatalError as error: 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.')) @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('--alarms', '-m', help=('Alarm times for the new event as DELTAs comma separated')) @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, format, 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, format=format, ) except FatalError as error: 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.') try: # Default to stdin: if not ics: ics_strs = (sys.stdin.read(),) if not batch: if os.path.isfile('/dev/tty'): sys.stdin = open('/dev/tty', 'r') else: logger.warning('/dev/tty does not exist, importing might not work') else: ics_strs = (ics_file.read() for ics_file in ics) for ics_str in ics_strs: 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.fatal(error) sys.exit(1) @cli.command() @multi_calendar_option @click.pass_context def interactive(ctx): '''Interactive UI. Also launchable via `ikhal`.''' controllers.interactive( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), ctx.obj['conf'] ) @click.command() @global_options @multi_calendar_option @click.pass_context def interactive_cli(ctx): '''Interactive UI. Also launchable via `khal interactive`.''' controllers.interactive( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), ctx.obj['conf']) @cli.command() @multi_calendar_option @click.pass_context def printcalendars(ctx): '''List all calendars.''' try: click.echo( '\n'.join( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)).names ) ) except FatalError as error: 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 10:09 in all configured date(time) formats to check if these locale settings are configured to ones liking.''' from datetime import datetime time = datetime(2013, 12, 21, 10, 9) try: for strftime_format in [ 'longdatetimeformat', 'datetimeformat', 'longdateformat', 'dateformat', 'timeformat']: dt_str = time.strftime(ctx.obj['conf']['locale'][strftime_format]) click.echo('{}: {}'.format(strftime_format, dt_str)) except FatalError as error: 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.fatal(error) sys.exit(1) @cli.command() @multi_calendar_option @click.option('--format', '-f', help=('The format of the events.')) @click.argument('search_string') @click.pass_context def search(ctx, format, search_string): '''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'], ctx.obj.get('calendar_selection', None)) events = sorted(collection.search(search_string)) event_column = list() term_width, _ = get_terminal_size() now = datetime.datetime.now() env = {"calendars": ctx.obj['conf']['calendars']} for event in events: desc = textwrap.wrap(event.format(format, 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] ) click.echo('\n'.join(event_column)) except FatalError as error: 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): '''Interactively edit (or delete) events matching the search string.''' try: controllers.edit( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), ' '.join(search_string), format=format, allow_past=show_past, locale=ctx.obj['conf']['locale'], conf=ctx.obj['conf'] ) except FatalError as error: 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.argument('DATETIME', nargs=-1, required=False, metavar='[[START DATE] TIME | now]') @click.pass_context def at(ctx, datetime, notstarted, format, day_format): '''Print all events at a specific datetime (defaults to now).''' if not datetime: datetime = ("now",) try: rows = controllers.khal_list( build_collection(ctx.obj['conf'], ctx.obj.get('calendar_selection', None)), agenda_format=format, day_format=day_format, datepoint=list(datetime), once=True, notstarted=notstarted, conf=ctx.obj['conf'], env={"calendars": ctx.obj['conf']['calendars']} ) click.echo('\n'.join(rows)) except FatalError as error: 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.fatal(error) sys.exit(1) return cli, interactive_cli main_khal, main_ikhal = _get_cli() khal-0.9.10/khal/utils.py0000644000076600000240000006161213357150322017304 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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""" from calendar import isleap, month_abbr from collections import defaultdict from datetime import date, datetime, timedelta, time import random import string import re from time import strptime from textwrap import wrap import icalendar import pytz from khal.log import logger from khal.exceptions import FatalError from .khalendar.utils import sanitize def timefstr(dtime_list, timeformat): """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 :type dtime_list: list(str) :type timeformat: str :rtype: datetime.datetime """ if len(dtime_list) == 0: raise ValueError() time_start = datetime.strptime(dtime_list[0], timeformat) time_start = time(*time_start.timetuple()[3:5]) day_start = date.today() dtstart = datetime.combine(day_start, time_start) dtime_list.pop(0) return dtstart def datetimefstr(dtime_list, dtformat): """ converts a datetime (as one or several string elements of a list) to a datetimeobject removes "used" elements of list :returns: a datetime :rtype: datetime.datetime """ parts = dtformat.count(' ') + 1 dtstring = ' '.join(dtime_list[0:parts]) dtstart = datetime.strptime(dtstring, dtformat) for _ in range(parts): dtime_list.pop(0) return dtstart def weekdaypstr(dayname): """converts an (abbreviated) dayname to a number (mon=0, sun=6) :param dayname: name of abbreviation of the day :type dayname: str :return: number of the day in a week :rtype: int """ 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_): """converts datetime.date into a string description either `Today`, `Tomorrow` or name of weekday. """ if date_ == date.today(): return 'Today' elif date_ == date.today() + timedelta(days=1): return 'Tomorrow' else: return date_.strftime('%A') def relative_timedelta_str(day): """Converts the timespan from `day` to today into a human readable string. :type day: datetime.date :rtype: str """ days = (day - 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 '{approx}{count} {unit} {direction}'.format( approx=approx, count=count, unit=unit, direction=direction, ) def calc_day(dayname): """converts a relative date's description to a datetime object :param dayname: relative day name (like 'today' or 'monday') :type dayname: str :returns: date :rtype: datetime.datetime """ today = datetime.combine(date.today(), time.min) dayname = dayname.lower() if dayname == 'today': return today if dayname == 'tomorrow': return today + timedelta(days=1) if dayname == 'yesterday': return today - timedelta(days=1) wday = weekdaypstr(dayname) days = (wday - today.weekday()) % 7 days = 7 if days == 0 else days day = today + timedelta(days=days) return day def datefstr_weekday(dtime_list, _): """interprets first element of a list as a relative date and removes that element :param dtime_list: event description in list form :type dtime_list: list :returns: date :rtype: datetime.datetime """ if len(dtime_list) == 0: raise ValueError() day = calc_day(dtime_list[0]) dtime_list.pop(0) return day def datetimefstr_weekday(dtime_list, timeformat): 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 = datetime.combine(day, this_time.time()) return dtime def guessdatetimefstr(dtime_list, locale, default_day=None): """ :type dtime_list: list :type locale: dict :type default_day: datetime.datetime :rtype: datetime.datetime """ # if now() is called as default param, mocking with freezegun won't work if default_day is None: default_day = datetime.now().date() # TODO rename in guessdatetimefstrLIST or something saner altogether def timefstr_day(dtime_list, timeformat): if locale['timeformat'] == '%H:%M' and dtime_list[0] == '24:00': a_date = datetime.combine(default_day, time(0)) dtime_list.pop(0) else: a_date = timefstr(dtime_list, timeformat) a_date = datetime(*(default_day.timetuple()[:3] + a_date.timetuple()[3:5])) return a_date def datetimefwords(dtime_list, _): if len(dtime_list) > 0 and dtime_list[0].lower() == 'now': dtime_list.pop(0) return datetime.now() raise ValueError def datefstr_year(dtime_list, dateformat): """should be used if a date(time) without year is given we cannot use datetimefstr() here, because only time.strptime can parse the 29th of Feb. if no year is given example: dtime_list = ['17.03.', 'description'] dateformat = '%d.%m.' or : dtime_list = ['17.03.', '16:00', 'description'] dateformat = '%d.%m. %H:%M' """ parts = dateformat.count(' ') + 1 dtstring = ' '.join(dtime_list[0:parts]) dtstart = strptime(dtstring, dateformat) if dtstart.tm_mon == 2 and dtstart.tm_mday == 29 and not isleap(default_day.year): raise ValueError for _ in range(parts): dtime_list.pop(0) a_date = datetime(*(default_day.timetuple()[:1] + dtstart[1:5])) return a_date dtstart = None for fun, dtformat, all_day, shortformat in [ (datefstr_year, locale['datetimeformat'], False, True), (datetimefstr, locale['longdatetimeformat'], False, False), (timefstr_day, locale['timeformat'], False, False), (datetimefstr_weekday, locale['timeformat'], False, False), (datefstr_year, locale['dateformat'], True, True), (datetimefstr, locale['longdateformat'], True, False), (datefstr_weekday, None, True, False), (datetimefwords, None, False, False), ]: if shortformat and '97' in datetime(1997, 10, 11).strftime(dtformat): continue try: dtstart = fun(dtime_list, dtformat) except ValueError: pass else: return dtstart, all_day raise ValueError() def timedelta2str(delta): # 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): """parses a timedelta from a string :param delta_string: string encoding time-delta, e.g. '1h 15m' :type delta_string: str :rtype: datetime.timedelta """ tups = re.split(r'(-?\d+)', delta_string) if not re.match(r'^\s*$', tups[0]): raise ValueError('Invalid beginning of timedelta string "%s": "%s"' % (delta_string, tups[0])) tups = tups[1:] res = timedelta() for num, unit in zip(tups[0::2], tups[1::2]): try: numint = int(num) except ValueError: raise ValueError('Invalid number in timedelta string "%s": "%s"' % (delta_string, num)) ulower = unit.lower().strip() if ulower == 'd' or ulower == 'day' or ulower == 'days': res += timedelta(days=numint) elif ulower == 'h' or ulower == 'hour' or ulower == 'hours': res += timedelta(hours=numint) elif (ulower == 'm' or ulower == 'minute' or ulower == 'minutes' or ulower == 'min'): res += timedelta(minutes=numint) elif (ulower == 's' or ulower == 'second' or ulower == 'seconds' or ulower == 'sec'): res += timedelta(seconds=numint) else: raise ValueError('Invalid unit in timedelta string "%s": "%s"' % (delta_string, unit)) return res def guessrangefstr(daterange, locale, adjust_reasonably=False, default_timedelta_date=timedelta(days=1), default_timedelta_datetime=timedelta(hours=1), ): """parses a range string :param daterange: date1 [date2 | timedelta] :type daterange: str or list :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 :rtype: (datetime, datetime, bool) """ range_list = daterange if isinstance(daterange, str): range_list = daterange.split(' ') if range_list == ['week']: today_weekday = datetime.today().weekday() start = datetime.today() - timedelta(days=(today_weekday - locale['firstweekday'])) end = start + timedelta(days=8) return start, end, True for i in reversed(range(1, len(range_list) + 1)): start = ' '.join(range_list[:i]) end = ' '.join(range_list[i:]) allday = False try: # figuring out start split = start.split(" ") start, allday = guessdatetimefstr(split, locale) if len(split) != 0: continue # and end if len(end) == 0: if allday: end = start + default_timedelta_date else: end = start + default_timedelta_datetime elif end.lower() == 'eod': end = datetime.combine(start.date(), time.max) elif end.lower() == 'week': start -= timedelta(days=(start.weekday() - locale['firstweekday'])) end = start + timedelta(days=8) else: try: delta = guesstimedeltafstr(end) 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: split = end.split(" ") end, end_allday = guessdatetimefstr(split, locale, default_day=start.date()) if len(split) != 0: continue if allday: end += timedelta(days=1) if adjust_reasonably: if allday: # test if end's year is this year, but start's year is not today = datetime.today() if end.year == today.year and start.year != today.year: end = datetime(start.year, *end.timetuple()[1:6]) if end < start: end = datetime(end.year + 1, *end.timetuple()[1:6]) if end < start: end = datetime(*start.timetuple()[0:3] + end.timetuple()[3:5]) if end < start: end = end + timedelta(days=1) return start, end, allday except ValueError: pass raise ValueError('Could not parse `{}` as a daterange'.format(daterange)) def generate_random_uid(): """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)]) def rrulefstr(repeat, until, locale): if repeat in ["daily", "weekly", "monthly", "yearly"]: rrule_settings = {'freq': repeat} if until: until_date = None for fun, dformat in [(datetimefstr, locale['datetimeformat']), (datetimefstr, locale['longdatetimeformat']), (timefstr, locale['timeformat']), (datetimefstr, locale['dateformat']), (datetimefstr, locale['longdateformat'])]: try: until_date = fun(until.split(' '), dformat) break except ValueError: pass if until_date is None: logger.fatal("Cannot parse until date: '{}'\nPlease have a look " "at the documentation.".format(until)) raise FatalError() rrule_settings['until'] = until_date 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, locale, adjust_reasonably=False, localize=False): """parses a string of the form START [END | DELTA] [TIMEZONE] [SUMMARY] [:: DESCRIPTION] into a dictionary with keys: dtstart, dtend, timezone, allday, summary, description :param info_string: :type info_string: string fitting the form :param locale: :type locale: locale :param adjust_reasonably: :type adjust_reasonably: passed on to guessrangefstr :rtype: dictionary """ description = None if " :: " in info_string: info_string, description = info_string.split(' :: ') parts = info_string.split(' ') summary = None start = None end = None tz = None allday = False for i in reversed(range(1, len(parts) + 1)): try: start, end, allday = guessrangefstr( ' '.join(parts[0:i]), locale, adjust_reasonably=adjust_reasonably, ) except ValueError: 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 ValueError('Could not parse `{}`'.format(info_string)) if tz is None: tz = locale['default_timezone'] if allday: start = start.date() end = end.date() info = {} 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 def new_event(locale, dtstart=None, dtend=None, summary=None, timezone=None, allday=False, description=None, location=None, categories=None, repeat=None, until=None, alarms=None): """create a new event :param dtstart: starttime of that event :type dtstart: datetime :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* :type dtend: datetime :param summary: description of the event, used in the SUMMARY property :type summary: unicode :param timezone: timezone of the event (start and end) :type timezone: pytz.timezone :param allday: if set to True, we will not transform dtstart and dtend to datetime :type allday: bool :returns: event :rtype: icalendar.Event """ if dtstart is None: raise ValueError("no start given") if dtend is None: raise ValueError("no end given") if summary is None: raise ValueError("no summary given") if not allday and timezone is not None: dtstart = timezone.localize(dtstart) dtend = timezone.localize(dtend) event = icalendar.Event() event.add('dtstart', dtstart) event.add('dtend', dtend) event.add('dtstamp', 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 repeat and repeat != "none": rrule = rrulefstr(repeat, until, locale) event.add('rrule', rrule) if alarms: for alarm in alarms.split(","): alarm = alarm.strip() alarm_trig = -1 * guesstimedeltafstr(alarm) new_alarm = icalendar.Alarm() new_alarm.add('ACTION', 'DISPLAY') new_alarm.add('TRIGGER', alarm_trig) new_alarm.add('DESCRIPTION', description) event.add_component(new_alarm) return event def split_ics(ics, random_uid=False, default_timezone=None): """split an ics string into several according to VEVENT's UIDs and sort the right VTIMEZONEs accordingly ignores all other ics components :type ics: str :param random_uid: assign random uids to all events :type random_uid: bool :rtype list: """ cal = icalendar.Calendar.from_ical(ics) tzs = {item['TZID']: item for item in cal.walk() if item.name == 'VTIMEZONE'} events_grouped = defaultdict(list) for item in cal.walk(): if item.name == 'VEVENT': events_grouped[item['UID']].append(item) else: continue return [ics_from_list(events, tzs, random_uid) for uid, events in sorted(events_grouped.items())] def ics_from_list(events, tzs, random_uid=False, default_timezone=None): """convert an iterable of icalendar.Events to an icalendar.Calendar :params events: list of events all with the same uid :type events: list(icalendar.cal.Event) :param random_uid: assign random uids to all events :type random_uid: bool :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( 'Cannot find timezone `{}` in .ics file, using default timezone. ' 'This can lead to erroneous time shifts'.format(item.params['TZID']) ) 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( 'Cannot find timezone `{}` in .ics file, this could be a bug, ' 'please report this issue at http://github.com/pimutils/khal/.'.format(tzid)) for sub_event in events: calendar.add_component(sub_event) return calendar.to_ical().decode('utf-8') 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): for match in re.finditer(ansi_reset, string): pass try: return match.start(), match.end(), match.group(0) except UnboundLocalError: return -2, -1, '' def find_last_sgr(string): for match in re.finditer(ansi_sgr, string): pass try: return match.start(), match.end(), match.group(0) except UnboundLocalError: return -2, -1, '' def find_unmatched_sgr(string): reset_pos, _, _ = find_last_reset(string) sgr_pos, _, sgr = find_last_sgr(string) if sgr_pos > reset_pos: return sgr else: return False def color_wrap(text, width=70): """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: lines[num] += RESET if num != len(lines): lines[num + 1] = sgr + lines[num + 1] return lines def get_weekday_occurrence(day): """Calculate how often this weekday has already occurred in a given month. :type day: datetime.date :returns: weekday (0=Monday, ..., 6=Sunday), occurrence :rtype: tuple(int, int) """ xthday = 1 + (day.day - 1) // 7 return day.weekday(), xthday def get_month_abbr_len(): """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 khal-0.9.10/khal/exceptions.py0000644000076600000240000000264613357150322020327 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 ConfigurationError(FatalError): pass class UnsupportedFeatureError(Error): """something Failed but we know why""" pass class InvalidDate(Error): pass khal-0.9.10/khal/__main__.py0000644000076600000240000000224013243067215017656 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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.9.10/bin/0000755000076600000240000000000013357150672015425 5ustar christiangeierstaff00000000000000khal-0.9.10/bin/khal0000644000076600000240000000014113243067215016255 0ustar christiangeierstaff00000000000000#!/usr/bin/env python from khal.cli import main_khal if __name__ == "__main__": main_khal() khal-0.9.10/bin/ikhal0000644000076600000240000000014313243067215016430 0ustar christiangeierstaff00000000000000#!/usr/bin/env python from khal.cli import main_ikhal if __name__ == "__main__": main_ikhal() khal-0.9.10/CONTRIBUTING.rst0000644000076600000240000000017413243067215017312 0ustar christiangeierstaff00000000000000Please see `the documentation `_ for how to contribute to this project. khal-0.9.10/tests/0000755000076600000240000000000013357150672016017 5ustar christiangeierstaff00000000000000khal-0.9.10/tests/vdir_test.py0000644000076600000240000000452113243067215020370 0ustar christiangeierstaff00000000000000# Copyright (c) 2013-2017 Christian Geier et al. # # 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 import time import pytest from khal.khalendar import vdir @pytest.mark.xfail def test_etag(tmpdir): fpath = os.path.join(str(tmpdir), 'foo') file_ = open(fpath, 'w') file_.write('foo') file_.close() old_etag = vdir.get_etag_from_file(fpath) file_ = open(fpath, 'w') file_.write('foo') file_.close() new_etag = vdir.get_etag_from_file(fpath) try: assert old_etag != new_etag except AssertionError: pytest.xfail( "Do we need to sleep?" ) def test_etag_sync(tmpdir): 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) 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_sleep(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) time.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 khal-0.9.10/tests/ui/0000755000076600000240000000000013357150673016435 5ustar christiangeierstaff00000000000000khal-0.9.10/tests/ui/test_calendarwidget.py0000644000076600000240000000406313357150322023015 0ustar christiangeierstaff00000000000000from datetime import date, timedelta from freezegun import freeze_time from khal.ui.calendarwidget import CalendarWidget on_press = {} keybindings = { 'today': ['T'], 'left': ['left', 'h', 'backspace'], 'up': ['up', 'k'], 'right': ['right', 'l', ' '], 'down': ['down', 'j'], } def test_initial_focus_today(): today = 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 = 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 + 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 = 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 + timedelta(days=diff) frame.set_focus_date(day) assert frame.focus_date == day with freeze_time('2016-04-23'): today = 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 + timedelta(days=diff) frame.set_focus_date(day) assert frame.focus_date == day khal-0.9.10/tests/ui/test_editor.py0000644000076600000240000000315113357150322021323 0ustar christiangeierstaff00000000000000from datetime import datetime, date import icalendar from khal.ui.editor import StartEndEditor, RecurrenceEditor from ..utils import LOCALE_BERLIN, BERLIN CONF = {'locale': LOCALE_BERLIN, 'keybindings': {}} START = BERLIN.localize(datetime(2015, 4, 26, 22, 23)) END = BERLIN.localize(datetime(2015, 4, 27, 23, 23)) 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'] == date(2015, 4, 26) see.widgets.enddate.keypress((22, ), 'enter') assert fake.kwargs['initial'] == 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 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') ) khal-0.9.10/tests/ui/__init__.py0000644000076600000240000000000013243067215020525 0ustar christiangeierstaff00000000000000khal-0.9.10/tests/ui/test_widgets.py0000644000076600000240000000171713243067215021513 0ustar christiangeierstaff00000000000000from 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.9.10/tests/terminal_test.py0000644000076600000240000000260613357150322021237 0ustar christiangeierstaff00000000000000from khal.terminal import merge_columns, colored 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.9.10/tests/conftest.py0000644000076600000240000000554513357150322020217 0ustar christiangeierstaff00000000000000import os from time import sleep import pytest import pytz from khal.khalendar import CalendarCollection from khal.khalendar.vdir import Vdir from .utils import LOCALE_BERLIN, example_cals, cal1 @pytest.fixture def coll_vdirs(tmpdir): calendars, vdirs = dict(), dict() 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} vdirs[name] = Vdir(path, '.ics') 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(scope='session') def pytz_version(): """Return the version of pytz as a tuple.""" year, month = pytz.__version__.split('.') return int(year), int(month) khal-0.9.10/tests/utils_test.py0000644000076600000240000006406113357150322020567 0ustar christiangeierstaff00000000000000"""testing functions from the khal.utils""" from datetime import date, datetime, time, timedelta from collections import OrderedDict import textwrap import random import icalendar from freezegun import freeze_time from khal.utils import guessdatetimefstr, guesstimedeltafstr, new_event, eventinfofstr from khal.utils import timedelta2str, guessrangefstr, weekdaypstr, construct_daynames from khal.utils import get_weekday_occurrence from khal import utils from khal.exceptions import FatalError import pytest from .utils import _get_text, normalize_component, \ LOCALE_BERLIN, LOCALE_NEW_YORK today = date.today() tomorrow = today + timedelta(days=1) 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, adjust_reasonably=True, localize=False) if description is not None: info["description"] = description event = new_event(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;VALUE=DATE-TIME: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()) def _create_testcases(*cases): return [(userinput, ('\r\n'.join(output) + '\r\n').encode('utf-8')) for userinput, output in cases] def _replace_uid(event): """ Replace an event's UID with E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA. """ event.pop('uid') event.add('uid', 'E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA') return event 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;VALUE=DATE-TIME:20140409T093000 END:VEVENT """)) != normalize_component(textwrap.dedent(""" BEGIN:VEVENT DTSTART;TZID=Oyrope/Berlin;VALUE=DATE-TIME:20140409T093000 END:VEVENT """)) class TestGuessDatetimefstr: tomorrow16 = datetime.combine(tomorrow, time(16, 0)) def test_today(self): with freeze_time('2016-9-19 8:00'): today13 = datetime.combine(date.today(), time(13, 0)) assert (today13, False) == guessdatetimefstr(['today', '13:00'], LOCALE_BERLIN) assert date.today() == guessdatetimefstr(['today'], LOCALE_BERLIN)[0].date() def test_tomorrow(self): assert (self.tomorrow16, False) == \ guessdatetimefstr('tomorrow 16:00 16:00'.split(), locale=LOCALE_BERLIN) def test_time_tomorrow(self): assert (self.tomorrow16, False) == \ guessdatetimefstr('16:00'.split(), locale=LOCALE_BERLIN, default_day=tomorrow) def test_time_yesterday(self): with freeze_time('2016-9-19'): assert (datetime(2016, 9, 18, 16), False) == \ guessdatetimefstr( 'Yesterday 16:00'.split(), locale=LOCALE_BERLIN, default_day=datetime.today()) def test_time_weekday(self): with freeze_time('2016-9-19'): assert (datetime(2016, 9, 23, 16), False) == \ guessdatetimefstr( 'Friday 16:00'.split(), locale=LOCALE_BERLIN, default_day=datetime.today()) def test_time_now(self): with freeze_time('2016-9-19 17:53'): assert (datetime(2016, 9, 19, 17, 53), False) == \ guessdatetimefstr('now'.split(), locale=LOCALE_BERLIN, default_day=datetime.today()) 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', } with freeze_time('2016-12-30 17:53'): assert (datetime(2017, 1, 1), True) == \ guessdatetimefstr('2017-1-1'.split(), locale=locale, default_day=datetime.today()) with freeze_time('2016-12-30 17:53'): assert (datetime(2017, 1, 1, 16, 30), False) == guessdatetimefstr( '2017-1-1 16:30'.split(), locale=locale, default_day=datetime.today(), ) class TestGuessTimedeltafstr: def test_single(self): assert timedelta(minutes=10) == guesstimedeltafstr('10m') def test_seconds(self): assert timedelta(seconds=10) == guesstimedeltafstr('10s') def test_negative(self): assert timedelta(minutes=-10) == guesstimedeltafstr('-10m') def test_multi(self): assert timedelta(days=1, hours=-3, minutes=10) == \ guesstimedeltafstr(' 1d -3H 10min ') def test_multi_nospace(self): assert 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 timedelta(minutes=20) == \ guesstimedeltafstr('10min 10minutes') class TestGuessRangefstr: td_1d = timedelta(days=1) today_start = datetime.combine(date.today(), time.min) tomorrow_start = today_start + td_1d today13 = datetime.combine(date.today(), time(13, 0)) today14 = datetime.combine(date.today(), time(14, 0)) tomorrow16 = datetime.combine(tomorrow, time(16, 0)) today16 = datetime.combine(date.today(), time(16, 0)) today17 = datetime.combine(date.today(), time(17, 0)) def test_today(self): with freeze_time('2016-9-19'): assert (datetime(2016, 9, 19, 13), datetime(2016, 9, 19, 14), False) == \ guessrangefstr('13:00 14:00', locale=LOCALE_BERLIN) assert (datetime(2016, 9, 19), datetime(2016, 9, 21), True) == \ guessrangefstr('today tomorrow', LOCALE_BERLIN) def test_tomorrow(self): # XXX remove me, we shouldn't support this anyway with freeze_time('2016-9-19 16:34'): assert (datetime(2016, 9, 19), datetime(2016, 9, 21, 16), True) == \ guessrangefstr('today tomorrow 16:00', locale=LOCALE_BERLIN) def test_time_tomorrow(self): with freeze_time('2016-9-19 13:34'): assert (datetime(2016, 9, 19, 16), datetime(2016, 9, 19, 17), False) == \ guessrangefstr('16:00', locale=LOCALE_BERLIN) assert (datetime(2016, 9, 19, 16), datetime(2016, 9, 19, 17), False) == \ guessrangefstr('16:00 17:00', locale=LOCALE_BERLIN) def test_start_and_end_date(self): assert (datetime(2016, 1, 1), datetime(2017, 1, 2), True) == \ guessrangefstr('1.1.2016 1.1.2017', locale=LOCALE_BERLIN) def test_start_and_no_end_date(self): assert (datetime(2016, 1, 1), datetime(2016, 1, 2), True) == \ guessrangefstr('1.1.2016', locale=LOCALE_BERLIN) def test_start_and_end_date_time(self): assert (datetime(2016, 1, 1, 10), 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): assert (datetime(2016, 1, 1, 10), datetime(2016, 1, 1, 23, 59, 59, 999999), False) == \ guessrangefstr('1.1.2016 10:00 eod', locale=LOCALE_BERLIN) def test_start_and_week(self): assert (datetime(2015, 12, 28), datetime(2016, 1, 5), True) == \ guessrangefstr('1.1.2016 week', locale=LOCALE_BERLIN) def test_start_and_delta_1d(self): assert (datetime(2016, 1, 1), datetime(2016, 1, 2), True) == \ guessrangefstr('1.1.2016 1d', locale=LOCALE_BERLIN) def test_start_and_delta_3d(self): assert (datetime(2016, 1, 1), datetime(2016, 1, 4), True) == \ guessrangefstr('1.1.2016 3d', locale=LOCALE_BERLIN) def test_start_dt_and_delta(self): assert (datetime(2016, 1, 1, 10), 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 (datetime(2016, 2, 15), datetime(2016, 2, 23), True) == \ guessrangefstr('week', locale=LOCALE_BERLIN) def test_invalid(self): with pytest.raises(ValueError): guessrangefstr('3d', locale=LOCALE_BERLIN) with pytest.raises(ValueError): guessrangefstr('35.1.2016', locale=LOCALE_BERLIN) with pytest.raises(ValueError): guessrangefstr('1.1.2016 2x', locale=LOCALE_BERLIN) with pytest.raises(ValueError): guessrangefstr('1.1.2016x', locale=LOCALE_BERLIN) with pytest.raises(ValueError): guessrangefstr('xxx yyy zzz', locale=LOCALE_BERLIN) 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', } with freeze_time('2016-12-30 17:53'): assert (datetime(2017, 1, 1), datetime(2017, 1, 2), True) == \ guessrangefstr('2017-1-1 2017-1-1', locale=locale) class TestTimeDelta2Str: def test_single(self): assert timedelta2str(timedelta(minutes=10)) == '10m' def test_negative(self): assert timedelta2str(timedelta(minutes=-10)) == '-10m' def test_days(self): assert timedelta2str(timedelta(days=2)) == '2d' def test_multi(self): assert timedelta2str(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(date(2016, 9, 19)) == 'Today' assert construct_daynames(date(2016, 9, 20)) == 'Tomorrow' assert construct_daynames(date(2016, 9, 21)) == 'Wednesday' 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;VALUE=DATE-TIME:20131025T180000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME: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;VALUE=DATE-TIME:20131025T180000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T000000')), # start and end date same, explicit end date (but no year) given # XXX FIXME: if no explicit year is given for the end, this_year is used ('25.10.2013 18:00 26.10. 20:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T180000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T200000')), # 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;VALUE=DATE-TIME:20131025T230000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131026T003000')), # only start datetime given ('25.10.2013 06:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T060000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20131025T070000')), # timezone given ('25.10.2013 06:00 America/New_York Äwesöme Event', _create_vevent( 'DTSTART;TZID=America/New_York;VALUE=DATE-TIME:20131025T060000', 'DTEND;TZID=America/New_York;VALUE=DATE-TIME: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;VALUE=DATE-TIME:19991231T060000', 'DTEND;TZID=America/New_York;VALUE=DATE-TIME:19991231T070000')), ('2014/12/18 2014/12/20 Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20141218', 'DTEND;VALUE=DATE:20141221')), ) def test__construct_event_format_us(): for data_list, vevent in test_set_format_us: with freeze_time('2014-02-16 12:00:00'): 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;VALUE=DATE-TIME:20140216T080000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000')), # today until tomorrow ('22:00 1:00 Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T220000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140217T010000')), # other timezone ('22:00 1:00 Europe/London Äwesöme Event', _create_vevent( 'DTSTART;TZID=Europe/London;VALUE=DATE-TIME:20140216T220000', 'DTEND;TZID=Europe/London;VALUE=DATE-TIME:20140217T010000')), ('15.06. Äwesöme Event', _create_vevent('DTSTART;VALUE=DATE:20140615', 'DTEND;VALUE=DATE:20140616')), ) def test__construct_event_format_de_complexer(): for data_list, vevent in test_set_format_de_complexer: 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_leap_year = _create_testcases( ('29.02. Äwesöme Event', _create_vevent( 'DTSTART;VALUE=DATE:20160229', 'DTEND;VALUE=DATE:20160301', 'DTSTAMP;VALUE=DATE-TIME:20160101T202122Z')), ) def test_leap_year(): for data_list, vevent in test_set_leap_year: with freeze_time('1999-1-1'): with pytest.raises(ValueError): 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;VALUE=DATE-TIME:20140216T080000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME: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;VALUE=DATE-TIME:20140216T220000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME: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 = _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;VALUE=DATE-TIME:20140216T080000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000', 'DESCRIPTION:please describe the event', 'RRULE:FREQ=DAILY;UNTIL=20150605T000000'))) def test_repeat(): for data_list, vevent in test_set_repeat: 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;VALUE=DATE-TIME:20140216T080000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000', 'DTSTAMP;VALUE=DATE-TIME:20140216T120000Z', 'UID:E41JRQX2DB4P1AQZI86BAT7NHPBHPRIIHQKA', 'DESCRIPTION:please describe the event', 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:please describe the event', 'TRIGGER:-PT23M', 'END:VALARM', 'END:VEVENT'])) def test_alarm(): for data_list, vevent in test_set_alarm: with freeze_time('2014-02-16 12:00:00'): 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;VALUE=DATE-TIME:20140216T080000', 'DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140216T090000', 'CATEGORIES:boring meeting', 'DESCRIPTION:please describe the event', 'LOCATION:in the office'))) def test_description_and_location_and_categories(): for data_list, vevent in test_set_description_and_location_and_categories: with freeze_time('2014-02-16 12:00:00'): 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 def test_split_ics(): cal = _get_text('cal_lots_of_timezones') vevents = utils.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 = utils.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_relative_timedelta_str(): with freeze_time('2016-9-19'): assert utils.relative_timedelta_str(date(2016, 9, 24)) == '5 days from now' assert utils.relative_timedelta_str(date(2016, 9, 29)) == '~1 week from now' assert utils.relative_timedelta_str(date(2017, 9, 29)) == '~1 year from now' assert utils.relative_timedelta_str(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 False assert utils.find_unmatched_sgr(today_line) is False assert utils.find_unmatched_sgr(calendarline) is False 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_get_weekday_occurrence(): assert get_weekday_occurrence(datetime(2017, 3, 1)) == (2, 1) assert get_weekday_occurrence(datetime(2017, 3, 2)) == (3, 1) assert get_weekday_occurrence(datetime(2017, 3, 3)) == (4, 1) assert get_weekday_occurrence(datetime(2017, 3, 4)) == (5, 1) assert get_weekday_occurrence(datetime(2017, 3, 5)) == (6, 1) assert get_weekday_occurrence(datetime(2017, 3, 6)) == (0, 1) assert get_weekday_occurrence(datetime(2017, 3, 7)) == (1, 1) assert get_weekday_occurrence(datetime(2017, 3, 8)) == (2, 2) assert get_weekday_occurrence(datetime(2017, 3, 9)) == (3, 2) assert get_weekday_occurrence(datetime(2017, 3, 10)) == (4, 2) assert get_weekday_occurrence(datetime(2017, 3, 31)) == (4, 5) assert get_weekday_occurrence(date(2017, 5, 1)) == (0, 1) assert get_weekday_occurrence(date(2017, 5, 7)) == (6, 1) assert get_weekday_occurrence(date(2017, 5, 8)) == (0, 2) assert get_weekday_occurrence(date(2017, 5, 28)) == (6, 4) assert get_weekday_occurrence(date(2017, 5, 29)) == (0, 5) khal-0.9.10/tests/controller_test.py0000644000076600000240000001567113357150322021615 0ustar christiangeierstaff00000000000000import datetime as dt from textwrap import dedent from freezegun import freeze_time import pytest from khal.khalendar.vdir import Item from khal.controllers import import_ics, khal_list, start_end_from_daterange from khal import exceptions from .utils import _get_text from . import utils 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.new_event(event_today, utils.cal1) coll.new(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.new_event(event_today, utils.cal1) coll.new(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.new_event(event_today, utils.cal1) coll.new(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.new(coll.new_event(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 'no events' in '\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.9.10/tests/settings_test.py0000644000076600000240000002242513357150322021265 0ustar christiangeierstaff00000000000000import os.path import datetime as dt from validate import VdtValueError import pytest from tzlocal import get_localzone from .utils import LOCALE_BERLIN from khal.settings import get_config from khal.settings.exceptions import InvalidSettingsError, \ CannotParseConfigFileError from khal.settings.utils import get_all_vdirs, get_unique_name, config_checks, \ get_color_from_vdir, is_color PATH = __file__.rsplit('/', 1)[0] + '/configs/' class TestSettings(object): 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, 'type': 'calendar'}, 'work': {'path': os.path.expanduser('~/.calendars/work/'), 'readonly': False, 'color': None, 'type': 'calendar'}, }, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, 'locale': LOCALE_BERLIN, 'default': { 'default_command': 'calendar', 'default_calendar': None, 'print_new': 'False', 'highlight_event_days': False, 'timedelta': dt.timedelta(days=2), 'show_all_days': False } } 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_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, 'type': 'calendar'}, 'work': {'path': os.path.expanduser('~/.calendars/work/'), 'readonly': True, 'color': None, 'type': 'calendar'}}, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, 'locale': { 'local_timezone': get_localzone(), 'default_timezone': get_localzone(), 'timeformat': '%H:%M', 'dateformat': '%d.%m.', 'longdateformat': '%d.%m.%Y', 'datetimeformat': '%d.%m. %H:%M', 'longdatetimeformat': '%d.%m.%Y %H:%M', 'firstweekday': 0, 'unicode_symbols': True, 'weeknumbers': False, }, 'default': { 'default_calendar': None, 'default_command': 'calendar', 'print_new': 'False', 'highlight_event_days': False, 'timedelta': dt.timedelta(days=2), 'show_all_days': False } } 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] default_command: calendar """ 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)) @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/', ] 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 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): path = metavdirs vdirs = {vdir[len(path):] for vdir in get_all_vdirs(path + '/*/*')} assert vdirs == { '/cal1/public', '/cal1/private', '/cal2/public', '/cal3/home', '/cal3/public', '/cal3/work', '/cal4/cfgcolor', '/cal4/dircolor', '/cal4/cfgcolor_again', '/cal4/cfgcolor_once_more' } def test_get_unique_name(metavdirs): path = metavdirs vdirs = [vdir for vdir in get_all_vdirs(path + '/*/*')] names = list() for vdir in sorted(vdirs): names.append(get_unique_name(vdir, names)) assert names == [ 'my private calendar', 'my calendar', 'public', 'home', 'public1', 'work', 'cfgcolor', 'cfgcolor_again', 'cfgcolor_once_more', 'dircolor', ] 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):] assert config == { 'calendars': { 'home': { 'color': None, 'path': '/cal3/home', 'readonly': False, 'type': 'calendar', }, 'my calendar': { 'color': 'dark blue', 'path': '/cal1/public', 'readonly': False, 'type': 'calendar', }, 'my private calendar': { 'color': '#FF00FF', 'path': '/cal1/private', 'readonly': False, 'type': 'calendar', }, 'public': { 'color': None, 'path': '/cal2/public', 'readonly': False, 'type': 'calendar', }, 'public1': { 'color': None, 'path': '/cal3/public', 'readonly': False, 'type': 'calendar', }, 'work': { 'color': None, 'path': '/cal3/work', 'readonly': False, 'type': 'calendar', }, 'cfgcolor': { 'color': 'dark blue', 'path': '/cal4/cfgcolor', 'readonly': False, 'type': 'calendar', }, 'dircolor': { 'color': 'dark blue', 'path': '/cal4/dircolor', 'readonly': False, 'type': 'calendar', }, 'cfgcolor_again': { 'color': 'dark blue', 'path': '/cal4/cfgcolor_again', 'readonly': False, 'type': 'calendar', }, 'cfgcolor_once_more': { 'color': 'dark blue', 'path': '/cal4/cfgcolor_once_more', 'readonly': False, 'type': 'calendar', }, }, 'default': {'default_calendar': None}, 'locale': {'default_timezone': 'Europe/Berlin', 'local_timezone': 'Europe/Berlin'}, 'sqlite': {'path': '/tmp'}, } 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.9.10/tests/event_test.py0000644000076600000240000005016413357150322020547 0ustar christiangeierstaff00000000000000from datetime import datetime, date, timedelta import pytz import pytest from freezegun import freeze_time from icalendar import vRecur, vText from khal.khalendar.event import Event, AllDayEvent, LocalizedEvent, FloatingEvent, \ create_timezone from .utils import normalize_component, _get_text, \ LOCALE_BERLIN, LOCALE_MIXED, LOCALE_BOGOTA, \ BERLIN, NEW_YORK, BOGOTA, GMTPLUS3 EVENT_KWARGS = {'calendar': 'foobar', 'locale': LOCALE_BERLIN} LIST_FORMAT = '{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}' SEARCH_FORMAT = '{calendar-color}{cancelled}{start-long}{to-style}' + \ '{end-necessary-long} {title}{repeat-symbol}' 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(datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(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 event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.recurring is False assert event.duration == timedelta(hours=1) assert event.uid == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU' assert event.organizer == '' 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_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 event.format(SEARCH_FORMAT, 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_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_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 event.format(LIST_FORMAT, date(2014, 4, 9)) == ' An Event\x1b[0m' assert event.format(SEARCH_FORMAT, 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(datetime(2014, 4, 9, 9, 30)) end = BERLIN.localize(datetime(2014, 4, 9, 10, 30)) event.update_start_end(start, end) assert isinstance(event, LocalizedEvent) assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, 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, 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(date(2014, 4, 20), date(2014, 4, 22)) assert event.format(LIST_FORMAT, date(2014, 4, 20)) == '↦ An Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 21)) == '↔ An Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 22)) == '⇥ An Event\x1b[0m' assert event.format(SEARCH_FORMAT, 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(datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) assert event.duration == timedelta(hours=1) event.update_start_end(BERLIN.localize(datetime(2014, 4, 9, 8, 0)), BERLIN.localize(datetime(2014, 4, 9, 12, 0))) assert event.start == BERLIN.localize(datetime(2014, 4, 9, 8, 0)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 12, 0)) assert event.duration == 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(datetime(2014, 4, 9, 9, 30)) assert event.end == NEW_YORK.localize(datetime(2014, 4, 9, 10, 30)) # local (Berlin) time! assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 16, 30)) assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-16:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, 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(datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \ '09.04.2014 09:30-10:30 An Event\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 event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \ '09.04.2014 09:30-10:30 An Event\x1b[0m' assert event.start == datetime(2014, 4, 9, 9, 30) assert event.end == datetime(2014, 4, 9, 10, 30) assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED) assert event.start == datetime(2014, 4, 9, 9, 30) assert event.end == datetime(2014, 4, 9, 10, 30) assert event.start_local == BOGOTA.localize(datetime(2014, 4, 9, 9, 30)) assert event.end_local == BOGOTA.localize(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(datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) event = Event.fromString(event_str, calendar='foobar', locale=LOCALE_MIXED) assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) assert event.start_local == BOGOTA.localize(datetime(2014, 4, 9, 2, 30)) assert event.end_local == BOGOTA.localize(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 event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30-10:30 An Event ⟳\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \ '09.04.2014 09:30-10:30 An Event ⟳\x1b[0m' assert event.format('{repeat-pattern}', 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 event.format(LIST_FORMAT, date(2014, 4, 9)) == ' Another Event ⟳\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 9)) == \ '09.04.2014 Another Event ⟳\x1b[0m' assert event.format('{repeat-pattern}', date(2014, 4, 9)) == 'FREQ=DAILY;COUNT=10\x1b[0m' start = date(2014, 4, 10) end = date(2014, 4, 11) event = Event.fromString(event_d_rr, start=start, end=end, **EVENT_KWARGS) assert event.recurring is True assert event.format(LIST_FORMAT, date(2014, 4, 10)) == ' Another Event ⟳\x1b[0m' assert event.format(SEARCH_FORMAT, 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_event_d_long(): event_d_long = _get_text('event_d_long') event = Event.fromString(event_d_long, **EVENT_KWARGS) assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '↦ Another Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '↔ Another Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 11)) == '⇥ Another Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 12)) == ' Another Event\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 16)) == \ '09.04.2014-11.04.2014 Another Event\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(date(2014, 4, 9), date(2014, 4, 10)) assert event.format(LIST_FORMAT, date(2014, 4, 9)) == '↦ Another Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '⇥ Another Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 12)) == ' Another Event\x1b[0m' assert event.format(SEARCH_FORMAT, 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 event.format(LIST_FORMAT, date(2014, 4, 9)) == '09:30→ An Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 10)) == '↔ An Event\x1b[0m' assert event.format(LIST_FORMAT, date(2014, 4, 12)) == '→10:30 An Event\x1b[0m' assert event.format(SEARCH_FORMAT, date(2014, 4, 10)) == \ '09.04.2014 09:30-12.04.2014 10:30 An Event\x1b[0m' def test_event_no_dst(pytz_version): """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 pytz_version > (2017, 1): cal_no_dst = cal_no_dst.replace( 'TZNAME:COT', 'RDATE:20380118T221407\r\nTZNAME:-05' ) assert normalize_component(event.raw) == normalize_component(cal_no_dst) assert event.format(SEARCH_FORMAT, 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;VALUE=DATE-TIME:20140401T234817Z''', '''UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU''', '''END:VEVENT''', '''END:VCALENDAR\r\n''']) 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 event.format(SEARCH_FORMAT, 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 event.format(SEARCH_FORMAT, 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(datetime(2014, 4, 10, 9, 30)) assert 'EXDATE:20140410T093000' in event.raw.split('\r\n') event.delete_instance(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(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(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(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(datetime(2014, 4, 9, 19, 30)) end = BERLIN.localize(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 event.format(format_, 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): event.format(format_, date(2014, 4, 9)) def test_format_colors(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) format_ = '{red}{title}{reset}' assert event.format(format_, date(2014, 4, 9)) == '\x1b[31mAn Event\x1b[0m\x1b[0m' assert event.format(format_, 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([(timedelta(-1, 82800), 'new event')]) assert event.alarms == [(timedelta(-1, 82800), vText('new event'))] 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;VALUE=DATE-TIME: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(datetime(2014, 4, 9, 9, 30)) end = GMTPLUS3.localize(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;VALUE=DATE-TIME: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;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Etc/GMT+3;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR""" ) khal-0.9.10/tests/vtimezone_test.py0000644000076600000240000000436713357150322021452 0ustar christiangeierstaff00000000000000from datetime import datetime as datetime import pytz from khal.khalendar.event import create_timezone berlin = pytz.timezone('Europe/Berlin') bogota = pytz.timezone('America/Bogota') atime = datetime(2014, 10, 28, 10, 10) btime = datetime(2016, 10, 28, 10, 10) def test_berlin(): vberlin_std = b'\r\n'.join( [b'BEGIN:STANDARD', b'DTSTART;VALUE=DATE-TIME:20141026T020000', b'TZNAME:CET', b'TZOFFSETFROM:+0200', b'TZOFFSETTO:+0100', b'END:STANDARD', ]) vberlin_dst = b'\r\n'.join( [b'BEGIN:DAYLIGHT', b'DTSTART;VALUE=DATE-TIME: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;VALUE=DATE-TIME: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;VALUE=DATE-TIME: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(pytz_version): vbogota = [b'BEGIN:VTIMEZONE', b'TZID:America/Bogota', b'BEGIN:STANDARD', b'DTSTART;VALUE=DATE-TIME:19930403T230000', b'TZNAME:COT', b'TZOFFSETFROM:-0400', b'TZOFFSETTO:-0500', b'END:STANDARD', b'END:VTIMEZONE', b''] if pytz_version > (2017, 1): vbogota[4] = b'TZNAME:-05' vbogota.insert(4, b'RDATE:20380118T221407') assert create_timezone(bogota, atime, atime).to_ical().split(b'\r\n') == vbogota khal-0.9.10/tests/__init__.py0000644000076600000240000000000013243067215020110 0ustar christiangeierstaff00000000000000khal-0.9.10/tests/khalendar_utils_test.py0000644000076600000240000006011713357150322022576 0ustar christiangeierstaff00000000000000from datetime import date, datetime, timedelta import icalendar import pytz from khal.khalendar 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(object): dtstartend_berlin = [ (berlin.localize(datetime(2013, 3, 1, 14, 0, )), berlin.localize(datetime(2013, 3, 1, 16, 0, ))), (berlin.localize(datetime(2013, 5, 1, 14, 0, )), berlin.localize(datetime(2013, 5, 1, 16, 0, ))), (berlin.localize(datetime(2013, 7, 1, 14, 0, )), berlin.localize(datetime(2013, 7, 1, 16, 0, ))), (berlin.localize(datetime(2013, 9, 1, 14, 0, )), berlin.localize(datetime(2013, 9, 1, 16, 0, ))), (berlin.localize(datetime(2013, 11, 1, 14, 0,)), berlin.localize(datetime(2013, 11, 1, 16, 0,))), (berlin.localize(datetime(2014, 1, 1, 14, 0, )), berlin.localize(datetime(2014, 1, 1, 16, 0, ))) ] dtstartend_utc = [ (datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)), (datetime(2013, 5, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 5, 1, 16, 0, tzinfo=pytz.utc)), (datetime(2013, 7, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 7, 1, 16, 0, tzinfo=pytz.utc)), (datetime(2013, 9, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 9, 1, 16, 0, tzinfo=pytz.utc)), (datetime(2013, 11, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 11, 1, 16, 0, tzinfo=pytz.utc)), (datetime(2014, 1, 1, 14, 0, tzinfo=pytz.utc), datetime(2014, 1, 1, 16, 0, tzinfo=pytz.utc)) ] dtstartend_float = [ (datetime(2013, 3, 1, 14, 0), datetime(2013, 3, 1, 16, 0)), (datetime(2013, 5, 1, 14, 0), datetime(2013, 5, 1, 16, 0)), (datetime(2013, 7, 1, 14, 0), datetime(2013, 7, 1, 16, 0)), (datetime(2013, 9, 1, 14, 0), datetime(2013, 9, 1, 16, 0)), (datetime(2013, 11, 1, 14, 0), datetime(2013, 11, 1, 16, 0)), (datetime(2014, 1, 1, 14, 0), datetime(2014, 1, 1, 16, 0)) ] dstartend = [ (date(2013, 3, 1,), date(2013, 3, 2,)), (date(2013, 5, 1,), date(2013, 5, 2,)), (date(2013, 7, 1,), date(2013, 7, 2,)), (date(2013, 9, 1,), date(2013, 9, 2,)), (date(2013, 11, 1), date(2013, 11, 2)), (date(2014, 1, 1,), date(2014, 1, 2,)) ] offset_berlin = [ timedelta(0, 3600), timedelta(0, 7200), timedelta(0, 7200), timedelta(0, 7200), timedelta(0, 3600), timedelta(0, 3600) ] offset_utc = [ timedelta(0, 0), timedelta(0, 0), timedelta(0, 0), timedelta(0, 0), timedelta(0, 0), timedelta(0, 0), ] offset_none = [None, None, None, None, None, None] def test_expand_dt(self): vevent = _get_vevent(event_dt) dtstart = utils.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 = utils.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 = utils.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 = utils.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 = utils.expand(vevent, berlin) assert dtstart == self.dstartend def test_expand_dtz(self): vevent = _get_vevent(event_dtz) dtstart = utils.expand(vevent, berlin) assert dtstart == self.dstartend def test_expand_dtzb(self): vevent = _get_vevent(event_dtzb) dtstart = utils.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 = utils.expand(vevent, berlin) # TODO test for logging message assert dtstartl == [ (new_york.localize(datetime(2011, 11, 12, 15, 50)), new_york.localize(datetime(2011, 11, 12, 17, 0))), (new_york.localize(datetime(2011, 11, 19, 15, 50)), new_york.localize(datetime(2011, 11, 19, 17, 0))), (new_york.localize(datetime(2011, 12, 3, 15, 50)), new_york.localize(datetime(2011, 12, 3, 17, 0))), ] class TestExpandNoRR(object): dtstartend_berlin = [ (berlin.localize(datetime(2013, 3, 1, 14, 0)), berlin.localize(datetime(2013, 3, 1, 16, 0))), ] dtstartend_utc = [ (datetime(2013, 3, 1, 14, 0, tzinfo=pytz.utc), datetime(2013, 3, 1, 16, 0, tzinfo=pytz.utc)), ] dtstartend_float = [ (datetime(2013, 3, 1, 14, 0), datetime(2013, 3, 1, 16, 0)), ] offset_berlin = [ timedelta(0, 3600), ] offset_utc = [ timedelta(0, 0), ] offset_none = [None] def test_expand_dt(self): vevent = _get_vevent(event_dt_norr) dtstart = utils.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 = utils.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 = utils.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 = utils.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 = utils.expand(vevent, berlin) assert dtstart == [ (date(2013, 3, 1,), 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 = utils.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 = utils.sanitize(vevent, berlin, '', '') dtstart = utils.expand(vevent, berlin) assert len(dtstart) == 5 dtstarts = [start for start, end in dtstart] assert dtstarts == [ berlin.localize(datetime(2012, 4, 3, 10, 0)), berlin.localize(datetime(2012, 5, 3, 10, 0)), berlin.localize(datetime(2012, 7, 3, 10, 0)), berlin.localize(datetime(2012, 8, 3, 10, 0)), berlin.localize(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 = utils.sanitize(vevent, new_york, '', '') dtstart = utils.expand(vevent, new_york) assert len(dtstart) == 7 dtstarts = [start for start, end in dtstart] assert dtstarts == [ new_york.localize(datetime(2012, 7, 26, 13, 0)), new_york.localize(datetime(2012, 8, 9, 13, 0)), new_york.localize(datetime(2012, 8, 23, 13, 0)), new_york.localize(datetime(2012, 9, 6, 13, 0)), new_york.localize(datetime(2012, 9, 20, 13, 0)), new_york.localize(datetime(2012, 10, 4, 13, 0)), new_york.localize(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(object): """collection of strange test cases that don't fit anywhere else really""" def test_count(self): vevent = _get_vevent(vevent_count) dtstart = utils.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 18 assert dtstart[0][0] == datetime(2014, 2, 3, 7, 0) assert dtstart[-1][0] == datetime(2014, 2, 20, 7, 0) def test_until_notz(self): vevent = _get_vevent(vevent_until_notz) dtstart = utils.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 18 assert dtstart[0][0] == berlin.localize( datetime(2014, 2, 3, 7, 0)) assert dtstart[-1][0] == berlin.localize( datetime(2014, 2, 20, 7, 0)) def test_until_d_notz(self): vevent = _get_vevent(event_until_d_notz) dtstart = utils.expand(vevent, berlin) starts = [start for start, _ in dtstart] assert len(starts) == 6 assert dtstart[0][0] == date(2014, 1, 10) assert dtstart[-1][0] == date(2014, 2, 14) def test_latest_bug(self): vevent = _get_vevent(latest_bug) dtstart = utils.expand(vevent, berlin) assert dtstart[0][0] == date(2009, 10, 31) assert dtstart[-1][0] == date(2037, 10, 31) def test_recurrence_id_with_timezone(self): vevent = _get_vevent(recurrence_id_with_timezone) dtstart = utils.expand(vevent, berlin) assert len(dtstart) == 1 assert dtstart[0][0] == berlin.localize( 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 = utils.expand(vevent, berlin) assert len(dtstart) == 9 assert dtstart[0][0] == berlin.localize( datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( 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 = utils.expand(vevent, berlin) assert len(dtstart) == 8 assert dtstart[0][0] == berlin.localize( datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( 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 = utils.expand(vevent, berlin) assert len(dtstart) == 7 assert dtstart[0][0] == berlin.localize( datetime(2014, 7, 2, 19, 0)) assert dtstart[-1][0] == berlin.localize( 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 = utils.expand(vevent, berlin) assert len(dtstart) == 7 exdate1 = pytz.UTC.localize(datetime(2014, 7, 11, 17, 0)) utils.delete_instance(vevent, exdate1) dtstart = utils.expand(vevent, berlin) assert len(dtstart) == 6 exdate2 = berlin.localize(datetime(2014, 7, 9, 19, 0)) utils.delete_instance(vevent, exdate2) dtstart = utils.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 = utils.expand(vevent, berlin) assert dtstart == [(date(2007, 12, 1), date(2007, 12, 2)), (date(2008, 1, 1), date(2008, 1, 2)), (date(2008, 2, 1), 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 = utils.expand(vevent, berlin) assert len(dtstart) == 35 assert dtstart[0] == (berlin.localize(datetime(2014, 4, 9, 9, 30)), berlin.localize(datetime(2014, 4, 9, 10, 30))) assert dtstart[-1] == (berlin.localize(datetime(2014, 12, 3, 9, 30)), berlin.localize(datetime(2014, 12, 3, 10, 30))) 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(object): """Testing expanding of recurrence rules""" def test_simple_rdate(self): vevent = _get_vevent(simple_rdate) dtstart = utils.expand(vevent, berlin) assert len(dtstart) == 4 def test_rrule_and_rdate(self): vevent = _get_vevent(rrule_and_rdate) dtstart = utils.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 = utils.expand(vevent, berlin) assert len(dtstarts) == 73 assert dtstarts[0][0] == date(1965, 4, 23) assert dtstarts[-1][0] == date(2037, 4, 23) def test_rdate_date(self): vevent = _get_vevent_file('event_d_rdate') dtstarts = utils.expand(vevent, berlin) assert len(dtstarts) == 4 assert dtstarts == [(date(2015, 8, 12), date(2015, 8, 13)), (date(2015, 8, 13), date(2015, 8, 14)), (date(2015, 8, 14), date(2015, 8, 15)), (date(2015, 8, 15), 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(object): def test_noend_date(self): vevent = _get_vevent(noend_date) vevent = utils.sanitize(vevent, berlin, '', '') assert vevent['DTSTART'].dt == date(2014, 8, 29) assert vevent['DTEND'].dt == date(2014, 8, 30) def test_noend_datetime(self): vevent = _get_vevent(noend_datetime) vevent = utils.sanitize(vevent, berlin, '', '') assert vevent['DTSTART'].dt == date(2014, 8, 29) assert vevent['DTEND'].dt == date(2014, 8, 30) def test_duration(self): vevent = _get_vevent_file('event_dtr_exdatez') vevent = utils.sanitize(vevent, berlin, '', '') def test_instant(self): vevent = _get_vevent(instant) assert vevent['DTEND'].dt - vevent['DTSTART'].dt == timedelta() vevent = utils.sanitize(vevent, berlin, '', '') assert vevent['DTEND'].dt - vevent['DTSTART'].dt == timedelta(hours=1) class TestIsAware(): def test_naive(self): assert utils.is_aware(datetime.now()) is False def test_berlin(self): assert utils.is_aware(BERLIN.localize(datetime.now())) is True def test_bogota(self): assert utils.is_aware(BOGOTA.localize(datetime.now())) is True def test_utc(self): assert utils.is_aware(pytz.UTC.localize(datetime.now())) is True khal-0.9.10/tests/utils.py0000644000076600000240000000614113357150322017523 0ustar christiangeierstaff00000000000000import icalendar import os import pytz cal0 = 'a_calendar' cal1 = 'foobar' cal2 = 'work' 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 = { '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 = { '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 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 khal-0.9.10/tests/configs/0000755000076600000240000000000013357150672017447 5ustar christiangeierstaff00000000000000khal-0.9.10/tests/configs/small.conf0000644000076600000240000000021313243067215021414 0ustar christiangeierstaff00000000000000[calendars] [[home]] path = ~/.calendars/home/ color = dark green [[work]] path = ~/.calendars/work/ readonly = True khal-0.9.10/tests/configs/simple.conf0000644000076600000240000000043113243067215021577 0ustar christiangeierstaff00000000000000[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.9.10/tests/configs/nocalendars.conf0000644000076600000240000000030513243067215022577 0ustar christiangeierstaff00000000000000[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.9.10/tests/cal_display_test.py0000644000076600000240000002723513357150322021715 0ustar christiangeierstaff00000000000000import datetime import locale import platform import unicodedata import pytest from khal.calendar_display import vertical_month, getweeknumber, str_week today = datetime.date.today() yesterday = today - datetime.timedelta(days=1) tomorrow = today + datetime.timedelta(days=1) def test_getweeknumber(): assert getweeknumber(datetime.date(2011, 12, 12)) == 50 assert getweeknumber(datetime.date(2011, 12, 31)) == 52 assert getweeknumber(datetime.date(2012, 1, 1)) == 52 assert getweeknumber(datetime.date(2012, 1, 2)) == 1 def test_str_week(): aday = datetime.date(2012, 6, 1) bday = datetime.date(2012, 6, 8) week = [datetime.date(2012, 6, 6), datetime.date(2012, 6, 7), datetime.date(2012, 6, 8), datetime.date(2012, 6, 9), datetime.date(2012, 6, 10), datetime.date(2012, 6, 11), datetime.date(2012, 6, 12), datetime.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 ' 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 '] 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_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 '] def test_vertical_month(): try: locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') vert_str = vertical_month(month=12, year=2011, today=datetime.date(2011, 12, 12)) assert vert_str == example1 weno_str = vertical_month(month=12, year=2011, today=datetime.date(2011, 12, 12), weeknumber='right') assert weno_str == example_weno we_start_su_str = vertical_month( month=12, year=2011, today=datetime.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=datetime.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=datetime.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=datetime.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 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=datetime.date(2011, 12, 12)) 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.9.10/tests/backend_test.py0000644000076600000240000007536013357150322021022 0ustar christiangeierstaff00000000000000 import pytest import pkg_resources from datetime import date, datetime, timedelta, time import icalendar from khal.khalendar import backend from khal.khalendar.event import LocalizedEvent, EventStandIn from khal.khalendar.exceptions import OutdatedDbVersionError, UpdateFailed from .utils import _get_text, \ BERLIN, LONDON, SYDNEY, \ LOCALE_BERLIN, LOCALE_SYDNEY 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) == list() events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0))) assert list(events) == list() 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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0))) events = sorted(events, key=lambda x: x.start) assert len(events) == 6 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0)) assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0)) assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 7, 0)) events = dbi.get_localized( BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0)), minimal=True, ) events = list(events) assert len(events) == 6 for event in events: assert isinstance(event, EventStandIn) def test_event_different_timezones(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) dbi.update(_get_text('event_dt_london'), href='12345.ics', etag='abcd', calendar=calname) events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 9, 0, 0)), BERLIN.localize(datetime(2014, 4, 9, 23, 59))) events = list(events) assert len(events) == 1 event = events[0] assert event.start_local == LONDON.localize(datetime(2014, 4, 9, 14)) assert event.end_local == LONDON.localize(datetime(2014, 4, 9, 19)) assert event.start == LONDON.localize(datetime(2014, 4, 9, 14)) assert event.end == LONDON.localize(datetime(2014, 4, 9, 19)) # no event scheduled on the next day events = dbi.get_localized(BERLIN.localize(datetime(2014, 4, 10, 0, 0)), BERLIN.localize(datetime(2014, 4, 10, 23, 59))) events = list(events) assert len(events) == 0 # now setting the local_timezone to Sydney dbi.locale = LOCALE_SYDNEY events = dbi.get_localized(SYDNEY.localize(datetime(2014, 4, 9, 0, 0)), SYDNEY.localize(datetime(2014, 4, 9, 23, 59))) events = list(events) assert len(events) == 1 event = events[0] assert event.start_local == SYDNEY.localize(datetime(2014, 4, 9, 23)) assert event.end_local == SYDNEY.localize(datetime(2014, 4, 10, 4)) assert event.start == LONDON.localize(datetime(2014, 4, 9, 14)) assert event.end == LONDON.localize(datetime(2014, 4, 9, 19)) # the event spans midnight Sydney, therefor it should also show up on the # next day events = dbi.get_localized(SYDNEY.localize(datetime(2014, 4, 10, 0, 0)), SYDNEY.localize(datetime(2014, 4, 10, 23, 59))) events = list(events) assert len(events) == 1 assert event.start_local == SYDNEY.localize(datetime(2014, 4, 9, 23)) assert event.end_local == SYDNEY.localize(datetime(2014, 4, 10, 4)) 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(datetime(2014, 4, 30, 0, 0)), BERLIN.localize(datetime(2014, 9, 26, 0, 0))) events = sorted(events) assert len(events) == 6 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0)) assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0)) assert events[5].start == BERLIN.localize(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) == list() events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0))) assert list(events) == list() 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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0))) events = sorted(events, key=lambda x: x.start) assert len(events) == 6 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 7, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0)) assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0)) assert events[5].start == BERLIN.localize(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(datetime(2014, 4, 30, 0, 0)), BERLIN.localize(datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=lambda x: x.start) assert len(events) == 5 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 7, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 21, 7, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 28, 7, 0)) assert events[4].start == BERLIN.localize(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( datetime(2017, 3, 1, 0, 0), datetime(2017, 4, 1, 0, 0), ) events = sorted(events, key=lambda x: x.start) assert len(events) == 1 assert events[0].start == datetime(2017, 3, 29, 16) assert events[0].end == datetime(2017, 3, 29, 16, 25) assert events[0].format( '{title}', relative_to=date(2017, 3, 29) ) == 'Infrastructure Planning\x1b[0m' 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( datetime(2010, 1, 1, 0, 0), datetime(2020, 1, 1, 0, 0), ) events = sorted(events, key=lambda x: x.start) assert len(list(events)) == 2 assert events[0].start == datetime(2014, 6, 30, 7, 30) assert events[0].end == datetime(2014, 6, 30, 12, 0) assert events[1].start == datetime(2014, 7, 7, 8, 30) assert events[1].end == datetime(2014, 7, 7, 12, 0) events = dbi.search('VEVENT') events = sorted(events, key=lambda x: x.start) 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(datetime(2014, 4, 9, 0, 0)), BERLIN.localize(datetime(2014, 4, 10, 0, 0))) events = sorted(list(events)) assert len(events) == 1 event = events[0] assert event.start == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 9, 30)) assert event.end_local == BERLIN.localize(datetime(2014, 4, 9, 10, 30)) def test_event_delete(): dbi = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert dbi.list(calname) == list() events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 8, 26, 0, 0))) assert list(events) == list() 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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 9, 26, 0, 0))) assert len(list(events)) == 6 dbi.delete('12345.ics', calendar=calname) events = dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(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(datetime(2014, 4, 30, 0, 0)), BERLIN.localize(datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=lambda x: x.start) assert len(events) == 6 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 7, 9, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 14, 9, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 21, 9, 0)) assert events[4].start == BERLIN.localize(datetime(2014, 7, 28, 9, 0)) assert events[5].start == BERLIN.localize(datetime(2014, 8, 4, 9, 0)) assert events[0].end == BERLIN.localize(datetime(2014, 6, 30, 12, 0)) assert events[1].end == BERLIN.localize(datetime(2014, 7, 7, 18, 0)) assert events[2].end == BERLIN.localize(datetime(2014, 7, 14, 18, 0)) assert events[3].end == BERLIN.localize(datetime(2014, 7, 21, 18, 0)) assert events[4].end == BERLIN.localize(datetime(2014, 7, 28, 18, 0)) assert events[5].end == BERLIN.localize(datetime(2014, 8, 4, 18, 0)) assert str(events[0].summary) == 'Arbeit' for num, event in enumerate(events[1:]): assert event.raw # just making sure we don't raise any exception assert str(event.summary) == 'Arbeit (lang)' 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(datetime(2014, 4, 30, 0, 0)), BERLIN.localize(datetime(2014, 9, 26, 0, 0))) events = sorted(events, key=lambda x: x.start) assert len(events) == 6 assert events[0].start == BERLIN.localize(datetime(2014, 6, 30, 7, 0)) assert events[1].start == BERLIN.localize(datetime(2014, 7, 8, 9, 0)) assert events[2].start == BERLIN.localize(datetime(2014, 7, 15, 9, 0)) assert events[3].start == BERLIN.localize(datetime(2014, 7, 22, 9, 0)) assert events[4].start == BERLIN.localize(datetime(2014, 7, 29, 9, 0)) assert events[5].start == BERLIN.localize(datetime(2014, 8, 5, 9, 0)) assert events[0].end == BERLIN.localize(datetime(2014, 6, 30, 12, 0)) assert events[1].end == BERLIN.localize(datetime(2014, 7, 9, 15, 0)) assert events[2].end == BERLIN.localize(datetime(2014, 7, 16, 15, 0)) assert events[3].end == BERLIN.localize(datetime(2014, 7, 23, 15, 0)) assert events[4].end == BERLIN.localize(datetime(2014, 7, 30, 15, 0)) assert events[5].end == BERLIN.localize(datetime(2014, 8, 6, 15, 0)) assert str(events[0].summary) == 'Arbeit' for event in events[1:]: assert str(event.summary) == 'Arbeit (lang)' 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(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0))) assert len(events) == 6 assert events[0].start == date(2014, 6, 30) assert events[1].start == date(2014, 7, 8) assert events[2].start == date(2014, 7, 15) assert events[3].start == date(2014, 7, 22) assert events[4].start == date(2014, 7, 29) assert events[5].start == date(2014, 8, 5) assert events[0].end == date(2014, 6, 30) assert events[1].end == date(2014, 7, 8) assert events[2].end == date(2014, 7, 15) assert events[3].end == date(2014, 7, 22) assert events[4].end == date(2014, 7, 29) assert events[5].end == date(2014, 8, 5) assert str(events[0].summary) == 'Arbeit' for event in events[1:]: assert str(event.summary) == 'Arbeit (lang)' events = list(dbi.get_floating( datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0), minimal=True, )) assert len(events) == 6 for event in events: assert isinstance(event, EventStandIn) 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(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0))) assert len(events) == 6 assert events[0].start == date(2014, 6, 30) assert events[1].start == date(2014, 7, 5) assert events[2].start == date(2014, 7, 12) assert events[3].start == date(2014, 7, 19) assert events[4].start == date(2014, 7, 26) assert events[5].start == date(2014, 8, 2) assert events[0].end == date(2014, 6, 30) assert events[1].end == date(2014, 7, 5) assert events[2].end == date(2014, 7, 12) assert events[3].end == date(2014, 7, 19) assert events[4].end == date(2014, 7, 26) assert events[5].end == date(2014, 8, 2) assert str(events[0].summary) == 'Arbeit' for event in events[1:]: assert str(event.summary) == 'Arbeit (lang)' 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(datetime(2014, 4, 30, 0, 0), datetime(2014, 9, 27, 0, 0))) assert len(events) == 6 assert events[0].start == date(2014, 6, 30) assert events[1].start == date(2014, 7, 12) assert events[2].start == date(2014, 7, 17) assert events[3].start == date(2014, 7, 19) assert events[4].start == date(2014, 7, 24) assert events[5].start == date(2014, 7, 31) assert events[0].end == date(2014, 6, 30) assert events[1].end == date(2014, 7, 13) assert events[2].end == date(2014, 7, 17) assert events[3].end == date(2014, 7, 20) assert events[4].end == date(2014, 7, 24) assert events[5].end == date(2014, 7, 31) assert str(events[0].summary) == 'Arbeit' for event in [events[1], events[3]]: assert str(event.summary) == 'Arbeit (lang)' for event in [events[2], events[4], events[5]]: assert str(event.summary) == 'Arbeit (neu)' 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 (timedelta(hours=2), timedelta(hours=5)) == \ backend.calc_shift_deltas(recuid_this_future) assert (timedelta(hours=2), 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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 7, 26, 0, 0)))) dbi.calendars = [work] events_b = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 7, 26, 0, 0)))) assert len(events_c) == 8 assert [event.calendar for event in events_c].count(home) == 4 assert [event.calendar 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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 7, 26, 0, 0)))) dbi.calendars = [work] events_b = list(dbi.get_localized(BERLIN.localize(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(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(datetime(2014, 6, 30, 0, 0)), BERLIN.localize(datetime(2014, 7, 26, 0, 0)))) assert [event.calendar for event in events_c].count('home') == 0 assert [event.calendar 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(datetime(2015, 4, 9, 0, 0), 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(datetime(2015, 4, 9, 0, 0), datetime(2015, 4, 10, 0, 0))) assert len(events) == 1 def test_zulu_events(): """test if events in Zulu time are correctly recognized as locaized events""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update(_get_text('event_dt_simple_zulu'), href='event_zulu', calendar=calname) events = db.get_localized(BERLIN.localize(datetime(2014, 4, 9, 0, 0)), BERLIN.localize(datetime(2014, 4, 10, 0, 0))) events = list(events) assert len(events) == 1 event = events[0] assert type(event) == LocalizedEvent assert event.start_local == BERLIN.localize(datetime(2014, 4, 9, 11, 30)) 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_floating(datetime(2016, 1, 16, 0, 0), datetime(2016, 1, 17, 0, 0)) event = list(events)[0] assert event.start == date(2016, 1, 16) assert event.end == date(2016, 1, 16) 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 """ day = date(1971, 3, 11) start = datetime.combine(day, time.min) end = datetime.combine(day, time.max) def test_birthdays(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == list() db.update_birthday(card, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert events[0].summary == 'Unix\'s 0th birthday' events = list(db.get_floating(datetime(2016, 3, 11, 0, 0), datetime(2016, 3, 11, 23, 59, 59, 999))) assert events[0].summary == 'Unix\'s 45th birthday' def test_birthdays_update(): """test if we can update a birthday""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update_birthday(card, 'unix.vcf', calendar=calname) db.update_birthday(card, 'unix.vcf', calendar=calname) def test_birthdays_29feb(): """test how we deal with birthdays on 29th of feb in leap years""" db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) db.update_birthday(card_29thfeb, 'leap.vcf', calendar=calname) events = list( db.get_floating(datetime(2004, 1, 1, 0, 0), datetime(2004, 12, 31)) ) assert len(events) == 1 assert events[0].summary == 'leapyear\'s 4th birthday (29th of Feb.)' assert events[0].start == date(2004, 2, 29) events = list( db.get_floating(datetime(2005, 1, 1, 0, 0), datetime(2005, 12, 31)) ) assert len(events) == 1 assert events[0].summary == 'leapyear\'s 5th birthday (29th of Feb.)' assert events[0].start == date(2005, 3, 1) def test_birthdays_no_year(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == list() db.update_birthday(card_no_year, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 1 assert events[0].summary == 'Unix\'s birthday' def test_birthdays_no_fn(): db = backend.SQLiteDb(['home'], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(datetime(1941, 9, 9, 0, 0), datetime(1941, 9, 9, 23, 59, 59, 9999))) == list() db.update_birthday(card_no_fn, 'unix.vcf', calendar=calname) events = list(db.get_floating(datetime(1941, 9, 9, 0, 0), datetime(1941, 9, 9, 23, 59, 59, 9999))) assert len(events) == 1 assert events[0].summary == 'Dennis MacAlistair Ritchie\'s 0th birthday' def test_birthday_does_not_parse(): db = backend.SQLiteDb([calname], ':memory:', locale=LOCALE_BERLIN) assert list(db.get_floating(start, end)) == list() db.update_birthday(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)) == list() db.update_birthday(card_two_birthdays, 'unix.vcf', calendar=calname) events = list(db.get_floating(start, end)) assert len(events) == 0 khal-0.9.10/tests/configwizard_test.py0000644000076600000240000000044013357150322022104 0ustar christiangeierstaff00000000000000import click import pytest from khal.configwizard import 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) khal-0.9.10/tests/cli_test.py0000644000076600000240000006677413357150322020213 0ustar christiangeierstaff00000000000000import datetime import os import sys from unittest import mock from datetime import timedelta import pytest from freezegun import freeze_time from click.testing import CliRunner from khal.cli import main_khal, main_ikhal from .utils import _get_text, _get_ics_filepath class CustomCliRunner(CliRunner): def __init__(self, config_file, db=None, calendars=None, xdg_data_home=None, xdg_config_home=None, tmpdir=None, **kwargs): 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(CustomCliRunner, self).__init__(**kwargs) def invoke(self, cli, args=None, *a, **kw): args = ['-c', str(self.config_file)] + (args or []) return super(CustomCliRunner, self).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(default_command='list', 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( default_command=default_command, 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=dict(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_command = {default_command} {default_calendar} timedelta = {delta} print_new = {print_new} [sqlite] path = {dbpath} ''' def test_direct_modification(runner): runner = runner(default_command='list') result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == 'No events\n' 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 == 'No events\n' def test_simple(runner): runner = runner(default_command='list', days=2) result = runner.invoke(main_khal) assert not result.exception assert result.output == 'No events\n' now = datetime.datetime.now().strftime('%d.%m.%Y') result = runner.invoke( main_khal, 'new {} 18:00 myevent'.format(now).split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal) 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(default_command='list', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') result = runner.invoke(main_khal, 'new {} 18:00 myevent'.format(now).split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal, color=True) assert not result.exception assert '\x1b[34m' in result.output def test_days(runner): runner = runner(default_command='list', days=9) when = (datetime.datetime.now() + timedelta(days=7)).strftime('%d.%m.%Y') result = runner.invoke(main_khal, 'new {} 18:00 nextweek'.format(when).split()) assert result.output == '' assert not result.exception when = (datetime.datetime.now() + timedelta(days=30)).strftime('%d.%m.%Y') result = runner.invoke(main_khal, 'new {} 18:00 nextmonth'.format(when).split()) assert result.output == '' assert not result.exception result = runner.invoke(main_khal) 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(default_command='calendar', 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(default_command='calendar', days=0) result = runner.invoke(main_khal) 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(default_command='calendar', days=100) result = runner.invoke(main_khal) 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(default_command='', days=2) result = runner.invoke(main_khal) assert result.exception assert result.exit_code == 1 assert result.output.startswith('Usage: ') def test_default_command_nonempty(runner): runner = runner(default_command='list', days=2) result = runner.invoke(main_khal) assert not result.exception assert result.output == 'No events\n' def test_invalid_calendar(runner): runner = runner(default_command='', 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(default_command='calendar', days=2) result = runner.invoke(main_khal, ['printcalendars']) assert set(result.output.split('\n')[:3]) == set(['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]) == set(['two', 'three']) assert not result.exception @pytest.mark.parametrize('contents', [ '', 'BEGIN:VCALENDAR\nBEGIN:VTODO\nEND:VTODO\nEND:VCALENDAR\n' ]) def test_no_vevent(runner, tmpdir, contents): runner = runner(default_command='list', 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) assert not result.exception assert 'No events' in result.output def test_printformats(runner): runner = runner(default_command='printformats', days=2) result = runner.invoke(main_khal) assert '\n'.join(['longdatetimeformat: 21.12.2013 10:09', 'datetimeformat: 21.12. 10:09', 'longdateformat: 21.12.2013', 'dateformat: 21.12.', 'timeformat: 10:09', '']) == result.output assert not result.exception # "see #810" @pytest.mark.xfail def test_repeating(runner): runner = runner(default_command='list', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') end_date = datetime.datetime.now() + datetime.timedelta(days=10) result = runner.invoke( main_khal, 'new {} 18:00 myevent -r weekly -u {}'.format( now, end_date.strftime('%d.%m.%Y')).split()) assert not result.exception assert result.output == '' def test_at(runner): runner = runner(default_command='calendar', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') end_date = datetime.datetime.now() + datetime.timedelta(days=10) result = runner.invoke( main_khal, 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).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_day_format(runner): runner = runner(default_command='calendar', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') end_date = datetime.datetime.now() + datetime.timedelta(days=10) result = runner.invoke( main_khal, 'new {} {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).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(default_command='calendar', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') end_date = datetime.datetime.now() + datetime.timedelta(days=10) result = runner.invoke( main_khal, 'new {} 18:00 myevent'.format(now, end_date.strftime('%d.%m.%Y')).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_search(runner): runner = runner(default_command='calendar', days=2) now = datetime.datetime.now().strftime('%d.%m.%Y') result = runner.invoke(main_khal, 'new {} 18:00 myevent'.format(now).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_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_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, 'import -a one {}'.format(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, 'import {}'.format(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_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): ics_data = 'This is some really fake icalendar data' with mock.patch('khal.controllers.import_ics') as mocked_import: runner = runner() result = runner.invoke(main_khal, ['import'], input=ics_data) assert not result.exception assert mocked_import.call_count == 1 assert mocked_import.call_args[1]['ics'] == ics_data def test_interactive_command(runner, monkeypatch): runner = runner(default_command='list', 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(default_command='list', days=2) result = runner.invoke(main_khal, ['--no-color']) assert result.output == 'No events\n' result = runner.invoke(main_khal, ['--color']) assert 'No events' in result.output assert result.output != 'No events\n' def choices(dateformat=0, timeformat=0, parse_vdirsyncer_conf=True, create_vdir=False, write_config=True): """helper function to generate input for testing `configure`""" confirm = {True: 'y', False: 'n'} out = [ str(dateformat), str(timeformat), confirm[parse_vdirsyncer_conf], ] if not parse_vdirsyncer_conf: out.append(confirm[create_vdir]) out.append(confirm[write_config]) 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 'Successfully wrote configuration to {}'.format(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 == '''[calendars] [[events_local]] path = ~/.local/share/calendars/events/* type = discover [[home_calendar_local]] path = ~/.local/share/calendars/home/* type = discover [[home_contacts_local]] path = ~/.local/share/contacts/* type = discover [locale] timeformat = %H:%M dateformat = %Y-%m-%d longdateformat = %Y-%m-%d datetimeformat = %Y-%m-%d %H:%M longdatetimeformat = %Y-%m-%d %H:%M ''' # 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(command='printics', days=2) # Input is empty and loading from stdin result = runner.invoke(main_khal, ['-']) assert result.exception # Non existing file result = runner.invoke(main_khal, ['printics', 'nonexisting_file']) assert result.exception assert ('Error: Invalid value for "ics": Could not open file: ' \ in result.output or \ 'Error: Invalid value for "[ICS]": Could not open file:' \ in 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 = '{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 24 == 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 24 == 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\n 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(parse_vdirsyncer_conf=False, create_vdir=True), ) assert 'Successfully wrote configuration to {}'.format(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 == '''[calendars] [[private]] path = {}/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 '''.format(runner.xdg_data_home) # 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(parse_vdirsyncer_conf=False, create_vdir=True), ) assert 'Successfully wrote configuration to {}'.format(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 '{}/khal/calendars/private1' .format(runner.xdg_data_home) in actual_config 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 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(parse_vdirsyncer_conf=False, create_vdir=True), ) assert 'Exiting' in result.output assert result.exit_code == 1 def test_configure_no_vdir(runner): runner = runner() runner.config_file.remove() result = runner.invoke( main_khal, ['configure'], input=choices(parse_vdirsyncer_conf=False, create_vdir=False), ) assert 'khal will not be usable like this' in result.output assert result.exit_code == 0 assert not result.exception def test_edit(runner): runner = runner() result = runner.invoke(main_khal, ['list']) assert not result.exception assert result.output == 'No events\n' for name in ['event_dt_simple', 'event_d_15']: cal_dt = _get_text(name) event = runner.calendars['one'].join('{}.ics'.format(name)) 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)) @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 @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 khal-0.9.10/tests/ics/0000755000076600000240000000000013357150673016576 5ustar christiangeierstaff00000000000000khal-0.9.10/tests/ics/part1.ics0000644000076600000240000000406513243067215020323 0ustar christiangeierstaff00000000000000BEGIN: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;VALUE=DATE-TIME:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RDATE:20151025T020000 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_New_York BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:20141102T010000 RDATE:20151101T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME: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;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America_New_York;VALUE=DATE-TIME:20140409T103000 RDATE;TZID=IndianReunion:20140418T113000 RDATE;TZID=America_Bogota:20140411T113000,20140413T113000 RDATE;TZID=America_Bogota:20140415T113000 RRULE:FREQ=MONTHLY;COUNT=6 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:abcde END:VEVENT BEGIN:VEVENT SUMMARY:An Updated Event DTSTART;TZID=Europe_Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America_New_York;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:abcde RECURRENCE-ID;TZID=Europe_Amsterdam:20140707T070000 END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_multi_recuid_no_master.ics0000644000076600000240000000065513243067215025721 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/mult_uids_and_recuid_no_order.ics0000644000076600000240000000110213243067215025332 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/part0.ics0000644000076600000240000000116413243067215020317 0ustar christiangeierstaff00000000000000BEGIN: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;VALUE=DATE-TIME:20140509T193000 DTEND;TZID=Europe_London;VALUE=DATE-TIME:20140509T203000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:123 END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_simple_updated.ics0000644000076600000240000000064413243067215024162 0ustar christiangeierstaff00000000000000BEGIN: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;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_simple_zulu.ics0000644000076600000240000000041313243067215023525 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART:20140409T093000Z DTEND:20140409T103000Z DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_floating.ics0000644000076600000240000000032113243067215022756 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:An Event DESCRIPTION:Search for me DTSTART;VALUE=DATE-TIME:20140409T093000 DTEND;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:floating1234567890 END:VEVENT khal-0.9.10/tests/ics/event_d_same_start_end.ics0000644000076600000240000000026613243067215023767 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140409 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_dt_london.ics0000644000076600000240000000051713243067215022453 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/London;VALUE=DATE-TIME:20140409T140000 DTEND;TZID=Europe/London;VALUE=DATE-TIME:20140409T190000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_d_long.ics0000644000076600000240000000026613243067215021736 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140412 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/cal_d.ics0000644000076600000240000000042113243067215020326 0ustar christiangeierstaff00000000000000BEGIN: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 UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_d.ics0000644000076600000240000000026113243067215020712 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20140409 DTEND;VALUE=DATE:20140410 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_dt_simple_inkl_vtimezone.ics0000644000076600000240000000124313243067215025565 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD RDATE:20151025T020000 DTSTART;VALUE=DATE-TIME:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/cal_dt_two_tz.ics0000644000076600000240000000177413243067215022134 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:Europe/Berlin BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RDATE:20151025T020000 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America/New_York BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:20141102T010000 RDATE:20151101T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20140309T030000 RDATE:20150308T030000,20160313T030000 TZNAME:EDT TZOFFSETFROM:-0500 TZOFFSETTO:-0400 END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America/New_York;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_two_rd.ics0000644000076600000240000000044513243067215022460 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_recuid_no_master.ics0000644000076600000240000000032313243067215024477 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_duration.ics0000644000076600000240000000036613243067215023011 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DURATION:PT1H0M0S DTSTAMP;VALUE=DATE-TIME:20140401T234817Z ORGANIZER;CN=Frank Nord:mailto:frank@nord.tld UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_dt_local_missing_tz.ics0000644000076600000240000000047313243067215024523 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_invalid_exdate.ics0000644000076600000240000000047113243067215023452 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_rrule_recuid.ics0000644000076600000240000000073213243067215023156 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dtr_notz_untilz.ics0000644000076600000240000000045013243067215023737 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/cal_lots_of_timezones.ics0000644000076600000240000000536713243067215023663 0ustar christiangeierstaff00000000000000BEGIN: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;VALUE=DATE-TIME:20141026T020000 TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 RDATE:20151025T020000 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME:20140330T030000 RDATE:20150329T030000,20160327T030000 TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 END:DAYLIGHT END:VTIMEZONE BEGIN:VTIMEZONE TZID:America_New_York BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:20141102T010000 RDATE:20151101T010000 TZNAME:EST TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD BEGIN:DAYLIGHT DTSTART;VALUE=DATE-TIME: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;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America_New_York;VALUE=DATE-TIME:20140409T103000 RDATE;TZID=America_Bogota:20140411T113000,20140413T113000 RDATE;TZID=America_Bogota:20140415T113000 RDATE;TZID=IndianReunion:20140418T113000 RRULE:FREQ=MONTHLY;COUNT=6 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:abcde END:VEVENT BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe_London;VALUE=DATE-TIME:20140509T193000 DTEND;TZID=Europe_London;VALUE=DATE-TIME:20140509T203000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:123 END:VEVENT BEGIN:VEVENT SUMMARY:An Updated Event DTSTART;TZID=Europe_Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America_New_York;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:abcde RECURRENCE-ID;TZID=Europe_Amsterdam:20140707T070000 END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_simple.ics0000644000076600000240000000051713243067215022453 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_d_15.ics0000644000076600000240000000026113243067215021217 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;VALUE=DATE:20150409 DTEND;VALUE=DATE:20150410 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_d_rr.ics0000644000076600000240000000032013243067215021411 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_d_no_value.ics0000644000076600000240000000016713243067215022607 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:Another Event DTSTART:20140409 DTEND:20140410 UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_dt_rr.ics0000644000076600000240000000034313243067215021602 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_no_end.ics0000644000076600000240000000015113243067215022416 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR BEGIN:VEVENT SUMMARY:Test DTSTART:20160116T070000Z UID:nodtend END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_dt_rd.ics0000644000076600000240000000035713243067215021571 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dtr_no_tz_exdatez.ics0000644000076600000240000000052113243067215024214 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_rrule_recuid_cancelled.ics0000644000076600000240000000124313243067215025146 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dtr_exdatez.ics0000644000076600000240000000047013243067215023006 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_mixed_awareness.ics0000644000076600000240000000110713243067215024334 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_rrule_recuid_update.ics0000644000076600000240000000041513243067215024516 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_no_dst.ics0000644000076600000240000000033213243067215021754 0ustar christiangeierstaff00000000000000 BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=America/Bogota;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America/Bogota;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:event_no_dst END:VEVENT khal-0.9.10/tests/ics/event_dt_simple_nocat.ics0000644000076600000240000000062113243067215023633 0ustar christiangeierstaff00000000000000BEGIN: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;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_r_past.ics0000644000076600000240000000026113243067215021757 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_rrule_invalid_until2.ics0000644000076600000240000000055013243067215025313 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_rrule_invalid_until.ics0000644000076600000240000000046213243067215025233 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/cal_no_dst.ics0000644000076600000240000000073613243067215021402 0ustar christiangeierstaff00000000000000BEGIN:VCALENDAR VERSION:2.0 PRODID:-//PIMUTILS.ORG//NONSGML khal / icalendar //EN BEGIN:VTIMEZONE TZID:America/Bogota BEGIN:STANDARD DTSTART;VALUE=DATE-TIME:19930403T230000 TZNAME:COT TZOFFSETFROM:-0400 TZOFFSETTO:-0500 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=America/Bogota;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America/Bogota;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:event_no_dst END:VEVENT END:VCALENDAR khal-0.9.10/tests/ics/event_d_rdate.ics0000644000076600000240000000040013243067215022064 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_long.ics0000644000076600000240000000031113243067215022111 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_rrule_recuid_invalid_tzid.ics0000644000076600000240000000067213243067215025721 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/ics/event_dt_two_tz.ics0000644000076600000240000000036213243067215022506 0ustar christiangeierstaff00000000000000BEGIN:VEVENT SUMMARY:An Event DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:20140409T093000 DTEND;TZID=America/New_York;VALUE=DATE-TIME:20140409T103000 DTSTAMP;VALUE=DATE-TIME:20140401T234817Z UID:V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU END:VEVENT khal-0.9.10/tests/ics/event_rdate_no_value.ics0000644000076600000240000000056713243067215023467 0ustar christiangeierstaff00000000000000BEGIN: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.9.10/tests/khalendar_test.py0000644000076600000240000003747213357150322021366 0ustar christiangeierstaff00000000000000from datetime import datetime, date, timedelta, time import os from time import sleep from textwrap import dedent import pytest from khal.khalendar.vdir import Item import khal.utils from khal.khalendar import CalendarCollection from khal.khalendar.event import Event from khal.khalendar.backend import CouldNotCreateDbDir import khal.khalendar.exceptions from .utils import _get_text, cal1, cal2, cal3, normalize_component from . import utils from freezegun import freeze_time today = date.today() yesterday = today - timedelta(days=1) tomorrow = today + 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) SIMPLE_EVENT_UID = 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU' class TestCalendar(object): def test_create(self, coll_vdirs): assert True def test_new_event(self, coll_vdirs, sleep_time): coll, vdirs = coll_vdirs event = coll.new_event(event_today, cal1) assert event.calendar == cal1 coll.new(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 = dict() for i 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('{}: saved ctag: {}, vdir ctag: {}'.format( calendar, coll._local_ctag(calendar), 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('{}: saved ctag: {}, vdir ctag: {}'.format( calendar, coll._local_ctag(calendar), 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('{}: saved ctag: {}, vdir ctag: {}'.format( calendar, coll._local_ctag(calendar), coll._backend.get_ctag(calendar))) assert coll._needs_update(cal1) is False class TestVdirsyncerCompat(object): def test_list(self, coll_vdirs): coll, vdirs = coll_vdirs event = Event.fromString(event_d, calendar=cal1, locale=utils.LOCALE_BERLIN) assert event.etag is None assert event.href is None coll.new(event) assert event.etag is not None assert event.href == 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU.ics' event = Event.fromString(event_today, calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(event) hrefs = sorted(href for href, uid in coll._backend.list(cal1)) assert set(str(coll._backend.get(href, calendar=cal1).uid) for href in hrefs) == set(( 'uid3@host1.com', 'V042MJ8B3SJNFXQOJL6P53OFMHJE8Z3VZWOU', )) aday = date(2014, 4, 9) bday = date(2014, 4, 10) event_dt = _get_text('event_dt_simple') event_d = _get_text('event_d') event_d_no_value = _get_text('event_d_no_value') class TestCollection(object): astart = datetime.combine(aday, time.min) aend = datetime.combine(aday, time.max) bstart = datetime.combine(bday, time.min) bend = datetime.combine(bday, 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)}, 'work': {'name': 'work', 'path': str(tmpdir), 'readonly': True}, } coll = CalendarCollection( calendars=calendars, locale=utils.LOCALE_BERLIN, dbpath=':memory:', ) assert coll.default_calendar_name is None with pytest.raises(ValueError): coll.default_calendar_name = 'work' 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 = datetime.combine(today, time.min) end = datetime.combine(today, time.max) assert list(coll.get_floating(start, end)) == list() assert list(coll.get_localized(utils.BERLIN.localize(start), utils.BERLIN.localize(end))) == list() def test_insert(self, coll_vdirs): """insert a localized event""" coll, vdirs = coll_vdirs event = Event.fromString(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(event, 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(event_d, calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(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 event = Event.fromString(event_d_no_value, calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(event, 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( event_dt, href='xyz.ics', calendar=cal1, locale=utils.LOCALE_BERLIN, ) coll.new(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')) def test_change(self, coll_vdirs): """moving an event from one calendar to another""" coll, vdirs = coll_vdirs event = Event.fromString(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(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=utils.LOCALE_BERLIN) coll.new(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 = datetime.combine(aday, time.min) anend = bday + timedelta(hours=1) event = khal.utils.new_event( dtstart=bday, dtend=anend, summary="hi", timezone=utils.BERLIN, locale=utils.LOCALE_BERLIN, ) event = coll.new_event(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(event_dt, calendar=cal1, locale=utils.LOCALE_BERLIN) with pytest.raises(khal.khalendar.exceptions.ReadOnlyCalendarError): coll.new(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=utils.LOCALE_BERLIN) coll.new(event, cal1) assert len(list(coll.search('Event'))) == 1 event = Event.fromString( _get_text('event_dt_floating'), calendar=cal1, locale=utils.LOCALE_BERLIN) coll.new(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=utils.LOCALE_BERLIN) coll.new(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=utils.LOCALE_BERLIN) coll.new(event, cal1) events = list(sorted(coll.search('Event'))) assert len(events) == 2 assert events[0].format( '{start} {end} {title}', date.today()) == '30.06. 07:30 30.06. 12:00 Arbeit\x1b[0m' assert events[1].format( '{start} {end} {title}', 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=utils.LOCALE_BERLIN) event2 = Event.fromString( _get_text('event_dt_simple'), calendar=cal2, locale=utils.LOCALE_BERLIN) coll.new(event1, cal1) sleep(sleep_time) # make sure the etags are different coll.new(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 class TestDbCreation(object): 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=utils.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=utils.LOCALE_BERLIN) 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.new_event(event_today, 'foobar') assert len(list(coll.get_events_on(today))) == 0 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.new_event(dedent(""" BEGIN:VEVENT UID:meeting-one DTSTART;VALUE=DATE:20140909 DTEND;VALUE=DATE:20140910 SUMMARY:first meeting END:VEVENT """), cal1)) href_two, etag_two = vdirs[cal1].upload(coll.new_event(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.new_event(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] khal-0.9.10/MANIFEST.in0000644000076600000240000000026313243067215016406 0ustar christiangeierstaff00000000000000include khal.conf.sample include README.rst include CONTRIBUTING.txt include AUTHORS.txt include COPYING include CHANGELOG.rst include misc/__khal include khal/settings/khal.spec khal-0.9.10/.coveragerc0000644000076600000240000000002513243067215016765 0ustar christiangeierstaff00000000000000[run] omit=khal/ui/* khal-0.9.10/COPYING0000644000076600000240000000205713243067215015706 0ustar christiangeierstaff00000000000000Copyright (c) 2013-2017 Christian Geier et al. 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.9.10/setup.py0000755000076600000240000000420013357150322016356 0ustar christiangeierstaff00000000000000#!/usr/bin/env python3 from setuptools import setup import sys if sys.version_info < (3, 3): errstr = "khal only supports python version 3.3+. Please Upgrade.\n" sys.stderr.write("#" * len(errstr) + '\n') sys.stderr.write(errstr) sys.stderr.write("#" * len(errstr) + '\n') sys.exit(1) requirements = [ 'click>=3.2', 'icalendar', 'urwid', 'pyxdg', 'pytz', 'python-dateutil', 'configobj', # https://github.com/untitaker/python-atomicwrites/commit/4d12f23227b6a944ab1d99c507a69fdbc7c9ed6d # noqa 'atomicwrites>=0.1.7', 'tzlocal>=1.0', ] test_requirements = [ 'freezegun' ] extra_requirements = { 'proctitle': ['setproctitle'], } setup( name='khal', description='A standards based terminal calendar', long_description=open('README.rst').read(), author='Christian Geier et. al.', author_email='khal@lostpackets.de', url='http://lostpackets.de/khal/', license='Expat/MIT', packages=['khal', 'khal/ui', 'khal/khalendar', 'khal/settings'], package_data={'khal': [ 'settings/default.khal', 'settings/khal.spec', ]}, entry_points={ 'console_scripts': [ 'khal = khal.cli:main_khal', 'ikhal = khal.cli:main_ikhal', ] }, install_requires=requirements, extras_require=extra_requirements, tests_require=test_requirements, setup_requires=['setuptools_scm != 1.12.0'], # not needed when using packages from PyPI use_scm_version={'write_to': 'khal/version.py'}, zip_safe=False, # because of configobj loading the .spec file classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Environment :: Console :: Curses", "Intended Audience :: End Users/Desktop", "Operating System :: POSIX", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3 :: Only", "Topic :: Utilities", "Topic :: Communications", ], ) khal-0.9.10/.gitignore0000644000076600000240000000020313357150322016630 0ustar christiangeierstaff00000000000000*.pyc *.swp khal/version.py khal.egg-info/ build/ dist/ .eggs/* *.egg *.db .tox .coverage .cache htmlcov doc/source/configspec.rst khal-0.9.10/tox.ini0000644000076600000240000000147113357150322016163 0ustar christiangeierstaff00000000000000[tox] envlist = {py33,py34,py35,py36}-{tests,style}-{pytz201702,pytz201610} skip_missing_interpreters = True [testenv] passenv = LANG CI TRAVIS TRAVIS_BRANCH TRAVIS_BUILD_ID TRAVIS_COMMIT TRAVIS_JOB_ID TRAVIS_JOB_NUMBER TRAVIS_PULL_REQUEST TRAVIS_REPO_SLUG deps = codecov pytest pytest-cov pytest-capturelog freezegun vdirsyncer<0.17.0 pytz201702: pytz==2017.2 pytz201610: pytz==2016.10 commands = py.test --cov khal {posargs} codecov -e TOXENV [testenv:style] skip_install=True whitelist_externals = sh deps = flake8 commands = flake8 sh -c '! grep -ri seperat */*' [testenv:docs] whitelist_externals = make commands = pip install -r doc/requirements.txt make -C doc html make -C doc man [flake8] max-line-length = 100 exclude=.tox,examples,doc khal-0.9.10/doc/0000755000076600000240000000000013357150672015422 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/requirements.txt0000644000076600000240000000004513243067215020677 0ustar christiangeierstaff00000000000000sphinx!=1.6.1 sphinxcontrib-newsfeed khal-0.9.10/doc/Makefile0000644000076600000240000001516313243067215017062 0ustar christiangeierstaff00000000000000# 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.9.10/doc/source/0000755000076600000240000000000013357150672016722 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/install.rst0000644000076600000240000000631613357150322021120 0ustar christiangeierstaff00000000000000Installation ============ If khal is packaged for your OS/distribution, using your system's standard package manager is probably the easiest way to install khal. khal has been packaged for, among others: Arch Linux (stable_ and development_ versions), Debian_, Fedora_, FreeBSD_, Guix_, and pkgsrc_. .. _stable: https://aur.archlinux.org/packages/khal/ .. _development: https://aur.archlinux.org/packages/khal-git/ .. _Debian: https://packages.debian.org/search?keywords=khal&searchon=names .. _Fedora: https://admin.fedoraproject.org/pkgdb/package/rpms/khal/ .. _FreeBSD: https://www.freshports.org/deskutils/py-khal/ .. _Guix: http://www.gnu.org/software/guix/packages/ .. _pkgsrc: http://pkgsrc.se/time/khal If a package isn't available (or it is outdated) you need to fall back to one of the methods mentioned below. Install via Python's Package Managers ------------------------------------- Since *khal* is written in python, you can use one of the package managers available to install python packages, e.g. *pip*. You can install the latest released version of *khal* by executing:: pip install khal or the latest development version by executing:: pip install git+git://github.com/pimutils/khal.git This should also take care of installing all required dependencies. 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.8, *khal* **only supports python 3.3+**. 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/ .. _requirements: Requirements ------------ *khal* is written in python and can run on Python 3.3+. 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/untitaker/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.9.10/doc/source/configspec.rst0000644000076600000240000004041013357150322021563 0ustar christiangeierstaff00000000000000 The [calendars] section ~~~~~~~~~~~~~~~~~~~~~~~ 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 .. _calendars-color: .. object:: color 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 the color is set to *auto* (the default), khal tries to read the file *color* from this calendar's vdir, if this fails the default_color (see below) is used. If color is set to '', the default_color is always used. :type: color :default: auto .. _calendars-path: .. object:: path 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. 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). :type: string :default: None .. _calendars-readonly: .. object:: readonly setting this to *True*, will keep khal from making any changes to this calendar :type: boolean :default: False .. _calendars-type: .. object:: type 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 ``.ics`` 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, allowed values are *calendar*, *birthdays* and *discover* :default: calendar The [default] section ~~~~~~~~~~~~~~~~~~~~~ Some default values and behaviors are set here. .. _default-default_calendar: .. object:: default_calendar 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. :type: string :default: None .. _default-default_command: .. object:: default_command Command to be executed if no command is given when executing khal. :type: option, allowed values are *calendar*, *list*, *interactive*, *printformats*, *printcalendars*, *printics* and ** :default: calendar .. _default-highlight_event_days: .. object:: highlight_event_days If true, khal will highlight days with events. Options for highlighting are in [highlight_days] section. :type: boolean :default: False .. _default-print_new: .. object:: print_new 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? :type: option, allowed values are *event*, *path* and *False* :default: False .. _default-show_all_days: .. object:: show_all_days 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. :type: boolean :default: False .. _default-timedelta: .. object:: timedelta Controls for how many days into the future we show events (for example, in `khal list`) by default. :type: timedelta :default: 2d The [highlight_days] section ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When highlight_event_days is enabled, this section specifies how the highlighting/coloring of days is handled. .. _highlight_days-color: .. object:: color What color to use when highlighting -- explicit color or use calendar color when set to '' :type: color :default: .. _highlight_days-default_color: .. object:: default_color Default color for calendars without color -- when set to '' it actually disables highlighting for events that should use the default color. :type: color :default: .. _highlight_days-method: .. object:: method Highlighting method to use -- foreground or background :type: option, allowed values are *foreground*, *fg*, *background* and *bg* :default: fg .. _highlight_days-multiple: .. object:: multiple How to color days with events from multiple calendars -- either explicit color or use calendars' colors when set to '' :type: color :default: The [keybindings] section ~~~~~~~~~~~~~~~~~~~~~~~~~ Keybindings for :command:`ikhal` are set here. You can bind more then 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-delete: .. object:: delete delete the currently selected event :type: list :default: d .. _keybindings-down: .. object:: down move the cursor down (in the calendar browser) :type: list :default: down, j .. _keybindings-duplicate: .. object:: duplicate duplicate the currently selected event :type: list :default: p .. _keybindings-export: .. object:: export export event as a .ics file :type: list :default: e .. _keybindings-external_edit: .. object:: external_edit 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. :type: list :default: meta E .. _keybindings-left: .. object:: left move the cursor left (in the calendar browser) :type: list :default: left, h, backspace .. _keybindings-mark: .. object:: mark go into highlight (visual) mode to choose a date range :type: list :default: v .. _keybindings-new: .. object:: new create a new event on the selected date :type: list :default: n .. _keybindings-other: .. object:: other in highlight mode go to the other end of the highlighted date range :type: list :default: o .. _keybindings-quit: .. object:: quit quit :type: list :default: q, Q .. _keybindings-right: .. object:: right move the cursor right (in the calendar browser) :type: list :default: right, l, space .. _keybindings-save: .. object:: save save the currently edited event and leave the event editor :type: list :default: meta enter .. _keybindings-search: .. object:: search open a text field to start a search for events :type: list :default: / .. _keybindings-today: .. object:: today focus the calendar browser on today :type: list :default: t .. _keybindings-up: .. object:: up move the cursor up (in the calendar browser) :type: list :default: up, k .. _keybindings-view: .. object:: view show details or edit (if details are already shown) the currently selected event :type: list :default: enter The [locale] section ~~~~~~~~~~~~~~~~~~~~ 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-dateformat: .. object:: dateformat khal will display and understand all dates in this format, see :ref:`timeformat ` for the format :type: string :default: %d.%m. .. _locale-datetimeformat: .. object:: datetimeformat khal will display and understand all datetimes in this format, see :ref:`timeformat ` for the format. :type: string :default: %d.%m. %H:%M .. _locale-default_timezone: .. object:: default_timezone 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. :type: timezone :default: None .. _locale-firstweekday: .. object:: firstweekday the first day of the week, were Monday is 0 and Sunday is 6 :type: integer, allowed values are between 0 and 6 :default: 0 .. _locale-local_timezone: .. object:: local_timezone khal will show all times in this timezone If no timezone is set, the timezone your computer is set to will be used. :type: timezone :default: None .. _locale-longdateformat: .. object:: longdateformat khal will display and understand all dates in this format, it should contain a year (e.g. *%Y*) see :ref:`timeformat ` for the format. :type: string :default: %d.%m.%Y .. _locale-longdatetimeformat: .. object:: longdatetimeformat khal will display and understand all datetimes in this format, it should contain a year (e.g. *%Y*) see :ref:`timeformat ` for the format. :type: string :default: %d.%m.%Y %H:%M .. _locale-timeformat: .. object:: timeformat 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``. :type: string :default: %H:%M .. _locale-unicode_symbols: .. object:: unicode_symbols 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). :type: boolean :default: True .. _locale-weeknumbers: .. object:: weeknumbers Enable weeknumbers in `calendar` and `interactive` (ikhal) mode. As those are iso weeknumbers, they only work properly if `firstweekday` is set to 0 :type: weeknumbers :default: off The [sqlite] section ~~~~~~~~~~~~~~~~~~~~ .. _sqlite-path: .. object:: path 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*). :type: string :default: None The [view] section ~~~~~~~~~~~~~~~~~~ The view section contains configuration options that effect the visual appearance when using khal and ikhal. .. _view-agenda_day_format: .. object:: agenda_day_format Specifies how each *day header* is formatted. :type: string :default: {bold}{name}, {date-long}{reset} .. _view-agenda_event_format: .. object:: agenda_event_format 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`. :type: string :default: {calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{description-separator}{description}{reset} .. _view-bold_for_light_color: .. object:: bold_for_light_color 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. :type: boolean :default: True .. _view-dynamic_days: .. object:: dynamic_days 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. :type: boolean :default: True .. _view-event_format: .. object:: event_format 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`. :type: string :default: {calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{description-separator}{description}{reset} .. _view-event_view_always_visible: .. object:: event_view_always_visible Set to true to always show the event view window when looking at the event list :type: boolean :default: False .. _view-event_view_weighting: .. object:: event_view_weighting weighting that is applied to the event view window :type: integer :default: 1 .. _view-frame: .. object:: frame 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). :type: option, allowed values are *False*, *width*, *color* and *top* :default: False .. _view-theme: .. object:: theme Choose a color theme for khal. This is very much work in progress. Help is really welcome! The two currently available color schemes (*dark* and *light*) are defined in *khal/ui/colors.py*, you can either help improve those or create a new one (see below). As ikhal uses urwid, have a look at `urwid's documentation`__ for how to set colors and/or at the existing schemes. If you cannot change the color of an element (or have any other problems) please open an issue on github_. If you want to create your own color scheme, copy the structure of the existing ones, give it a new and unique name and also add it as an option in `khal/settings/khal.spec` in the section `[default]` of the property `theme`. __ http://urwid.org/manual/displayattributes.html .. _github: # https://github.com/pimutils/khal/issues :type: option, allowed values are *dark* and *light* :default: dark khal-0.9.10/doc/source/index.rst0000644000076600000240000000230413357150322020552 0ustar christiangeierstaff00000000000000khal ==== *Khal* is a standards based CLI (console) 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 - only rudimentary support for creating and editing recursion rules - you cannot edit the timezones of events - works with python 3.3+ - khal should run on all major operating systems [1]_ .. [1] except for Microsoft Windows .. _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 Table of Contents ================= .. toctree:: :maxdepth: 1 install configure usage standards feedback hacking changelog faq license news khal-0.9.10/doc/source/news.rst0000644000076600000240000000145213357150322020422 0ustar christiangeierstaff00000000000000News ==== 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/khal099 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.9.10/doc/source/images/0000755000076600000240000000000013357150672020167 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/images/rss.png0000644000076600000240000000160013243067215021473 0ustar christiangeierstaff00000000000000PNG  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 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 LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'khal.tex', 'khal Documentation', 'Christan Geier et al.', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- 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', 'Christan Geier et al.', 'khal', 'One line description of project.', '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 = {'http://docs.python.org/': None} khal-0.9.10/doc/source/ystatic/0000755000076600000240000000000013357150672020402 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/ystatic/.gitignore0000644000076600000240000000000013243067215022352 0ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/hacking.rst0000644000076600000240000002442313357150322021055 0ustar christiangeierstaff00000000000000Hacking ======= .. 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) 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 `travis CI`_ 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` make 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). .. note:: The file :file:`doc/source/configspec.rst` is auto-generated on making the documentation from the file :file:`khal/settings/khal.spec`. So instead of editing the former, please edit the later, run make and include both changes in your patch. 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 flake8_ (run with :command:`flake8` from khal's source directory), which will also be run with tox and travisCI, 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 .. _travis CI: https://travis-ci.org/pimutils/khal .. _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 .. _flake8: http://flake8.pycqa.org/ .. _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.9.10/doc/source/ytemplates/0000755000076600000240000000000013357150672021111 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/ytemplates/layout.html0000644000076600000240000000030513243067215023304 0ustar christiangeierstaff00000000000000{% extends "!layout.html" %} {% block linktags %} {{ super() }} {% endblock %} khal-0.9.10/doc/source/usage.rst0000644000076600000240000003716313357150322020562 0ustar christiangeierstaff00000000000000Usage ===== 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. Calling :program:`khal` without any command will invoke the default command, which can be specified in the :doc:`configuration file `. 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 Be more verbose (e.g. print debugging information) .. 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. 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. description The event description. description-separator A separator: " :: " that appears when there is a description. 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`. cancelled The string `CANCELLED` (plus one blank) if the event's status is cancelled, otherwise nothing. 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, 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. For example the below command with print the title and description of all events today. :: khal list --format "{title} {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). Commands -------- list **** shows all events scheduled for a given date (or datetime) range, with custom formatting: :: khal list [-a CALENDAR ... | -d CALENDAR ...] [--format FORMAT] [--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`. 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] [--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` escape 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 field you can increment and decrement the number under the cursor with :kbd:`ctrl+a` and :kbd:`ctrl+x` (time in 15 minute steps) * 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 * **--alarm DURATION** will add an alarm DURATION before the start of the event, *DURATION* should look like `1day 10minutes` or `1d3H10m`, negative *DURATIONs* will set alarm after the start of the event. Examples """""""" :: 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* 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 10:09*) 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.9.10/doc/source/news/0000755000076600000240000000000013357150672017676 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/source/news/callfortesting.rst0000644000076600000240000000112713243067215023443 0ustar christiangeierstaff00000000000000Call 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.9.10/doc/source/news/khal011.rst0000644000076600000240000000034313243067215021563 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal09.rst0000644000076600000240000000206513243067215021515 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal08.rst0000644000076600000240000000170713243067215021516 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/31c3.rst0000644000076600000240000000064013243067215021073 0ustar christiangeierstaff00000000000000pycarddav 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.9.10/doc/source/news/khal071.rst0000644000076600000240000000163213243067215021573 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal099.rst0000644000076600000240000000120513357150322021577 0ustar christiangeierstaff00000000000000khal v0.9.9 released (dependency clarification) =============================================== .. feed-entry:: :date: 2018-05-26 `khal v0.9.9`_ (and previous version of khal) currently only support the dateutil library (that khal depends on) in versions < 2.7. The only change in khal v0.9.9 is updated dependency. If your OS already shipe dateutil >= 2.7, we recommend pipsi_ to install the latest version of khal. Get `khal v0.9.9`_ from this site, or from pypi_. .. _pypi: https://pypi.python.org/pypi/khal/ .. _khal v0.9.9: https://lostpackets.de/khal/downloads/khal-0.9.9.tar.gz .. _pipsi: https://pypi.org/project/pipsi/ khal-0.9.10/doc/source/news/khal098.rst0000644000076600000240000000126513243067215021606 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/30c3.rst0000644000076600000240000000063513243067215021076 0ustar christiangeierstaff00000000000000pycarddav 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.9.10/doc/source/news/khal093.rst0000644000076600000240000000132213243067215021573 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal092.rst0000644000076600000240000000215613243067215021600 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal084.rst0000644000076600000240000000350113243067215021574 0ustar christiangeierstaff00000000000000khal 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 Freenode). 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@Freenode khal-0.9.10/doc/source/news/khal091.rst0000644000076600000240000000200613243067215021571 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal081.rst0000644000076600000240000000055213243067215021574 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal095.rst0000644000076600000240000000070513243067215021601 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal094.rst0000644000076600000240000000154213243067215021600 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal096.rst0000644000076600000240000000072313243067215021602 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal082.rst0000644000076600000240000000106013243067215021570 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal083.rst0000644000076600000240000000054313243067215021576 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal097.rst0000644000076600000240000000125113243067215021600 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal03.rst0000644000076600000240000000246613357150322021512 0ustar christiangeierstaff00000000000000khal 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/untitaker/vdirsyncer/ khal-0.9.10/doc/source/news/khal02.rst0000644000076600000240000000230513357150322021501 0ustar christiangeierstaff00000000000000khal 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/untitaker/vdirsyncer/ .. _tutorial: https://vdirsyncer.readthedocs.org/en/latest/tutorial.html .. _documentation: http://lostpackets.de/khal/pages/usage.html khal-0.9.10/doc/source/news/khal031.rst0000644000076600000240000000116313243067215021566 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal01.rst0000644000076600000240000000112313357150322021475 0ustar christiangeierstaff00000000000000khal 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/untitaker/vdirsyncer/ .. _github: https://github.com/geier/khal/tree/vdir khal-0.9.10/doc/source/news/khal05.rst0000644000076600000240000000136213243067215021510 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal04.rst0000644000076600000240000000143513243067215021510 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal06.rst0000644000076600000240000000137013243067215021510 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/news/khal07.rst0000644000076600000240000000155113243067215021512 0ustar christiangeierstaff00000000000000khal 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.9.10/doc/source/man.rst0000644000076600000240000000054213243067215020222 0ustar christiangeierstaff00000000000000khal ==== 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.9.10/doc/source/configure.rst0000644000076600000240000000404513357150322021430 0ustar christiangeierstaff00000000000000Configuration ============= :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]]**). 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/untitaker/vdirsyncer khal-0.9.10/doc/source/license.rst0000644000076600000240000000225313243067215021072 0ustar christiangeierstaff00000000000000License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2017 Christian Geier et al. 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.9.10/doc/source/feedback.rst0000644000076600000240000000451113357150322021171 0ustar christiangeierstaff00000000000000Feedback ======== .. 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`) * 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 Freenode, 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@Freenode .. _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.9.10/doc/source/changelog.rst0000644000076600000240000000006113243067215021372 0ustar christiangeierstaff00000000000000.. _changelog: .. include:: ../../CHANGELOG.rst khal-0.9.10/doc/source/faq.rst0000644000076600000240000000211113243067215020210 0ustar christiangeierstaff00000000000000FAQ === Frequently asked questions: * **start up of khal and ikhal is very slow** In some case the pytz (python timezone) is only available as a zip file, as pytz accesses several parts during initialization this takes some time. If `time python -c "import pytz; pytz.timezone('Europe/Berlin')"` takes nearly as much time as running khal, uncompressing that file via pytz via `(sudo) pip unzip pytz` might help. * **ikhal raises an Exception: AttributeError: 'module' object has no attribute 'SimpleFocusListWalker'** You probably need to upgrade urwid to version 1.1.0, if your OS does come with an older version of *urwid* you can install the latest version to userspace (without messing up your default installation) with `pip install --upgrade urwid --user`. * **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*. khal-0.9.10/doc/source/standards.rst0000644000076600000240000001031213243067215021426 0ustar christiangeierstaff00000000000000Standards ========= *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.9.10/doc/webpage/0000755000076600000240000000000013357150672017034 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/webpage/src/0000755000076600000240000000000013357150672017623 5ustar christiangeierstaff00000000000000khal-0.9.10/doc/webpage/src/new_rss_url.rst0000644000076600000240000000030513243067215022707 0ustar christiangeierstaff00000000000000New 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.9.10/setup.cfg0000644000076600000240000000004613357150673016477 0ustar christiangeierstaff00000000000000[egg_info] tag_build = tag_date = 0 khal-0.9.10/khal.conf.sample0000644000076600000240000000110713357150322017712 0ustar christiangeierstaff00000000000000#/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 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_command = calendar default_calendar = home timedelta = 2 # the default timedelta that list uses highlight_event_days = True # the default is False khal-0.9.10/README.rst0000644000076600000240000000744413357150322016345 0ustar christiangeierstaff00000000000000khal ==== .. image:: https://travis-ci.org/pimutils/khal.svg?branch=master :target: https://travis-ci.org/pimutils/khal .. image:: https://codecov.io/github/pimutils/khal/coverage.svg?branch=master :target: https://codecov.io/github/pimutils/khal?branch=master *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.3+ - 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 IRC (#pimutils on Freenode), otherwise you can reach the original author via email at khal (at) lostpackets (dot) de or via jabber/XMPP at geier (at) jabber (dot) ccc (dot) de. .. _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 the website_ or readthedocs_. .. _website: https://lostpackets.de/khal/ .. _readthedocs: http://khal.readthedocs.org/ 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. If you don't have the time or the means to contribute in any of the above mentioned ways, donations are appreciated, too. .. image:: https://api.flattr.com/button/flattr-badge-large.png :alt: flattr button :target: http://flattr.com/thing/2475065/geierkhal-on-GitHub/ License ------- khal is released under the Expat/MIT License:: Copyright (c) 2013-2017 Christian Geier et al. 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.9.10/CHANGELOG.rst0000644000076600000240000004542713357150322016702 0ustar christiangeierstaff00000000000000Changelog ######### 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.9.10 ====== released 2018-010-09 * Dependencies: dateutil 2.7 supported now 0.9.9 ===== released 2018-05-26 * Dependencies: only dateutil < 2.7 is supported (and always has been) 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 * FIX `search` will no longer break on overwritten events with a master event * 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 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.9.10/AUTHORS.txt0000644000076600000240000000342513357150322016537 0ustar christiangeierstaff00000000000000Christian 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@barrera.io - https://hugo.barrera.io 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 khal-0.9.10/.travis.yml0000644000076600000240000000063213357150322016757 0ustar christiangeierstaff00000000000000sudo: false language: python python: - 3.3 - 3.4 - 3.5 - 3.6 - 3.7-dev env: - BUILD=py matrix: include: - python: 3.6 env: BUILD=style - python: 3.6 env: BUILD=docs - python: 3.6 env: BUILD=pytz201610 addons: apt: packages: - language-pack-de install: - "pip install tox" script: - "tox -e $BUILD" khal-0.9.10/CODE_OF_CONDUCT.rst0000644000076600000240000000006313243067215017655 0ustar christiangeierstaff00000000000000See `the pimutils CoC `_.