From f655b6b64765216f9f09a2049ccee76e83c7b05f Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 28 Nov 2022 01:20:40 +0100 Subject: config in place now ... and actually trying to use the new cal.py-interface for practical tasks... --- calendar_cli/cal.py | 144 ++++++++++++++++++++++++++++++++++++----------- calendar_cli/legacy.py | 115 +------------------------------------ calendar_cli/metadata.py | 2 +- setup.py | 2 +- 4 files changed, 114 insertions(+), 149 deletions(-) diff --git a/calendar_cli/cal.py b/calendar_cli/cal.py index 24dc50b..3f54dbd 100755 --- a/calendar_cli/cal.py +++ b/calendar_cli/cal.py @@ -30,9 +30,11 @@ import caldav import dateutil import dateutil.parser import datetime +import logging import re -from icalendar import prop +from icalendar import prop, Timezone from calendar_cli.template import Template +from calendar_cli.config import interactive_config, config_section, read_config list_type = list @@ -60,6 +62,14 @@ def parse_dt(input, return_type=None): guess if we should return a date or a datetime. """ + if isinstance(input, datetime.datetime): + if return_type is datetime.date: + return input.date() + return input + if isinstance(input, datetime.date): + if return_type is datetime.datetime: + return datetime.datetime.combine(input, datetime.time(0,0)) + return input ret = dateutil.parser.parse(input) if return_type is datetime.datetime: return ret @@ -132,10 +142,46 @@ def parse_timespec(timespec): raise NotImplementedError("possibly a ISO time interval") +def find_calendars(args): + def list_(obj): + """ + For backward compatibility, a string rather than a list can be given as + calendar_url, calendar_name. Make it into a list. + """ + if not obj: + obj = [] + if isinstance(obj, str) or isinstance(obj, bytes): + obj = [ obj ] + return obj + + conn_params = {} + for k in args: + if k.startswith('caldav_') and args[k]: + key = k[7:] + if key == 'pass': + key = 'password' + if key == 'user': + key = 'username' + conn_params[key] = args[k] + calendars = [] + if conn_params: + client = caldav.DAVClient(**conn_params) + principal = client.principal() + calendars = [] + for calendar_url in list_(args.get('calendar_url')): + calendars.append(principal.calendar(cal_id=calendar_url)) + for calendar_name in list_(args.get('calendar_name')): + calendars.append(principal.calendar(name=calendar_name)) + if not calendars: + calendars = principal.calendars() + return calendars + + @click.group() -## TODO -#@click.option('-c', '--config-file', type=click.File("rb"), default=f"{os.environ['HOME']}/.config/calendar.conf") -#@click.option('--config-section', default="default") +## TODO: interactive config building +## TODO: language and timezone +@click.option('-c', '--config-file', default=f"{os.environ['HOME']}/.config/calendar.conf") +@click.option('--config-section', default=["default"], multiple=True) @click.option('--caldav-url', help="Full URL to the caldav server", metavar='URL') @click.option('--caldav-username', '--caldav-user', help="Full URL to the caldav server", metavar='URL') @click.option('--caldav-password', '--caldav-pass', help="Full URL to the caldav server", metavar='URL') @@ -157,20 +203,12 @@ def cli(ctx, **kwargs): ## TODO: logic to read the config file and edit kwargs from config file ## TODO: delayed communication with caldav server (i.e. if --help is given to subcommand) ## TODO: catch errors, present nice error messages - conn_params = {} - for k in kwargs: - if k.startswith('caldav_'): - conn_params[k[7:]] = kwargs[k] - client = caldav.DAVClient(**conn_params) - principal = client.principal() - calendars = [] - for calendar_url in kwargs['calendar_url']: - calendars.append(principal.calendar(cal_id=calendar_url)) - for calendar_name in kwargs['calendar_name']: - calendars.append(principal.calendar(name=calendar_name)) - if not calendars: - calendars = principal.calendars() - ctx.obj['calendars'] = calendars + conns = [] + ctx.obj['calendars'] = find_calendars(kwargs) + config = read_config(kwargs['config_file']) + if config: + for section in kwargs['config_section']: + ctx.obj['calendars'].extend(find_calendars(config_section(config, section))) @cli.command() @click.pass_context @@ -178,7 +216,10 @@ def test(ctx): """ Will test that we can connect to the caldav server and find the calendars. """ - click.echo("Seems like everything is OK") + if not ctx.obj['calendars']: + _abort("No calendars found!") + else: + click.echo("Seems like everything is OK") def _set_attr_options_(func, verb): """ @@ -207,19 +248,25 @@ def _set_attr_options(verb=""): @click.option('--uid', multiple=True, help='select an object with a given uid (or select more object with given uids). Overrides all other selection options') @click.option('--abort-on-missing-uid/--ignore-missing-uid', default=False, help='Abort if (one or more) uids are not found (default: silently ignore missing uids). Only effective when used with --uid') @click.option('--todo/--notodo', default=None, help='select only todos (or no todos)') -@click.option('--event/--noevent', default=None, help='select only todos (or no todos)') +@click.option('--event/--noevent', default=None, help='select only events (or no events)') @click.option('--include-completed/--exclude-completed', default=False, help='select only todos (or no todos)') @_set_attr_options() @click.option('--start', help='do a time search, with this start timestamp') @click.option('--end', help='do a time search, with this end timestamp (or duration)') @click.option('--timespan', help='do a time search for this interval') -@click.option('--sort-key', help='use this attributes for sorting. Templating can be used. Prepend with - for reverse sort', multiple=True) +@click.option('--sort-key', help='use this attributes for sorting. Templating can be used. Prepend with - for reverse sort. Special: "get_duration()" yields the duration or the distance between dtend and dtstart, or an empty timedelta', multiple=True) @click.option('--skip-parents/--include-parents', help="Skip parents if it's children is selected. Useful for finding tasks that can be started if parent depends on child", default=False) @click.option('--skip-children/--include-children', help="Skip children if it's parent is selected. Useful for getting an overview of the big picture if children are subtasks", default=False) @click.option('--limit', help='Number of objects to show', type=int) @click.option('--offset', help='SKip the first objects', type=int) @click.pass_context -def select(ctx, all, uid, abort_on_missing_uid, sort_key, skip_parents, skip_children, limit, offset, **kwargs_): +def select(*largs, **kwargs): + """ + select/search/filter tasks/events, for listing/editing/deleting, etc + """ + return _select(*largs, **kwargs) + +def _select(ctx, all=None, uid=[], abort_on_missing_uid=None, sort_key=[], skip_parents=None, skip_children=None, limit=None, offset=None, **kwargs_): """ select/search/filter tasks/events, for listing/editing/deleting, etc """ @@ -263,34 +310,36 @@ def select(ctx, all, uid, abort_on_missing_uid, sort_key, skip_parents, skip_chi if uid: return - if kwargs_['start']: + if kwargs_.get('start'): kwargs['start'] = parse_dt(kwargs['start']) - if kwargs_['end']: + if kwargs_.get('end'): rx = re.match(r'\+((\d+(\.\d+)?[smhdwy])+)', kwargs['end']) if rx: kwargs['end'] = parse_add_dur(kwargs['start'], rx.group(1)) else: kwargs['end'] = parse_dt(kwargs['end']) - elif kwargs_['timespan']: + elif kwargs_.get('timespan'): kwargs['start'], kwargs['end'] = parse_timespec(kwargs['timespan']) for attr in attr_txt_many: - if len(kwargs_[attr])>1: + if len(kwargs_.get(attr, []))>1: raise NotImplementedError(f"is it really needed to search for more than one {attr}?") - elif kwargs_[attr]: + elif kwargs_.get(attr): kwargs[attr] = kwargs[attr][0] ## TODO: special handling of parent and child! (and test for that!) + if 'start' in kwargs and 'end' in kwargs: + kwargs['expand'] = True for c in ctx.obj['calendars']: objs.extend(c.search(**kwargs)) if skip_children or skip_parents: objs_by_uid = {} for obj in objs: - objs_by_uid[obj.icalendar_instance.subcomponents[0]['uid']] = obj + objs_by_uid[obj.icalendar_component['uid']] = obj for obj in objs: - rels = obj.icalendar_instance.subcomponents[0].get('RELATED-TO', []) + rels = obj.icalendar_component.get('RELATED-TO', []) rels = rels if isinstance(rels, list_type) else [ rels ] for rel in rels: rel_uid = rel @@ -312,9 +361,13 @@ def select(ctx, all, uid, abort_on_missing_uid, sort_key, skip_parents, skip_chi reverse = False ## if the key contains {}, it should be considered to be a template if '{' in skey: - fkey = lambda obj: Template(skey).format(**obj.icalendar_instance.subcomponents[0]) + fkey = lambda obj: Template(skey).format(**obj.icalendar_component) + elif skey == 'get_duration()': + fkey = lambda obj: obj.get_duration() + elif skey in ('DTSTART', 'DTEND', 'DUE', 'DTSTAMP'): + fkey = lambda obj: getattr(obj.icalendar_component.get(skey), 'dt', datetime.datetime(1970,1,2)).strftime("%F%H%M%S") else: - fkey = lambda obj: obj.icalendar_instance.subcomponents[0][skey] + fkey = lambda obj: obj.icalendar_component.get(skey) ctx.obj['objs'].sort(key=fkey, reverse=reverse) ## OPTIMIZE TODO: this is also suboptimal, if ctx.obj is a very long list @@ -331,6 +384,12 @@ def list(ctx, ics, template): """ print out a list of tasks/events/journals """ + return _list(ctx, ics, template) + +def _list(ctx, ics=False, template="{DUE.dt:?{DTSTART.dt:?(date missing)?}?:%F %H:%M:%S}: {SUMMARY:?{DESCRIPTION:?(no summary given)?}?}"): + """ + Actual implementation of list + """ if ics: if not ctx.obj['objs']: return @@ -342,12 +401,13 @@ def list(ctx, ics, template): template=Template(template) for obj in ctx.obj['objs']: for sub in obj.icalendar_instance.subcomponents: - click.echo(template.format(**sub)) + if not isinstance(sub, Timezone): + click.echo(template.format(**sub)) @select.command() @click.pass_context def print_uid(ctx): - click.echo(ctx.obj['objs'][0].icalendar_instance.subcomponents[0]['UID']) + click.echo(ctx.obj['objs'][0].icalendar_component['UID']) @select.command() @click.option('--multi-delete/--no-multi-delete', default=None, help="Delete multiple things without confirmation prompt") @@ -371,6 +431,9 @@ def delete(ctx, multi_delete, **kwargs): @_set_attr_options(verb='set') @click.pass_context def edit(*largs, **kwargs): + """ + Edits a task/event/journal + """ return _edit(*largs, **kwargs) def _edit(ctx, add_category=None, complete=None, complete_recurrence_mode='safe', **kwargs): @@ -381,7 +444,7 @@ def _edit(ctx, add_category=None, complete=None, complete_recurrence_mode='safe' complete_recurrence_mode = kwargs.pop('recurrence_mode') _process_set_args(ctx, kwargs) for obj in ctx.obj['objs']: - ie = obj.icalendar_instance.subcomponents[0] + ie = obj.icalendar_component for arg in ctx.obj['set_args']: if arg in ('child', 'parent'): obj.set_relation(arg, ctx.obj['set_args'][arg]) @@ -422,6 +485,19 @@ def calculate_panic_time(ctx, **kwargs): def sum_hours(ctx, **kwargs): raise NotImplementedError() +@cli.command() +@click.pass_context +def agenda(ctx): + """ + Prints an agenda (alias for select --event --start=now --end=in 32 days --limit=30 list) + + agenda is for convenience only and takes no options or parameters. + Use the select command for advanced usage. + """ + start = datetime.datetime.now() + _select(ctx=ctx, start=start, end='+30d', limit=32, sort_key=['DTSTART', 'get_duration()']) + return _list(ctx) + ## TODO: all combinations of --first-calendar, --no-first-calendar, --multi-add, --no-multi-add should be tested @cli.group() @click.option('-l', '--add-ical-line', multiple=True, help="extra ical data to be injected") diff --git a/calendar_cli/legacy.py b/calendar_cli/legacy.py index 69dd3e6..cf17bd5 100755 --- a/calendar_cli/legacy.py +++ b/calendar_cli/legacy.py @@ -26,6 +26,7 @@ from datetime import time as time_ import dateutil.parser from dateutil.rrule import rrulestr from icalendar import Calendar,Event,Todo,Journal,Alarm +from calendar_cli.config import interactive_config, config_section import vobject import caldav import uuid @@ -262,97 +263,6 @@ def calendar_addics(caldav_conn, args): c.subcomponents = timezones + uids[uid] _calendar_addics(caldav_conn, c.to_ical(), uid, args) -def interactive_config(args, config, remaining_argv): - import readline - - new_config = False - section = 'default' - backup = {} - modified = False - - print("Welcome to the interactive calendar configuration mode") - print("Warning - untested code ahead, raise issues at t-calendar-cli@tobixen.no or the github issue tracker") - print("It might be a good idea to read the documentation in parallel if running this for your first time") - if not config or not hasattr(config, 'keys'): - config = {} - print("No valid existing configuration found") - new_config = True - if config: - print("The following sections have been found: ") - print("\n".join(config.keys())) - if args.config_section and args.config_section != 'default': - section = args.config_section - else: - ## TODO: tab completion - section = raw_input("Chose one of those, or a new name / no name for a new configuration section: ") - if section in config: - backup = config[section].copy() - print("Using section " + section) - else: - section = 'default' - - if not section in config: - config[section] = {} - - for config_key in ('caldav_url', 'calendar_url', 'caldav_user', 'caldav_pass', 'caldav_proxy', 'ssl_verify_cert', 'language', 'timezone', 'inherits'): - - if config_key == 'caldav_pass': - print("Config option caldav_pass - old value: **HIDDEN**") - value = getpass(prompt="Enter new value (or just enter to keep the old): ") - else: - print("Config option %s - old value: %s" % (config_key, config[section].get(config_key, '(None)'))) - value = raw_input("Enter new value (or just enter to keep the old): ") - - if value: - config[section][config_key] = value - modified = True - - if not modified: - print("No configuration changes have been done") - else: - state = 'start' - while state == 'start': - options = [] - if section: - options.append(('save', 'save configuration into section %s' % section)) - if backup or not section: - options.append(('save_other', 'add this new configuration into a new section in the configuration file')) - if remaining_argv: - options.append(('use', 'use this configuration without saving')) - options.append(('abort', 'abort without saving')) - print("CONFIGURATION DONE ...") - for o in options: - print("Type %s if you want to %s" % o) - cmd = raw_input("Enter a command: ") - if cmd in ('use', 'abort'): - state = 'done' - if cmd in ('save', 'save_other'): - if cmd == 'save_other': - new_section = raw_input("New config section name: ") - config[new_section] = config[section] - if backup: - config[section] = backup - else: - del config[section] - section = new_section - try: - if os.path.isfile(args.config_file): - os.rename(args.config_file, "%s.%s.bak" % (args.config_file, int(time.time()))) - with open(args.config_file, 'w') as outfile: - json.dump(config, outfile, indent=4) - except Exception as e: - print(e) - else: - print("Saved config") - state = 'done' - - - - - if args.config_section == 'default' and section != 'default': - config['default'] = config[section] - return config - def create_alarm(message, relative_timedelta): alarm = Alarm() alarm.add('ACTION', 'DISPLAY') @@ -777,15 +687,6 @@ def todo_delete(caldav_conn, args): for task in tasks: task.delete() -def config_section(config, section='default'): - if section in config and 'inherits' in config[section]: - ret = config_section(config, config[section]['inherits']) - else: - ret = {} - if section in config: - ret.update(config[section]) - return ret - def main(): """ the main function does (almost) nothing but parsing command line parameters @@ -819,19 +720,7 @@ def main(): args, remaining_argv = conf_parser.parse_known_args() conf_parser.add_argument("--version", action='version', version='%%(prog)s %s' % metadata["version"]) - config = {} - - try: - with open(args.config_file) as config_file: - config = json.load(config_file) - except IOError: - ## File not found - logging.info("no config file found") - except ValueError: - if args.interactive_config: - logging.error("error in config file. Be aware that the current config file will be ignored and overwritten", exc_info=True) - else: - logging.error("error in config file. You may want to run --interactive-config or fix the config file", exc_info=True) + config = read_config(args.config_file) if args.interactive_config: defaults = interactive_config(args, config, remaining_argv) diff --git a/calendar_cli/metadata.py b/calendar_cli/metadata.py index 2f96421..39a6c4b 100644 --- a/calendar_cli/metadata.py +++ b/calendar_cli/metadata.py @@ -1,5 +1,5 @@ metadata = { - "version": "0.14.0", + "version": "0.14.1", "author": "Tobias Brox", "author_short": "tobixen", "copyright": "Copyright 2013-2022, Tobias Brox and contributors", diff --git a/setup.py b/setup.py index eba87e6..9ec12ba 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( py_modules=['cal'], install_requires=[ 'icalendar', - 'caldav>=0.10', + 'caldav>=0.12-dev0', # 'isodate', 'pytz', ## pytz is supposed to be obsoleted, but see https://github.com/collective/icalendar/issues/333 'tzlocal', -- cgit v1.2.3