Repository: pimutils/khal

Issue:
Default alarm
It would be nice if khal could default to setting an alarm, say, a configurable number of minutes before a newly-created event.

Discussion:
WhyNotHugo: I agree that the naming looks backwards there. I may be missing context though.
geier: Thanks and sorry for the 2.5 year delay in taking a look at this.

I have rebased this on the latest master. I also think it's a good idea to have this in ikhal and while it's simple to add a default alarm for a new event, I'm not yet sure on how to convert the alarm if one changes the event from daytime to allday. Perhaps check if the alarm is equal to the default and change it then to the other default?
geier:  > Also: I think in the existing options "default_(day)event_duration" the terms "dayevent" and "event" are mixed up. I'd expect "dayevent" to refer to an day-long event and in contrast to that "event" an event of e.g. 1h duration, but the implementation is the other way round. My commit, nevertheless, uses this same (possibly reverse) meaning.

Good catch and I have no idea how that could be that wrong for so many years. Hopefully fixed.

geier: > I'm not yet sure on how to convert the alarm if one changes the event from daytime to allday. Perhaps check if the alarm is equal to the default and change it then to the other default?

implemented that way.

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index c9dfc4f..b58161f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -18,6 +18,9 @@ not released yet
   config file (via the new [palette] section, check the documenation)
 * NEW timedelta strings can now have a leading `+`, e.g. `+1d`
 * NEW Add `--json` option to output event data as JSON objects
+* NEW Add default alarms configuration option
+* FIX defaults for `default_event_duration` and `default_dayevent_duration`
+  where mixed up, `default_dayevent_duration` is the default for all-day events
 
 0.11.2
 ======
diff --git a/khal/controllers.py b/khal/controllers.py
index acedf9c..abe36e9 100644
--- a/khal/controllers.py
+++ b/khal/controllers.py
@@ -48,6 +48,7 @@ from .exceptions import ConfigurationError
 from .icalendar import cal_from_ics, split_ics
 from .icalendar import sort_key as sort_vevent_key
 from .khalendar.vdir import Item
+from .parse_datetime import timedelta2str
 from .terminal import merge_columns
 from .utils import human_formatter, json_formatter
 
