diff --git a/AUTHORS.txt b/AUTHORS.txt index e417da915..dc8a0247e 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -55,3 +55,4 @@ Matthew Rademaker - matthew.rademaker [at] gmail [dot] com Valentin Iovene - val [at] too [dot] gy Julian Wollrath Mattori Birnbaum - me [at] mattori [dot] com - https://mattori.com +Piotr Wojciech Dabrowski - piotr.dabrowski [at] htw-berlin [dot] de diff --git a/khal/cli.py b/khal/cli.py index baf8c0090..290073e58 100644 --- a/khal/cli.py +++ b/khal/cli.py @@ -176,6 +176,7 @@ def build_collection(conf, selection): 'priority': cal['priority'], 'ctype': cal['type'], 'addresses': cal['addresses'], + 'address_adapter': cal['address_adapter'] } collection = khalendar.CalendarCollection( calendars=props, diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 9f7339521..6c5c19dce 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -25,6 +25,7 @@ import datetime as dt import logging import os +import re from typing import Dict, List, Optional, Tuple, Type, Union import icalendar @@ -43,6 +44,29 @@ logger = logging.getLogger('khal') +class Attendee: + def __init__(self, defline): + m = re.match(r"(?P.*)\<(?P.*)\>", defline) + if m is not None and m.group("name") is not None and m.group("mail") is not None: + self.cn = m.group("name").strip() + self.mail = m.group("mail").strip().lower() + else: + self.cn = None + self.mail = defline.strip().lower() + + @staticmethod + def format_vcard(vcard): + data = str(vcard).split(":") + if len(data) > 1: + mail = data[1] + else: + mail = str(vcard) + cn = mail + if "CN" in vcard.params: + cn = vcard.params["CN"] + return f"{cn} <{mail}>" + + class Event: """base Event class for representing a *recurring instance* of an Event @@ -493,28 +517,29 @@ def update_location(self, location: str) -> None: def attendees(self) -> str: addresses = self._vevents[self.ref].get('ATTENDEE', []) if not isinstance(addresses, list): - addresses = [addresses, ] - return ", ".join([address.split(':')[-1] - for address in addresses]) + return addresses + return ", ".join([Attendee.format_vcard(address) for address in addresses]) def update_attendees(self, attendees: List[str]): assert isinstance(attendees, list) - attendees = [a.strip().lower() for a in attendees if a != ""] - if len(attendees) > 0: + attendees_o : List[Attendee] = [Attendee(a) for a in attendees if a != ""] + if len(attendees_o) > 0: # first check for overlaps in existing attendees. # Existing vCalAddress objects will be copied, non-existing # vCalAddress objects will be created and appended. old_attendees = self._vevents[self.ref].get('ATTENDEE', []) unchanged_attendees = [] vCalAddresses = [] - for attendee in attendees: + for attendee in attendees_o: for old_attendee in old_attendees: old_email = old_attendee.lstrip("MAILTO:").lower() - if attendee == old_email: + if attendee.mail == old_email: vCalAddresses.append(old_attendee) unchanged_attendees.append(attendee) - for attendee in [a for a in attendees if a not in unchanged_attendees]: - item = icalendar.prop.vCalAddress(f'MAILTO:{attendee}') + for attendee in [a for a in attendees_o if a not in unchanged_attendees]: + item = icalendar.prop.vCalAddress(f'MAILTO:{attendee.mail}') + if attendee.cn is not None: + item.params['CN'] = attendee.cn item.params['ROLE'] = icalendar.prop.vText('REQ-PARTICIPANT') item.params['PARTSTAT'] = icalendar.prop.vText('NEEDS-ACTION') item.params['CUTYPE'] = icalendar.prop.vText('INDIVIDUAL') diff --git a/khal/khalendar/khalendar.py b/khal/khalendar/khalendar.py index 21c85ae8d..c0103d21a 100644 --- a/khal/khalendar/khalendar.py +++ b/khal/khalendar/khalendar.py @@ -30,6 +30,9 @@ import logging import os import os.path +import re +import subprocess +from subprocess import CalledProcessError from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union # noqa from ..custom_types import CalendarConfiguration, EventCreationTypes, LocaleConfiguration @@ -98,15 +101,19 @@ def __init__(self, self.hmethod = hmethod self.default_color = default_color + self.default_contacts : List[str] = [] self.multiple = multiple self.multiple_on_overflow = multiple_on_overflow self.color = color self.priority = priority self.highlight_event_days = highlight_event_days self._locale = locale + self._contacts: Dict[str, List[str]] = {} # List of mail addresses of contacts self._backend = backend.SQLiteDb(self.names, dbpath, self._locale) self._last_ctags: Dict[str, str] = {} self.update_db() + for cname in self._calendars.keys(): + self._contacts_update(cname) @property def writable_names(self) -> List[str]: @@ -361,12 +368,40 @@ def _needs_update(self, calendar: str, remember: bool=False) -> bool: self._last_ctags[calendar] = local_ctag return local_ctag != self._backend.get_ctag(calendar) + def _contacts_update(self, calendar: str) -> None: + if self._calendars[calendar].get('address_adapter') is None: + self._contacts[calendar] = [] + adaptercommand = str(self._calendars[calendar].get('address_adapter')) + if adaptercommand == "default": + self._contacts[calendar] = self.default_contacts + else: + self._contacts[calendar] = CalendarCollection._get_contacts(adaptercommand) + + @staticmethod + def _get_contacts(adaptercommand: str) -> List[str]: + # Check for both None and "None" because ConfigObj likes to stringify + # configuration + if adaptercommand is None or adaptercommand == "None": + return [] + try: + res = subprocess.check_output(["bash", "-c", adaptercommand]) + maildata = [re.split(r"\s{2,}", x) for x in res.decode("utf-8").split("\n")] + mails = [f"{x[0]} <{x[2]}>" for x in maildata if len(x) > 1] + return mails + except CalledProcessError: + return [] + + def update_default_contacts(self, command: str) -> None: + self.default_contacts.clear() + self.default_contacts += CalendarCollection._get_contacts(command) + def _db_update(self, calendar: str) -> None: """implements the actual db update on a per calendar base""" local_ctag = self._local_ctag(calendar) db_hrefs = {href for href, etag in self._backend.list(calendar)} storage_hrefs: Set[str] = set() bdays = self._calendars[calendar].get('ctype') == 'birthdays' + self._contacts_update(calendar) with self._backend.at_once(): for href, etag in self._storages[calendar].list(): diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec index 676d51032..90c08c859 100644 --- a/khal/settings/khal.spec +++ b/khal/settings/khal.spec @@ -76,6 +76,21 @@ type = option('calendar', 'birthdays', 'discover', default='calendar') # belongs to the user. addresses = force_list(default='') +# The address adapter is a command that will be run using "bash -c" to get +# a list of addresses for autocompletion in the attendee field of a new event +# in interactive mode. This expects the same output as "khard email | tail -n +2" +# since it has only been developed with khard in mind - but other providers +# should be configurable to create the same output, or the parser could +# be extended (see _contacts_update in CalenderCollection in khalendar.py). +# The following options are possible: +# +# * None: No autocompletion for this calendar +# * "default": Use the autocompletion addresses from address_adapter in the +# [default] configuration section (default) +# * "": Use the output of as contacts for +# autocompletion +address_adapter = string(default="default") + [sqlite] # khal stores its internal caching database here, by default this will be in the *$XDG_DATA_HOME/khal/khal.db* (this will most likely be *~/.local/share/khal/khal.db*). path = expand_db_path(default=None) @@ -231,6 +246,19 @@ default_dayevent_alarm = timedelta(default='') # 'ikhal' only) enable_mouse = boolean(default=True) +# The address adapter is a command that will be run using "bash -c" to get +# a list of addresses for autocompletion in the attendee field of a new event +# in interactive mode. The address_adapter defined in this section will be used +# for auto-completion of attendees in all calendars that have their +# address_adapter set to "default". +# The following options are possible: +# +# * None: No autocompletion +# * "": Use the output of as contacts for +# autocompletion. Default: +# "khard email | tail -n +2" +address_adapter = string(default="khard email | tail -n +2") + # The view section contains configuration options that effect the visual appearance # when using khal and ikhal. diff --git a/khal/settings/utils.py b/khal/settings/utils.py index 4dac92987..57714de86 100644 --- a/khal/settings/utils.py +++ b/khal/settings/utils.py @@ -43,7 +43,7 @@ from ..terminal import COLORS from .exceptions import InvalidSettingsError -logger = logging.getLogger('khal') +logger = logging.getLogger("khal") def is_timezone(tzstring: Optional[str]) -> dt.tzinfo: @@ -69,35 +69,36 @@ def is_timedelta(string: str) -> dt.timedelta: raise VdtValueError(f"Invalid timedelta: {string}") -def weeknumber_option(option: str) -> Union[Literal['left', 'right'], Literal[False]]: +def weeknumber_option(option: str) -> Union[Literal["left", "right"], Literal[False]]: """checks if *option* is a valid value :param option: the option the user set in the config file :returns: 'off', 'left', 'right' or False """ option = option.lower() - if option == 'left': - return 'left' - elif option == 'right': - return 'right' - elif option in ['off', 'false', '0', 'no', 'none']: + if option == "left": + return "left" + elif option == "right": + return "right" + elif option in ["off", "false", "0", "no", "none"]: return False else: raise VdtValueError( f"Invalid value '{option}' for option 'weeknumber', must be one of " - "'off', 'left' or 'right'") + "'off', 'left' or 'right'" + ) -def monthdisplay_option(option: str) -> Literal['firstday', 'firstfullweek']: +def monthdisplay_option(option: str) -> Literal["firstday", "firstfullweek"]: """checks if *option* is a valid value :param option: the option the user set in the config file """ option = option.lower() - if option == 'firstday': - return 'firstday' - elif option == 'firstfullweek': - return 'firstfullweek' + if option == "firstday": + return "firstday" + elif option == "firstfullweek": + return "firstfullweek" else: raise VdtValueError( f"Invalid value '{option}' for option 'monthdisplay', must be one " @@ -113,7 +114,7 @@ def expand_path(path: str) -> str: def expand_db_path(path: str) -> str: """expands `~` as well as variable names, defaults to $XDG_DATA_HOME""" if path is None: - path = join(xdg.BaseDirectory.xdg_data_home, 'khal', 'khal.db') + path = join(xdg.BaseDirectory.xdg_data_home, "khal", "khal.db") return expanduser(expandvars(path)) @@ -128,11 +129,16 @@ def is_color(color: str) -> str: # 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:]))): + 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) @@ -141,27 +147,27 @@ def test_default_calendar(config) -> None: """test if config['default']['default_calendar'] is set to a sensible value """ - if config['default']['default_calendar'] is None: + if config["default"]["default_calendar"] is None: pass - elif config['default']['default_calendar'] not in config['calendars']: + elif config["default"]["default_calendar"] not in config["calendars"]: logger.fatal( f"in section [default] {config['default']['default_calendar']} is " "not valid for 'default_calendar', must be one of " f"{config['calendars'].keys()}" ) raise InvalidSettingsError() - elif config['calendars'][config['default']['default_calendar']]['readonly']: - logger.fatal('default_calendar may not be read_only!') + elif config["calendars"][config["default"]["default_calendar"]]["readonly"]: + logger.fatal("default_calendar may not be read_only!") raise InvalidSettingsError() def get_color_from_vdir(path: str) -> Optional[str]: try: - color = Vdir(path, '.ics').get_meta('color') + color = Vdir(path, ".ics").get_meta("color") except CollectionNotFoundError: color = None - if color is None or color == '': - logger.debug(f'Found no or empty file `color` in {path}') + if color is None or color == "": + logger.debug(f"Found no or empty file `color` in {path}") return None color = color.strip() try: @@ -175,26 +181,25 @@ def get_color_from_vdir(path: str) -> Optional[str]: def get_unique_name(path: str, names: Iterable[str]) -> str: # TODO take care of edge cases, make unique name finding less brain-dead try: - name = Vdir(path, '.ics').get_meta('displayname') + name = Vdir(path, ".ics").get_meta("displayname") except CollectionNotFoundError: - logger.fatal(f'The calendar at `{path}` is not a directory.') + logger.fatal(f"The calendar at `{path}` is not a directory.") raise - if name is None or name == '': - logger.debug(f'Found no or empty file `displayname` in {path}') + if name is None or name == "": + logger.debug(f"Found no or empty file `displayname` in {path}") name = os.path.split(path)[-1] if name in names: while name in names: - name = name + '1' + name = name + "1" return name def get_all_vdirs(expand_path: str) -> Iterable[str]: - """returns a list of paths, expanded using glob - """ + """returns a list of paths, expanded using glob""" # FIXME currently returns a list of all directories in path # we add an additional / at the end to make sure we are only getting # directories - items = glob.glob(f'{expand_path}/', recursive=True) + items = glob.glob(f"{expand_path}/", recursive=True) paths = [pathlib.Path(item) for item in sorted(items, key=len, reverse=True)] leaves = set() parents = set() @@ -211,72 +216,80 @@ def get_all_vdirs(expand_path: str) -> Iterable[str]: def get_vdir_type(_: str) -> str: # TODO implement - return 'calendar' + return "calendar" + def validate_palette_entry(attr, definition: str) -> bool: if len(definition) not in (2, 3, 5): - logging.error('Invalid color definition for %s: %s, must be of length, 2, 3, or 5', - attr, definition) + logging.error( + "Invalid color definition for %s: %s, must be of length, 2, 3, or 5", attr, definition + ) return False - if (definition[0] not in COLORS and definition[0] != '') or \ - (definition[1] not in COLORS and definition[1] != ''): - logging.error('Invalid color definition for %s: %s, must be one of %s', - attr, definition, COLORS.keys()) + if (definition[0] not in COLORS and definition[0] != "") or ( + definition[1] not in COLORS and definition[1] != "" + ): + logging.error( + "Invalid color definition for %s: %s, must be one of %s", + attr, + definition, + COLORS.keys(), + ) return False return True + def config_checks( config, - _get_color_from_vdir: Callable=get_color_from_vdir, - _get_vdir_type: Callable=get_vdir_type, + _get_color_from_vdir: Callable = get_color_from_vdir, + _get_vdir_type: Callable = get_vdir_type, ) -> None: """do some tests on the config we cannot do with configobj's validator""" # TODO rename or split up, we are also expanding vdirs of type discover - if len(config['calendars'].keys()) < 1: - logger.fatal('Found no calendar section in the config file') + 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']) + config["sqlite"]["path"] = expand_db_path(config["sqlite"]["path"]) + if not config["locale"]["default_timezone"]: + config["locale"]["default_timezone"] = is_timezone(config["locale"]["default_timezone"]) + if not config["locale"]["local_timezone"]: + config["locale"]["local_timezone"] = is_timezone(config["locale"]["local_timezone"]) # expand calendars with type = discover # we need a copy of config['calendars'], because we modify config in the body of the loop - for cname, cconfig in sorted(config['calendars'].items()): - if not isinstance(config['calendars'][cname], dict): - logger.fatal('Invalid config file, probably missing calendar sections') + for cname, cconfig in sorted(config["calendars"].items()): + if not isinstance(config["calendars"][cname], dict): + logger.fatal("Invalid config file, probably missing calendar sections") raise InvalidSettingsError - if config['calendars'][cname]['type'] == 'discover': + if config["calendars"][cname]["type"] == "discover": logger.debug(f"discovering calendars in {cconfig['path']}") - vdirs_discovered = get_all_vdirs(cconfig['path']) + vdirs_discovered = get_all_vdirs(cconfig["path"]) logger.debug(f"found the following vdirs: {vdirs_discovered}") for vdir in vdirs_discovered: vdir_config = { - 'path': vdir, - 'color': _get_color_from_vdir(vdir) or cconfig.get('color', None), - 'type': _get_vdir_type(vdir), - 'readonly': cconfig.get('readonly', False), - 'priority': 10, + "path": vdir, + "color": _get_color_from_vdir(vdir) or cconfig.get("color", None), + "type": _get_vdir_type(vdir), + "readonly": cconfig.get("readonly", False), + "priority": 10, + "address_adapter": cconfig.get("address_adapter", None), } - unique_vdir_name = get_unique_name(vdir, config['calendars'].keys()) - config['calendars'][unique_vdir_name] = vdir_config - config['calendars'].pop(cname) + unique_vdir_name = get_unique_name(vdir, config["calendars"].keys()) + config["calendars"][unique_vdir_name] = vdir_config + config["calendars"].pop(cname) test_default_calendar(config) - for calendar in config['calendars']: - if config['calendars'][calendar]['type'] == 'birthdays': - config['calendars'][calendar]['readonly'] = True - if config['calendars'][calendar]['color'] == 'auto': - config['calendars'][calendar]['color'] = \ - _get_color_from_vdir(config['calendars'][calendar]['path']) + for calendar in config["calendars"]: + if config["calendars"][calendar]["type"] == "birthdays": + config["calendars"][calendar]["readonly"] = True + if config["calendars"][calendar]["color"] == "auto": + config["calendars"][calendar]["color"] = _get_color_from_vdir( + config["calendars"][calendar]["path"] + ) # check palette settings valid_palette = True - for attr in config.get('palette', []): - valid_palette = valid_palette and validate_palette_entry(attr, config['palette'][attr]) + for attr in config.get("palette", []): + valid_palette = valid_palette and validate_palette_entry(attr, config["palette"][attr]) if not valid_palette: - logger.fatal('Invalid palette entry') + logger.fatal("Invalid palette entry") raise InvalidSettingsError() diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 870577294..9919b0ff9 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -1065,6 +1065,8 @@ def __init__(self, collection, conf=None, title: str='', description: str='') -> self._conf = conf self.collection = collection self._deleted: Dict[int, List[str]] = {DeletionType.ALL: [], DeletionType.INSTANCES: []} + if conf is not None: + self.collection.update_default_contacts(self._conf['default']['address_adapter']) ContainerWidget = linebox[self._conf['view']['frame']] if self._conf['view']['dynamic_days']: diff --git a/khal/ui/attendeewidget.py b/khal/ui/attendeewidget.py new file mode 100644 index 000000000..3bbd7413a --- /dev/null +++ b/khal/ui/attendeewidget.py @@ -0,0 +1,156 @@ +import urwid +from additional_urwid_widgets import IndicativeListBox + + +class MailPopup(urwid.PopUpLauncher): + command_map = urwid.CommandMap() + own_commands = ["cursor left", "cursor right", "cursor max left", "cursor max right"] + + def __init__(self, widget, maillist): + self.maillist = maillist + self.widget = widget + self.popup_visible = False + self.justcompleted = False + super().__init__(widget) + + def change_mail_list(self, mails): + self.maillist = mails + + def get_current_mailpart(self): + mails = self.widget.get_edit_text().split(",") + lastmail = mails[-1].lstrip(" ") + return lastmail + + def complete_mail(self, newmail): + mails = [x.strip() for x in self.widget.get_edit_text().split(",")[:-1]] + mails += [newmail] + return ", ".join(mails) + + def get_num_mails(self): + mails = self.widget.get_edit_text().split(",") + return len(mails) + + def keypress(self, size, key): + cmd = self.command_map[key] + if cmd is not None and cmd not in self.own_commands and key != " ": + return key + if self.justcompleted and key not in ", ": + self.widget.keypress(size, ",") + self.widget.keypress(size, " ") + self.widget.keypress(size, key) + self.justcompleted = False + if not self.popup_visible: + # Only open the popup list if there will be at least 1 address displayed + current = self.get_current_mailpart() + if len([x for x in self.maillist if current.lower() in x.lower()]) == 0: + return + if len(current) == 0: + return + self.open_pop_up() + self.popup_visible = True + + def keycallback(self, size, key): + cmd = self.command_map[key] + if cmd == "menu": + self.popup_visible = False + self.close_pop_up() + self.widget.keypress((20,), key) + self.justcompleted = False + cmp = self.get_current_mailpart() + num_candidates = self.listbox.update_mails(cmp) + if num_candidates == 0 or len(cmp) == 0: + self.popup_visible = False + self.close_pop_up() + + def donecallback(self, text): + self.widget.set_edit_text(self.complete_mail(text)) + fulllength = len(self.widget.get_edit_text()) + self.widget.move_cursor_to_coords((fulllength,), fulllength, 0) + self.close_pop_up() + self.popup_visible = False + self.justcompleted = True + + def create_pop_up(self): + current_mailpart = self.get_current_mailpart() + self.listbox = MailListBox( + self.maillist, self.keycallback, self.donecallback, current_mailpart + ) + return urwid.WidgetWrap(self.listbox) + + def get_pop_up_parameters(self): + return {"left": 0, "top": 1, "overlay_width": 60, "overlay_height": 10} + + def render(self, size, focus=False): + return super().render(size, True) + + +class MailListItem(urwid.Text): + def render(self, size, focus=False): + return super().render(size, False) + + def selectable(self): + return True + + def keypress(self, size, key): + return key + + +class MailListBox(IndicativeListBox): + command_map = urwid.CommandMap() + own_commands = [urwid.CURSOR_DOWN, urwid.CURSOR_UP, urwid.ACTIVATE] + + def __init__(self, mails, keycallback, donecallback, current_mailpart, **args): + self.mails = [MailListItem(x) for x in mails] + mailsBody = [urwid.AttrMap(x, None, "list focused") for x in self.mails] + self.keycallback = keycallback + self.donecallback = donecallback + super().__init__(mailsBody, **args) + if len(current_mailpart) != 0: + self.update_mails(current_mailpart) + + def keypress(self, size, key): + cmd = self.command_map[key] + if cmd not in self.own_commands or key == " ": + self.keycallback(size, key) + elif cmd is urwid.ACTIVATE: + self.donecallback(self.get_selected_item()._original_widget.get_text()[0]) + else: + super().keypress(size, key) + + def update_mails(self, new_edit_text): + new_body = [] + for mail in self.mails: + if new_edit_text.lower() in mail.get_text()[0].lower(): + new_body += [urwid.AttrMap(mail, None, "list focused")] + self.set_body(new_body) + return len(new_body) + + +class AutocompleteEdit(urwid.Edit): + def render(self, size, focus=False): + return super().render(size, True) + + +class AttendeeWidget(urwid.WidgetWrap): + def __init__(self, initial_attendees, mails): + self.mails = mails + if self.mails is None: + self.mails = [] + if initial_attendees is None: + initial_attendees = "" + self.acedit = AutocompleteEdit() + self.acedit.set_edit_text(initial_attendees) + self.mp = MailPopup(self.acedit, self.mails) + super().__init__(self.mp) + + def get_attendees(self): + return self.acedit.get_edit_text() + + def change_mail_list(self, mails): + self.mails = mails + if self.mails is None: + self.mails = [] + self.mp.change_mail_list(mails) + + def get_edit_text(self): + return self.acedit.get_edit_text() diff --git a/khal/ui/editor.py b/khal/ui/editor.py index b4404f409..70dfb05c5 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -25,6 +25,7 @@ import urwid from ..utils import get_weekday_occurrence, get_wrapped_text +from .attendeewidget import AttendeeWidget from .widgets import ( AlarmsEditor, CalendarWidget, @@ -46,8 +47,8 @@ if TYPE_CHECKING: import khal.khalendar.event -class StartEnd: +class StartEnd: def __init__(self, startdate, starttime, enddate, endtime) -> None: """collecting some common properties""" self.startdate = startdate @@ -57,8 +58,15 @@ def __init__(self, startdate, starttime, enddate, endtime) -> None: class CalendarPopUp(urwid.PopUpLauncher): - def __init__(self, widget, on_date_change, weeknumbers: Literal['left', 'right', False]=False, - firstweekday=0, monthdisplay='firstday', keybindings=None) -> None: + def __init__( + self, + widget, + on_date_change, + weeknumbers: Literal["left", "right", False] = False, + firstweekday=0, + monthdisplay="firstday", + keybindings=None, + ) -> None: self._on_date_change = on_date_change self._weeknumbers = weeknumbers self._monthdisplay = monthdisplay @@ -67,7 +75,7 @@ def __init__(self, widget, on_date_change, weeknumbers: Literal['left', 'right', self.__super.__init__(widget) def keypress(self, size, key): - if key == 'enter': + if key == "enter": self.open_pop_up() else: return super().keypress(size, key) @@ -77,26 +85,31 @@ 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()} + 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, + on_change, + self._keybindings, + on_press, firstweekday=self._firstweekday, weeknumbers=self._weeknumbers, monthdisplay=self._monthdisplay, - initial=initial_date) - pop_up = CAttrMap(pop_up, 'calendar', ' calendar focus') - pop_up = CAttrMap(urwid.LineBox(pop_up), 'calendar', 'calendar focus') + initial=initial_date, + ) + pop_up = CAttrMap(pop_up, "calendar", " calendar focus") + pop_up = CAttrMap(urwid.LineBox(pop_up), "calendar", "calendar focus") return pop_up def get_pop_up_parameters(self): - width = 31 if self._weeknumbers == 'right' else 28 - return {'left': 0, 'top': 1, 'overlay_width': width, 'overlay_height': 8} + width = 31 if self._weeknumbers == "right" else 28 + return {"left": 0, "top": 1, "overlay_width": width, "overlay_height": 8} class DateEdit(urwid.WidgetWrap): @@ -109,11 +122,11 @@ class DateEdit(urwid.WidgetWrap): def __init__( self, startdt: dt.date, - dateformat: str='%Y-%m-%d', - on_date_change: Callable=lambda _: None, - weeknumbers: Literal['left', 'right', False]=False, - firstweekday: int=0, - monthdisplay: Literal['firstday', 'firstfullweek']='firstday', + dateformat: str = "%Y-%m-%d", + on_date_change: Callable = lambda _: None, + weeknumbers: Literal["left", "right", False] = False, + firstweekday: int = 0, + monthdisplay: Literal["firstday", "firstfullweek"] = "firstday", keybindings: Optional[Dict[str, List[str]]] = None, ) -> None: datewidth = len(startdt.strftime(dateformat)) + 1 @@ -125,12 +138,15 @@ def __init__( EditWidget=DateWidget, validate=self._validate, edit_text=startdt.strftime(dateformat), - on_date_change=on_date_change) - wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers, - firstweekday, monthdisplay, keybindings) + on_date_change=on_date_change, + ) + wrapped = CalendarPopUp( + self._edit, on_date_change, weeknumbers, firstweekday, monthdisplay, keybindings + ) padded = CAttrMap( - urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1), - 'calendar', 'calendar focus', + urwid.Padding(wrapped, align="left", width=datewidth, left=0, right=1), + "calendar", + "calendar focus", ) super().__init__(padded) @@ -163,14 +179,15 @@ def date(self, date): class StartEndEditor(urwid.WidgetWrap): """Widget for editing start and end times (of an event).""" - def __init__(self, - start: dt.datetime, - end: dt.datetime, - conf, - on_start_date_change=lambda x: None, - on_end_date_change=lambda x: None, - on_type_change: Callable[[bool], None]=lambda _: None, - ) -> None: + def __init__( + self, + start: dt.datetime, + end: dt.datetime, + conf, + on_start_date_change=lambda x: None, + on_end_date_change=lambda x: None, + on_type_change: Callable[[bool], None] = lambda _: None, + ) -> None: """ :param on_start_date_change: a callable that gets called everytime a new start date is entered, with that new date as an argument @@ -189,12 +206,11 @@ def __init__(self, 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'])) + 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.checkallday = urwid.CheckBox("Allday", state=self.allday, on_state_change=self.toggle) self.toggle(None, self.allday) def keypress(self, size, key): @@ -216,15 +232,15 @@ def _start_time(self): @property def localize_start(self): - if getattr(self.startdt, 'tzinfo', None) is None: - return self.conf['locale']['default_timezone'].localize + 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 + if getattr(self.enddt, "tzinfo", None) is None: + return self.conf["locale"]["default_timezone"].localize else: return self.enddt.tzinfo.localize @@ -244,9 +260,10 @@ def _end_time(self): def _validate_start_time(self, text): try: - startval = dt.datetime.strptime(text, self.conf['locale']['timeformat']) + startval = dt.datetime.strptime(text, self.conf["locale"]["timeformat"]) self._startdt = self.localize_start( - dt.datetime.combine(self._startdt.date(), startval.time())) + dt.datetime.combine(self._startdt.date(), startval.time()) + ) except ValueError: return False else: @@ -258,7 +275,7 @@ def _start_date_change(self, date): def _validate_end_time(self, text): try: - endval = dt.datetime.strptime(text, self.conf['locale']['timeformat']) + endval = dt.datetime.strptime(text, self.conf["locale"]["timeformat"]) self._enddt = self.localize_end(dt.datetime.combine(self._enddt.date(), endval.time())) except ValueError: return False @@ -289,55 +306,74 @@ def toggle(self, checkbox, state: bool): self._enddt = self._enddt.date() self.allday = state self.widgets.startdate = DateEdit( - self._startdt, self.conf['locale']['longdateformat'], - self._start_date_change, self.conf['locale']['weeknumbers'], - self.conf['locale']['firstweekday'], - self.conf['view']['monthdisplay'], - self.conf['keybindings'], + self._startdt, + self.conf["locale"]["longdateformat"], + self._start_date_change, + self.conf["locale"]["weeknumbers"], + self.conf["locale"]["firstweekday"], + self.conf["view"]["monthdisplay"], + self.conf["keybindings"], ) self.widgets.enddate = DateEdit( - self._enddt, self.conf['locale']['longdateformat'], - self._end_date_change, self.conf['locale']['weeknumbers'], - self.conf['locale']['firstweekday'], - self.conf['view']['monthdisplay'], - self.conf['keybindings'], + self._enddt, + self.conf["locale"]["longdateformat"], + self._end_date_change, + self.conf["locale"]["weeknumbers"], + self.conf["locale"]["firstweekday"], + self.conf["view"]["monthdisplay"], + self.conf["keybindings"], ) if state is True: # allday event self.on_type_change(True) timewidth = 1 - self.widgets.starttime = urwid.Text('') - self.widgets.endtime = urwid.Text('') + self.widgets.starttime = urwid.Text("") + self.widgets.endtime = urwid.Text("") elif state is False: # datetime event self.on_type_change(False) timewidth = self._timewidth + 1 raw_start_time_widget = ValidatedEdit( - dateformat=self.conf['locale']['timeformat'], + dateformat=self.conf["locale"]["timeformat"], EditWidget=TimeWidget, validate=self._validate_start_time, - edit_text=self.startdt.strftime(self.conf['locale']['timeformat']), + edit_text=self.startdt.strftime(self.conf["locale"]["timeformat"]), ) self.widgets.starttime = urwid.Padding( - raw_start_time_widget, align='left', width=self._timewidth + 1, left=1) + raw_start_time_widget, align="left", width=self._timewidth + 1, left=1 + ) raw_end_time_widget = ValidatedEdit( - dateformat=self.conf['locale']['timeformat'], + dateformat=self.conf["locale"]["timeformat"], EditWidget=TimeWidget, validate=self._validate_end_time, - edit_text=self.enddt.strftime(self.conf['locale']['timeformat']), + edit_text=self.enddt.strftime(self.conf["locale"]["timeformat"]), ) self.widgets.endtime = urwid.Padding( - raw_end_time_widget, align='left', width=self._timewidth + 1, left=1) - - columns = NPile([ - self.checkallday, - NColumns([(5, urwid.Text('From:')), (self._datewidth, self.widgets.startdate), ( - timewidth, self.widgets.starttime)], dividechars=1), - NColumns( - [(5, urwid.Text('To:')), (self._datewidth, self.widgets.enddate), - (timewidth, self.widgets.endtime)], - dividechars=1) - ], focus_item=1) + raw_end_time_widget, align="left", width=self._timewidth + 1, left=1 + ) + + columns = NPile( + [ + self.checkallday, + NColumns( + [ + (5, urwid.Text("From:")), + (self._datewidth, self.widgets.startdate), + (timewidth, self.widgets.starttime), + ], + dividechars=1, + ), + NColumns( + [ + (5, urwid.Text("To:")), + (self._datewidth, self.widgets.enddate), + (timewidth, self.widgets.endtime), + ], + dividechars=1, + ), + ], + focus_item=1, + ) urwid.WidgetWrap.__init__(self, columns) @property @@ -355,9 +391,9 @@ class EventEditor(urwid.WidgetWrap): def __init__( self, pane, - event: 'khal.khalendar.event.Event', + event: "khal.khalendar.event.Event", save_callback=None, - always_save: bool=False, + always_save: bool = False, ) -> None: """ :param save_callback: call when saving event with new start and end @@ -380,81 +416,109 @@ def __init__( self.categories = event.categories self.url = event.url self.startendeditor = StartEndEditor( - event.start_local, event.end_local, self._conf, - self.start_datechange, self.end_datechange, + event.start_local, + event.end_local, + self._conf, + self.start_datechange, + self.end_datechange, self.type_change, ) # TODO make sure recurrence rules cannot be edited if we only # edit one instance (or this and future) (once we support that) self.recurrenceeditor = RecurrenceEditor( - self.event.recurobject, self._conf, event.start_local, + self.event.recurobject, + self._conf, + event.start_local, ) - self.summary = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Title: '), edit_text=event.summary), 'edit', 'edit focus', + self.summary = urwid.AttrMap( + ExtendedEdit(caption=("caption", "Title: "), edit_text=event.summary), + "edit", + "edit focus", ) - divider = urwid.Divider(' ') + divider = urwid.Divider(" ") def decorate_choice(c) -> Tuple[str, str]: - return ('calendar ' + c['name'] + ' popup', c['name']) - - self.calendar_chooser= CAttrMap(Choice( - [self.collection._calendars[c] for c in self.collection.writable_names], - self.collection._calendars[self.event.calendar], - decorate_choice - ), 'caption') + return ("calendar " + c["name"] + " popup", c["name"]) + + self.calendar_chooser = CAttrMap( + Choice( + [self.collection._calendars[c] for c in self.collection.writable_names], + self.collection._calendars[self.event.calendar], + decorate_choice, + callback=self.account_change, + ), + "caption", + ) self.description = urwid.AttrMap( ExtendedEdit( - caption=('caption', 'Description: '), - edit_text=self.description, - multiline=True + caption=("caption", "Description: "), edit_text=self.description, multiline=True ), - 'edit', 'edit focus', + "edit", + "edit focus", ) - self.location = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Location: '), edit_text=self.location), 'edit', 'edit focus', + self.location = urwid.AttrMap( + ExtendedEdit(caption=("caption", "Location: "), edit_text=self.location), + "edit", + "edit focus", ) - self.categories = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Categories: '), edit_text=self.categories), 'edit', 'edit focus', + self.categories = urwid.AttrMap( + ExtendedEdit(caption=("caption", "Categories: "), edit_text=self.categories), + "edit", + "edit focus", ) self.attendees = urwid.AttrMap( - ExtendedEdit( - caption=('caption', 'Attendees: '), - edit_text=self.attendees, - multiline=True - ), - 'edit', 'edit focus', + AttendeeWidget(self.event.attendees, self.collection._contacts[self.event.calendar]), + "edit", + "edit focus", ) - self.url = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'URL: '), edit_text=self.url), 'edit', 'edit focus', + self.url = urwid.AttrMap( + ExtendedEdit(caption=("caption", "URL: "), edit_text=self.url), + "edit", + "edit focus", ) self.alarmseditor: AlarmsEditor = AlarmsEditor(self.event) - self.pile = NListBox(urwid.SimpleFocusListWalker([ - self.summary, - urwid.Columns([(13, urwid.AttrMap(urwid.Text('Calendar:'), 'caption')), - (12, self.calendar_chooser)], - ), - divider, - self.location, - self.categories, - self.description, - self.url, - divider, - self.attendees, - divider, - self.startendeditor, - self.recurrenceeditor, - divider, - self.alarmseditor, - divider, - urwid.Columns( - [(12, button('Save', on_press=self.save, padding_left=0, padding_right=0))] + self.pile = NListBox( + urwid.SimpleFocusListWalker( + [ + self.summary, + urwid.Columns( + [ + (13, urwid.AttrMap(urwid.Text("Calendar:"), "caption")), + (12, self.calendar_chooser), + ], + ), + divider, + self.location, + self.categories, + self.description, + self.url, + divider, + self.attendees, + divider, + self.startendeditor, + self.recurrenceeditor, + divider, + self.alarmseditor, + divider, + urwid.Columns( + [(12, button("Save", on_press=self.save, padding_left=0, padding_right=0))] + ), + urwid.Columns( + [ + ( + 12, + button( + "Export", on_press=self.export, padding_left=0, padding_right=0 + ), + ) + ], + ), + ] ), - urwid.Columns( - [(12, button('Export', on_press=self.export, padding_left=0, padding_right=0))], - ) - ]), outermost=True) + outermost=True, + ) self._always_save = always_save urwid.WidgetWrap.__init__(self, self.pile) @@ -471,13 +535,13 @@ def type_change(self, allday: bool) -> None: :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'): + 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'] + 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] @@ -491,9 +555,15 @@ def type_change(self, allday: bool) -> None: # either there were more than one alarm or the alarm was not the default pass + def account_change(self): + newaccount = self.calendar_chooser._original_widget.active + self.attendees._original_widget.change_mail_list( + self.collection._contacts[newaccount["name"]] + ) + @property def title(self): # Window title - return f'Edit: {get_wrapped_text(self.summary)}' + return f"Edit: {get_wrapped_text(self.summary)}" @classmethod def selectable(cls): @@ -525,13 +595,12 @@ def update_vevent(self): self.event.update_summary(get_wrapped_text(self.summary)) self.event.update_description(get_wrapped_text(self.description)) self.event.update_location(get_wrapped_text(self.location)) - self.event.update_attendees(get_wrapped_text(self.attendees).split(',')) - self.event.update_categories(get_wrapped_text(self.categories).split(',')) + self.event.update_attendees(self.attendees._original_widget.get_attendees().split(",")) + self.event.update_categories(get_wrapped_text(self.categories).split(",")) self.event.update_url(get_wrapped_text(self.url)) if self.startendeditor.changed: - self.event.update_start_end( - self.startendeditor.startdt, self.startendeditor.enddt) + self.event.update_start_end(self.startendeditor.startdt, self.startendeditor.enddt) if self.recurrenceeditor.changed: rrule = self.recurrenceeditor.active self.event.update_rrule(rrule) @@ -544,20 +613,17 @@ 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)) + 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')) + self.pane.window.alert(("light green", "Event successfuly exported")) overlay = urwid.Overlay( ExportDialog( @@ -566,7 +632,11 @@ def export_this(_, user_data): self.event, ), self.pane, - 'center', ('relative', 50), ('relative', 50), None) + "center", + ("relative", 50), + ("relative", 50), + None, + ) self.pane.window.open(overlay) def save(self, button): @@ -576,8 +646,7 @@ def save(self, button): :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!")) + 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: @@ -585,43 +654,39 @@ def save(self, button): self.event.allday = self.startendeditor.allday self.event.increment_sequence() if self.event.etag is None: # has not been saved before - self.event.calendar = self.calendar_chooser.original_widget.active['name'] + self.event.calendar = self.calendar_chooser.original_widget.active["name"] self.collection.insert(self.event) elif self.calendar_chooser.changed: - self.collection.change_collection( - self.event, - self.calendar_chooser.active['name'] - ) + 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.start_local, + self.event.end_local, self.event.recurring or self.recurrenceeditor.changed, ) self._abort_confirmed = False self.pane.window.backtrack() def keypress(self, size: Tuple[int], key: str) -> Optional[str]: - if key in ['esc'] and self.changed and not self._abort_confirmed: - self.pane.window.alert( - ('light red', 'Unsaved changes! Hit ESC again to discard.')) + if key in ["esc"] and self.changed and not self._abort_confirmed: + self.pane.window.alert(("light red", "Unsaved changes! Hit ESC again to discard.")) self._abort_confirmed = True return None else: self._abort_confirmed = False - if key in self.pane._conf['keybindings']['save']: + if key in self.pane._conf["keybindings"]["save"]: self.save(None) return None return super().keypress(size, key) -WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] # TODO use locale and respect weekdaystart +WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] # TODO use locale and respect weekdaystart class WeekDaySelector(urwid.WidgetWrap): def __init__(self, startdt, selected_days) -> None: - self._weekday_boxes = {day: urwid.CheckBox(day, state=False) for day in WEEKDAYS} weekday = startdt.weekday() self._weekday_boxes[WEEKDAYS[weekday]].state = True @@ -637,7 +702,6 @@ def days(self): class RecurrenceEditor(urwid.WidgetWrap): - def __init__(self, rrule, conf, startdt) -> None: self._conf = conf self._startdt = startdt @@ -645,7 +709,9 @@ def __init__(self, rrule, conf, startdt) -> None: 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, + "Repeat: ", + state=self.repeat, + on_state_change=self.check_repeat, ) if "UNTIL" in self._rrule: @@ -655,24 +721,42 @@ def __init__(self, rrule, conf, startdt) -> None: else: self._until = "Forever" - recurrence = self._rrule['freq'][0].lower() if self._rrule else "weekly" - self.recurrence_choice = CPadding(CAttrMap(Choice( - ["daily", "weekly", "monthly", "yearly"], - recurrence, - callback=self.rebuild, - ), 'popupper'), align='center', left=2, right=2) + recurrence = self._rrule["freq"][0].lower() if self._rrule else "weekly" + self.recurrence_choice = CPadding( + CAttrMap( + Choice( + ["daily", "weekly", "monthly", "yearly"], + recurrence, + callback=self.rebuild, + ), + "popupper", + ), + align="center", + left=2, + right=2, + ) self.interval_edit = PositiveIntEdit( - caption=('caption', 'every:'), - edit_text=str(self._rrule.get('INTERVAL', [1])[0]), + caption=("caption", "every:"), + edit_text=str(self._rrule.get("INTERVAL", [1])[0]), + ) + self.until_choice = CPadding( + CAttrMap( + Choice( + ["Forever", "Until", "Repetitions"], + self._until, + callback=self.rebuild, + ), + "popupper", + ), + align="center", + left=2, + right=2, ) - self.until_choice = CPadding(CAttrMap(Choice( - ["Forever", "Until", "Repetitions"], self._until, callback=self.rebuild, - ), 'popupper'), align='center', left=2, right=2) - count = str(self._rrule.get('COUNT', [1])[0]) + count = str(self._rrule.get("COUNT", [1])[0]) self.repetitions_edit = PositiveIntEdit(edit_text=count) - until = self._rrule.get('UNTIL', [None])[0] + until = self._rrule.get("UNTIL", [None])[0] if until is None and isinstance(self._startdt, dt.datetime): until = self._startdt.date() elif until is None: @@ -681,31 +765,36 @@ def __init__(self, rrule, conf, startdt) -> None: if isinstance(until, dt.datetime): until = until.date() self.until_edit = DateEdit( - until, self._conf['locale']['longdateformat'], - lambda _: None, self._conf['locale']['weeknumbers'], - self._conf['locale']['firstweekday'], - self._conf['view']['monthdisplay'], + until, + self._conf["locale"]["longdateformat"], + lambda _: None, + self._conf["locale"]["weeknumbers"], + self._conf["locale"]["firstweekday"], + self._conf["view"]["monthdisplay"], ) self._rebuild_weekday_checks() self._rebuild_monthly_choice() - self._pile = pile = NPile([urwid.Text('')]) + 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'} + ords = {1: "st", 2: "nd", 3: "rd", 21: "st", 22: "nd", 23: "rd", 31: "st"} self._xth_weekday = f"on every {xth}{ords.get(xth, 'th')} {WEEKDAYS[weekday]}" - self._xth_monthday = (f"on every {self._startdt.day}" - f"{ords.get(self._startdt.day, 'th')} of the month") + self._xth_monthday = ( + f"on every {self._startdt.day}" f"{ords.get(self._startdt.day, 'th')} of the month" + ) self.monthly_choice = Choice( - [self._xth_monthday, self._xth_weekday], self._xth_monthday, callback=self.rebuild, + [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', []) + if self.recurrence_choice.active == "weekly": + initial_days = self._rrule.get("BYDAY", []) else: initial_days = [] self.weekday_checks = WeekDaySelector(self._startdt, initial_days) @@ -720,25 +809,30 @@ def update_startdt(self, startdt): def check_understood_rrule(rrule): """test if we can reproduce `rrule`.""" keys = set(rrule.keys()) - freq = rrule.get('FREQ', [None])[0] + freq = rrule.get("FREQ", [None])[0] unsupported_rrule_parts = { - 'BYSECOND', 'BYMINUTE', 'BYHOUR', 'BYYEARDAY', 'BYWEEKNO', 'BYMONTH', + "BYSECOND", + "BYMINUTE", + "BYHOUR", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", } if keys.intersection(unsupported_rrule_parts): return False - if len(rrule.get('BYMONTHDAY', [1])) > 1: + 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: + if rrule.get("BYMONTHDAY", [1])[0] < 1: return False - if rrule.get('BYDAY', ['1'])[0][0] == '-': + if rrule.get("BYDAY", ["1"])[0][0] == "-": return False - if rrule.get('BYSETPOS', [1])[0] != 1: + if rrule.get("BYSETPOS", [1])[0] != 1: return False - if freq not in ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']: + if freq not in ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"]: return False - if 'BYDAY' in keys and freq == 'YEARLY': + if "BYDAY" in keys and freq == "YEARLY": return False return True @@ -752,7 +846,7 @@ def _refill_contents(self, lines): self._pile.contents.pop() except IndexError: break - [self._pile.contents.append((line, ('pack', None))) for line in lines] + [self._pile.contents.append((line, ("pack", None))) for line in lines] def rebuild(self): old_focus_y = self._pile.focus_position @@ -768,6 +862,7 @@ 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."), @@ -781,11 +876,13 @@ def _rebuild_edit_no_repeat(self): self._refill_contents(lines) def _rebuild_edit(self): - firstline = NColumns([ - (13, self.repeat_box), - (18, self.recurrence_choice), - (13, self.interval_edit), - ]) + firstline = NColumns( + [ + (13, self.repeat_box), + (18, self.recurrence_choice), + (13, self.interval_edit), + ] + ) lines = [firstline] if self.recurrence_choice.active == "weekly": @@ -810,25 +907,25 @@ def changed(self): def rrule(self): rrule = {} - rrule['freq'] = [self.recurrence_choice.active] + 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: + rrule["interval"] = [interval] + if rrule["freq"] == ["weekly"] and len(self.weekday_checks.days) > 1: + rrule["byday"] = self.weekday_checks.days + if rrule["freq"] == ["monthly"] and self.monthly_choice.active == self._xth_weekday: weekday, occurrence = get_weekday_occurrence(self._startdt) - rrule['byday'] = [f'{occurrence}{WEEKDAYS[weekday]}'] - if self.until_choice.active == 'Until': + rrule["byday"] = [f"{occurrence}{WEEKDAYS[weekday]}"] + if self.until_choice.active == "Until": if isinstance(self._startdt, dt.datetime): - rrule['until'] = dt.datetime.combine( + 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()) + rrule["until"] = self.until_edit.date + elif self.until_choice.active == "Repetitions": + rrule["count"] = int(self.repetitions_edit.get_edit_text()) return rrule @property @@ -847,14 +944,19 @@ def active(self, val): class ExportDialog(urwid.WidgetWrap): def __init__(self, this_func, abort_func, event) -> None: lines = [] - lines.append(urwid.Text('Export event as ICS file')) - lines.append(urwid.Text('')) + 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()) + caption="Location: ", edit_text="~/%s.ics" % event.summary.strip() + ) lines.append(export_location) - lines.append(urwid.Divider(' ')) - lines.append(CAttrMap( - urwid.Button('Save', on_press=this_func, user_data=export_location), - 'button', 'button focus')) + lines.append(urwid.Divider(" ")) + lines.append( + CAttrMap( + urwid.Button("Save", on_press=this_func, user_data=export_location), + "button", + "button focus", + ) + ) content = NPile(lines) urwid.WidgetWrap.__init__(self, urwid.LineBox(content)) diff --git a/pyproject.toml b/pyproject.toml index d6a215b1c..4c95e165d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "configobj", "atomicwrites>=0.1.7", "tzlocal>=1.0", + "additional_urwid_widgets>=0.4" ] [project.optional-dependencies] proctitle = [ @@ -61,7 +62,6 @@ ikhal = "khal.cli:main_ikhal" [build-system] requires = ["setuptools>=64", "setuptools_scm>=8"] build-backend = "setuptools.build_meta" -requires-python = ">=3.8,<3.12" [tool.setuptools.packages] find = {} diff --git a/tests/configs/small.conf b/tests/configs/small.conf index 37271e909..c2372349d 100644 --- a/tests/configs/small.conf +++ b/tests/configs/small.conf @@ -9,3 +9,4 @@ path = ~/.calendars/work/ readonly = True addresses = user@example.com + address_adapter = "a" diff --git a/tests/event_test.py b/tests/event_test.py index d20b90e5f..96baa6b2e 100644 --- a/tests/event_test.py +++ b/tests/event_test.py @@ -539,13 +539,14 @@ def test_event_attendees(): event = Event.fromString(_get_text('event_dt_simple'), **EVENT_KWARGS) assert event.attendees == "" event.update_attendees(["this-does@not-exist.de", ]) - assert event.attendees == "this-does@not-exist.de" + assert event.attendees == "this-does@not-exist.de " assert isinstance(event._vevents[event.ref].get('ATTENDEE', []), list) assert isinstance(event._vevents[event.ref].get('ATTENDEE', [])[0], vCalAddress) assert str(event._vevents[event.ref].get('ATTENDEE', [])[0]) == "MAILTO:this-does@not-exist.de" event.update_attendees(["this-does@not-exist.de", "also-does@not-exist.de"]) - assert event.attendees == "this-does@not-exist.de, also-does@not-exist.de" + assert event.attendees == "this-does@not-exist.de , " \ + "also-does@not-exist.de " assert isinstance(event._vevents[event.ref].get('ATTENDEE', []), list) assert len(event._vevents[event.ref].get('ATTENDEE', [])) == 2 @@ -561,7 +562,8 @@ def test_event_attendees(): ) event._vevents[event.ref]['ATTENDEE'] = [new_address, ] event.update_attendees(["another.mailaddress@not-exist.de", "mail.address@not-exist.de"]) - assert event.attendees == "mail.address@not-exist.de, another.mailaddress@not-exist.de" + assert event.attendees == "Real Name , "\ + "another.mailaddress@not-exist.de " address = [a for a in event._vevents[event.ref].get('ATTENDEE', []) if str(a) == "MAILTO:mail.address@not-exist.de"] assert len(address) == 1 diff --git a/tests/settings_test.py b/tests/settings_test.py index a1d6799c7..d5c2dc9af 100644 --- a/tests/settings_test.py +++ b/tests/settings_test.py @@ -43,10 +43,12 @@ def test_simple_config(self): 'home': { 'path': os.path.expanduser('~/.calendars/home/'), 'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''], + 'address_adapter': 'default' }, 'work': { 'path': os.path.expanduser('~/.calendars/work/'), 'readonly': False, 'color': None, 'priority': 10, 'type': 'calendar', 'addresses': [''], + 'address_adapter': 'default' }, }, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, @@ -62,6 +64,7 @@ def test_simple_config(self): 'default_dayevent_alarm': dt.timedelta(0), 'show_all_days': False, 'enable_mouse': True, + 'address_adapter': 'khard email | tail -n +2', } } for key in comp_config: @@ -85,10 +88,12 @@ def test_small(self): 'calendars': { 'home': {'path': os.path.expanduser('~/.calendars/home/'), 'color': 'dark green', 'readonly': False, 'priority': 20, - 'type': 'calendar', 'addresses': ['']}, + 'type': 'calendar', 'addresses': [''], + 'address_adapter': 'default'}, 'work': {'path': os.path.expanduser('~/.calendars/work/'), 'readonly': True, 'color': None, 'priority': 10, - 'type': 'calendar', 'addresses': ['user@example.com']}}, + 'type': 'calendar', 'addresses': ['user@example.com'], + 'address_adapter': 'a'}}, 'sqlite': {'path': os.path.expanduser('~/.local/share/khal/khal.db')}, 'locale': { 'local_timezone': get_localzone(), @@ -113,6 +118,7 @@ def test_small(self): 'enable_mouse': True, 'default_event_alarm': dt.timedelta(0), 'default_dayevent_alarm': dt.timedelta(0), + 'address_adapter': 'khard email | tail -n +2', } } for key in comp_config: @@ -230,6 +236,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'my calendar': { 'color': 'dark blue', @@ -237,6 +244,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'my private calendar': { 'color': '#FF00FF', @@ -244,6 +252,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'public1': { 'color': None, @@ -251,6 +260,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'public': { 'color': None, @@ -258,6 +268,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'work': { 'color': None, @@ -265,6 +276,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'cfgcolor': { 'color': 'dark blue', @@ -272,6 +284,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'dircolor': { 'color': 'dark blue', @@ -279,6 +292,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'cfgcolor_again': { 'color': 'dark blue', @@ -286,6 +300,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, 'cfgcolor_once_more': { 'color': 'dark blue', @@ -293,6 +308,7 @@ def test_config_checks(metavdirs): 'readonly': False, 'type': 'calendar', 'priority': 10, + 'address_adapter': None, }, },