@@ -329,8 +330,8 @@ def new_interactive(collection, calendar_name, conf, info, location=None,
     try:
         info = parse_datetime.eventinfofstr(
             info, conf['locale'],
-            conf['default']['default_event_duration'],
-            conf['default']['default_dayevent_duration'],
+            default_event_duration=conf['default']['default_event_duration'],
+            default_dayevent_duration=conf['default']['default_dayevent_duration'],
             adjust_reasonably=True,
         )
     except DateTimeParseError:
@@ -409,6 +410,11 @@ def new_from_string(collection, calendar_name, conf, info, location=None,
         conf['default']['default_dayevent_duration'],
         adjust_reasonably=True,
     )
+    if alarms is None:
+        if info['allday']:
+            alarms = timedelta2str(conf['default']['default_dayevent_alarm'])
+        else:
+            alarms = timedelta2str(conf['default']['default_event_alarm'])
     info.update({
         'location': location,
         'categories': categories,
diff --git a/khal/icalendar.py b/khal/icalendar.py
index cfe9811..39e6eda 100644
--- a/khal/icalendar.py
+++ b/khal/icalendar.py
@@ -32,8 +32,8 @@ import icalendar
 import pytz
 
 from .exceptions import UnsupportedRecurrence
-from .parse_datetime import guesstimedeltafstr, rrulefstr
-from .utils import generate_random_uid, localize_strip_tz, to_unix_time
+from .parse_datetime import rrulefstr
+from .utils import generate_random_uid, localize_strip_tz, str2alarm, to_unix_time
 
 logger = logging.getLogger('khal')
 
@@ -139,14 +139,8 @@ def new_vevent(locale,
         rrule = rrulefstr(repeat, until, locale, getattr(dtstart, 'tzinfo', None))
         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)
+        for alarm in str2alarm(alarms, description or ''):
+            event.add_component(alarm)
     return event
 
 
diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py
index fe506b2..e1258e2 100644
--- a/khal/khalendar/event.py
+++ b/khal/khalendar/event.py
@@ -25,7 +25,7 @@ helper functions."""
 import datetime as dt
 import logging
 import os
-from typing import Dict, List, Optional, Type, Union
+from typing import Dict, List, Optional, Tuple, Type, Union
 
 import icalendar
 import icalendar.cal
@@ -441,7 +441,7 @@ class Event:
             isinstance(alarm.get('TRIGGER').dt, dt.timedelta)
 
     @property
-    def alarms(self):
+    def alarms(self) -> List[Tuple[dt.timedelta, str]]:
         """
         Returns a list of all alarms in th original event that we can handle. Unknown types of
         alarms are ignored.
@@ -450,7 +450,7 @@ class Event:
                 for a in self._vevents[self.ref].subcomponents
                 if a.name == 'VALARM' and self._can_handle_alarm(a)]
 
-    def update_alarms(self, alarms):
+    def update_alarms(self, alarms: List[Tuple[dt.timedelta, str]]) -> None:
         """
         Replaces all alarms in the event that can be handled with the ones provided.
         """
@@ -532,7 +532,7 @@ class Event:
     def description(self) -> str:
         return self._vevents[self.ref].get('DESCRIPTION', '')
 
-    def update_description(self, description):
+    def update_description(self, description: str):
         if description:
             self._vevents[self.ref]['DESCRIPTION'] = description
         else:
@@ -554,16 +554,21 @@ class Event:
             alarmstr = ''
         return alarmstr
 
-    def attributes(self, relative_to, env=None, colors: bool=True):
+    def attributes(
+            self,
+            relative_to: Union[Tuple[dt.date, dt.date], dt.date],
+            env=None,
+            colors: bool=True,
+    ):
         """
         :param colors: determines if colors codes should be printed or not
         """
         env = env or {}
 
         attributes = {}
-        try:
+        if isinstance(relative_to, tuple):
             relative_to_start, relative_to_end = relative_to
-        except TypeError:
+        else:
             relative_to_start = relative_to_end = relative_to
 
         if isinstance(relative_to_end, dt.datetime):
diff --git a/khal/parse_datetime.py b/khal/parse_datetime.py
index c060579..c7d92f2 100644
--- a/khal/parse_datetime.py
+++ b/khal/parse_datetime.py
@@ -465,8 +465,8 @@ def eventinfofstr(info_string: str,
         try:
             start, end, allday = guessrangefstr(
                 ' '.join(parts[0:i]), locale,
-                default_event_duration,
-                default_dayevent_duration,
+                default_timedelta_datetime=default_event_duration,
+                default_timedelta_date=default_dayevent_duration,
                 adjust_reasonably=adjust_reasonably,
             )
         except (ValueError, DateTimeParseError):
diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec
index ec4e8ee..dc9ae1a 100644
--- a/khal/settings/khal.spec
+++ b/khal/settings/khal.spec
@@ -210,11 +210,17 @@ highlight_event_days = boolean(default=False)
 timedelta = timedelta(default='2d')
 
 
+# Define the default duration for an event ('khal new' only)
+default_event_duration = timedelta(default='1h')
+
 # Define the defaut duration for a day-long event ('khal new' only)
-default_event_duration = timedelta(default='1d')
+default_dayevent_duration = timedelta(default='1d')
 
-# Define the default duration for an event ('khal new' only)
-default_dayevent_duration = timedelta(default='1h')
+# Define the default alarm for new events, e.g. '15m'
+default_event_alarm = timedelta(default='')
+
+# Define the default alarm for new all dayevents, e.g. '12h'
+default_dayevent_alarm = timedelta(default='')
 
 # Whether the mouse should be enabled in interactive mode ('khal interactive' and
 # 'ikhal' only)
diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py
index 795f8b1..8705772 100644
--- a/khal/ui/__init__.py
+++ b/khal/ui/__init__.py
@@ -32,6 +32,7 @@ import urwid
 from .. import utils
 from ..khalendar import CalendarCollection
 from ..khalendar.exceptions import ReadOnlyCalendarError
+from ..parse_datetime import timedelta2str
 from . import colors
 from .base import Pane, Window
 from .editor import EventEditor, ExportDialog
@@ -907,6 +908,7 @@ class EventColumn(urwid.WidgetWrap):
             'summary': '',
             'timezone': self._conf['locale']['default_timezone'],
             'allday': allday,
+            'alarms': timedelta2str(self._conf['default']['default_event_alarm']),
         })
         self.edit(event)
 
diff --git a/khal/ui/editor.py b/khal/ui/editor.py
index 00a1a09..b4404f4 100644
--- a/khal/ui/editor.py
+++ b/khal/ui/editor.py
@@ -169,13 +169,16 @@ class StartEndEditor(urwid.WidgetWrap):
                  conf,
                  on_start_date_change=lambda x: None,
                  on_end_date_change=lambda x: None,
-
+                 on_type_change: Callable[[bool], None]=lambda _: None,
                  ) -> None:
         """
         :param on_start_date_change: a callable that gets called everytime a new
             start date is entered, with that new date as an argument
         :param on_end_date_change: same as for on_start_date_change, just for the
             end date
+        :param on_type_change: callback that gets called when the event type
+            (allday or datetime) changes, gets passed True if toggled to allday
+            and False if toggled to daytime
         """
         self.allday = not isinstance(start, dt.datetime)
         self.conf = conf
@@ -185,6 +188,7 @@ class StartEndEditor(urwid.WidgetWrap):
         self._original_end: dt.date = end
         self.on_start_date_change = on_start_date_change
         self.on_end_date_change = on_end_date_change
+        self.on_type_change = on_type_change
         self._datewidth = len(start.strftime(self.conf['locale']['longdateformat']))
         self._timewidth = len(start.strftime(self.conf['locale']['timeformat']))
         # this will contain the widgets for [start|end] [date|time]
@@ -299,11 +303,13 @@ class StartEndEditor(urwid.WidgetWrap):
             self.conf['keybindings'],
         )
 
-        if state is True:
+        if state is True:  # allday event
+            self.on_type_change(True)
             timewidth = 1
             self.widgets.starttime = urwid.Text('')
             self.widgets.endtime = urwid.Text('')
-        elif state is False:
+        elif state is False:  # datetime event
+            self.on_type_change(False)
             timewidth = self._timewidth + 1
             raw_start_time_widget = ValidatedEdit(
                 dateformat=self.conf['locale']['timeformat'],
@@ -376,6 +382,7 @@ class EventEditor(urwid.WidgetWrap):
         self.startendeditor = StartEndEditor(
             event.start_local, event.end_local, self._conf,
             self.start_datechange, self.end_datechange,
+            self.type_change,
         )
         # TODO make sure recurrence rules cannot be edited if we only
         # edit one instance (or this and future) (once we support that)
@@ -422,7 +429,7 @@ class EventEditor(urwid.WidgetWrap):
         self.url = urwid.AttrMap(ExtendedEdit(
             caption=('caption', 'URL:         '), edit_text=self.url), 'edit', 'edit focus',
         )
-        self.alarms = AlarmsEditor(self.event)
+        self.alarmseditor: AlarmsEditor = AlarmsEditor(self.event)
         self.pile = NListBox(urwid.SimpleFocusListWalker([
             self.summary,
             urwid.Columns([(13, urwid.AttrMap(urwid.Text('Calendar:'), 'caption')),
@@ -439,7 +446,7 @@ class EventEditor(urwid.WidgetWrap):
             self.startendeditor,
             self.recurrenceeditor,
             divider,
-            self.alarms,
+            self.alarmseditor,
             divider,
             urwid.Columns(
                 [(12, button('Save', on_press=self.save, padding_left=0, padding_right=0))]
@@ -458,6 +465,32 @@ class EventEditor(urwid.WidgetWrap):
     def end_datechange(self, date):
         self.pane.eventscolumn.original_widget.set_focus_date(date)
 
+    def type_change(self, allday: bool) -> None:
+        """when the event type changes, we might want to change the default alarms
+
+        :params allday: True if the event is now an allday event, False if it isn't
+        """
+        # test if self.alarmseditor exists
+        if not hasattr(self, 'alarmseditor'):
+            return
+
+        # to make the alarms before the event, we need to set it them to
+        # negative values
+        default_event_alarm = -1 * self._conf['default']['default_event_alarm']
+        default_dayevent_alarm =-1 *  self._conf['default']['default_dayevent_alarm']
+        alarms = self.alarmseditor.get_alarms()
+        if len(alarms) == 1:
+            timedelta = alarms[0][0]
+            if allday and timedelta == default_event_alarm:
+                self.alarmseditor.clear()
+                self.alarmseditor.add_alarm(None, default_dayevent_alarm)
+            elif (not allday) and timedelta == default_dayevent_alarm:
+                self.alarmseditor.clear()
+                self.alarmseditor.add_alarm(None, default_event_alarm)
+            else:
+                # either there were more than one alarm or the alarm was not the default
+                pass
+
     @property
     def title(self):  # Window title
         return f'Edit: {get_wrapped_text(self.summary)}'
@@ -484,7 +517,7 @@ class EventEditor(urwid.WidgetWrap):
             return True
         if self.recurrenceeditor.changed:
             return True
-        if self.alarms.changed:
+        if self.alarmseditor.changed:
             return True
         return False
 
@@ -503,8 +536,8 @@ class EventEditor(urwid.WidgetWrap):
             rrule = self.recurrenceeditor.active
             self.event.update_rrule(rrule)
 
-        if self.alarms.changed:
-            self.event.update_alarms(self.alarms.get_alarms())
+        if self.alarmseditor.changed:
+            self.event.update_alarms(self.alarmseditor.get_alarms())
 
     def export(self, button):
         """
diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py
index 9f12dcc..4a793ab 100644
--- a/khal/ui/widgets.py
+++ b/khal/ui/widgets.py
@@ -477,8 +477,8 @@ class DurationWidget(urwid.WidgetWrap):
         seconds = int(seconds % 60)
         return days, hours, minutes, seconds
 
-    def __init__(self, dt) -> None:
-        days, hours, minutes, seconds = self._convert_timedelta(dt)
+    def __init__(self, timedelta: dt.timedelta) -> None:
+        days, hours, minutes, seconds = self._convert_timedelta(timedelta)
 
         self.days_edit = ValidatedEdit(
             edit_text=str(days), validate=self.unsigned_int, align='right')
@@ -502,7 +502,7 @@ class DurationWidget(urwid.WidgetWrap):
 
         urwid.WidgetWrap.__init__(self, self.columns)
 
-    def get_timedelta(self):
+    def get_timedelta(self) -> dt.timedelta:
         return dt.timedelta(
             seconds=int(self.seconds_edit.get_edit_text()) +
             int(self.minutes_edit.get_edit_text()) * 60 +
@@ -514,7 +514,7 @@ class AlarmsEditor(urwid.WidgetWrap):
 
     class AlarmEditor(urwid.WidgetWrap):
 
-        def __init__(self, alarm, delete_handler) -> None:
+        def __init__(self, alarm: Tuple[dt.timedelta, str], delete_handler) -> None:
             duration, description = alarm
             if duration.total_seconds() > 0:
                 direction = 'after'
@@ -555,10 +555,17 @@ class AlarmsEditor(urwid.WidgetWrap):
 
         urwid.WidgetWrap.__init__(self, self.pile)
 
-    def add_alarm(self, button):
+
+    def clear(self) -> None:
+        """clear the alarm list"""
+        self.pile.contents.clear()
+
+    def add_alarm(self, button, timedelta: Optional[dt.timedelta]):
+        if timedelta is None:
+            timedelta = dt.timedelta(0)
         self.pile.contents.insert(
             len(self.pile.contents) - 1,
-            (self.AlarmEditor((dt.timedelta(0), self.event.summary), self.remove_alarm),
+            (self.AlarmEditor((timedelta, self.event.summary), self.remove_alarm),
              ('weight', 1)))
 
     def remove_alarm(self, button, editor):
diff --git a/khal/utils.py b/khal/utils.py
index fae820b..d7df22b 100644
--- a/khal/utils.py
+++ b/khal/utils.py
@@ -31,10 +31,12 @@ from calendar import month_abbr, timegm
 from textwrap import wrap
 from typing import Iterator, List, Optional, Tuple
 
+import icalendar
 import pytz
 import urwid
 from click import style
 
+from .parse_datetime import guesstimedeltafstr
 from .terminal import get_color
 
 
@@ -255,3 +257,21 @@ def json_formatter(fields):
         else:
             return results
     return fmt
+
+
+def alarmstr2trigger(alarms: str) -> Iterator[dt.timedelta]:
+    """convert a comma separated list of alarm strings to dt.timedelta"""
+    for alarm in alarms.split(","):
+        alarm = alarm.strip()
+        alarm_trig = -1 * guesstimedeltafstr(alarm)
+        yield alarm_trig
+
+
+def str2alarm(alarms: str, description: str) -> Iterator[icalendar.Alarm]:
+    """convert a comma separated list of alarm strings to icalendar.Alarm"""
+    for alarm_trig in alarmstr2trigger(alarms):
+        new_alarm = icalendar.Alarm()
+        new_alarm.add('ACTION', 'DISPLAY')
+        new_alarm.add('TRIGGER', alarm_trig)
+        new_alarm.add('DESCRIPTION', description)
+        yield new_alarm


Test patch:
diff --git a/tests/parse_datetime_test.py b/tests/parse_datetime_test.py
index 1dd9cd8..3eaa568 100644
--- a/tests/parse_datetime_test.py
+++ b/tests/parse_datetime_test.py
@@ -35,8 +35,8 @@ def _construct_event(info, locale,
                      location=None, categories=None, repeat=None, until=None,
                      alarm=None, **kwargs):
     info = eventinfofstr(' '.join(info), locale,
-                         dt.timedelta(days=1),
-                         dt.timedelta(hours=1),
+                         default_event_duration=dt.timedelta(hours=1),
+                         default_dayevent_duration=dt.timedelta(days=1),
                          adjust_reasonably=True,
                          )
     if description is not None:
diff --git a/tests/settings_test.py b/tests/settings_test.py
index 586e696..290f1ae 100644
--- a/tests/settings_test.py
+++ b/tests/settings_test.py
@@ -52,8 +52,10 @@ class TestSettings:
                 'print_new': 'False',
                 'highlight_event_days': False,
                 'timedelta': dt.timedelta(days=2),
-                'default_event_duration': dt.timedelta(days=1),
-                'default_dayevent_duration': dt.timedelta(hours=1),
+                'default_event_duration': dt.timedelta(hours=1),
+                'default_dayevent_duration': dt.timedelta(days=1),
+                'default_event_alarm': dt.timedelta(0),
+                'default_dayevent_alarm': dt.timedelta(0),
                 'show_all_days': False,
                 'enable_mouse': True,
             }
@@ -101,10 +103,12 @@ class TestSettings:
                 'print_new': 'False',
                 'highlight_event_days': False,
                 'timedelta': dt.timedelta(days=2),
-                'default_event_duration': dt.timedelta(days=1),
-                'default_dayevent_duration': dt.timedelta(hours=1),
+                'default_event_duration': dt.timedelta(hours=1),
+                'default_dayevent_duration': dt.timedelta(days=1),
                 'show_all_days': False,
                 'enable_mouse': True,
+                'default_event_alarm': dt.timedelta(0),
+                'default_dayevent_alarm': dt.timedelta(0),
             }
         }
         for key in comp_config: