diff options
Diffstat (limited to 'cal.py')
-rwxr-xr-x | cal.py | 237 |
1 files changed, 203 insertions, 34 deletions
@@ -25,6 +25,12 @@ from calendar_cli import __version__ import click import os import caldav +#import isodate +import dateutil +import datetime +import re +from icalendar import prop +from lib.template import Template ## should make some subclasses of click.ParamType: @@ -38,19 +44,89 @@ import caldav ## See https://click.palletsprojects.com/en/8.0.x/api/#click.ParamType and ## /usr/lib/*/site-packages/click/types.py on how to do this. -## TODO: maybe find those attributes through the icalendar library? +## TODO: maybe find those attributes through the icalendar library? icalendar.cal.singletons, icalendar.cal.multiple, etc attr_txt_one = ['location', 'description', 'geo', 'organizer', 'summary'] -attr_txt_many = ['categories', 'comment', 'contact', 'resources'] +attr_txt_many = ['category', 'comment', 'contact', 'resources'] + +def parse_dt(input, return_type=None): + """Parse a datetime or a date. + + If return_type is date, return a date - if return_type is + datetime, return a datetime. If no return_type is given, try to + guess if we should return a date or a datetime. -## TODO ... (and should be moved somewhere else) -def _parse_timespec(timespec): """ - will parse a timespec and return two timestamps. timespec can be - in ISO8601 interval format, as format 1, 2 or 3 as described at - https://en.wikipedia.org/wiki/ISO_8601#Time_intervals - or it can - be on calendar-cli format (i.e. 2021-01-08 15:00:00+1h) + ret = dateutil.parser.parse(input) + if return_type is datetime.datetime: + return ret + elif return_type is datetime.date: + return ret.date() + elif ret.time() == datetime.time(0,0) and len(input)<12 and not '00:00' in input and not '0000' in input: + return ret.date() + else: + return ret + +def parse_add_dur(dt, dur): """ - raise NotImplementedError() + duration may be something on the format 1s (one second), 3m (three minutes, not months), 3.5h, 1y1w, etc + or a ISO8601 duration (TODO: not supported yet). Return the dt plus duration + """ + time_units = { + 's': 1, 'm': 60, 'h': 3600, + 'd': 86400, 'w': 604800 + } + while dur: + rx = re.match(r'(\d+(?:\.\d+)?)([smhdw])(.*)', dur) + assert rx + i = float(rx.group(1)) + u = rx.group(2) + dur = rx.group(3) + if u=='y': + dt = datetime.datetime.combine(datetime.date(dt.year+i, dt.month, dt.day), dt.time()) + else: + dt = dt + datetime.timedelta(0, i*time_units[u]) + return dt + + +## TODO ... (and should be moved somewhere else?) +def parse_timespec(timespec): + """parses a timespec and return two timestamps + + The ISO8601 interval format, format 1, 2 or 3 as described at + https://en.wikipedia.org/wiki/ISO_8601#Time_intervals should be + accepted, though it may be dependent on + https://github.com/gweis/isodate/issues/77 or perhaps + https://github.com/dateutil/dateutil/issues/1184 + + The calendar-cli format (i.e. 2021-01-08 15:00:00+1h) should be accepted + + Two timestamps should be accepted. + + One timestamp should be accepted, and the second return value will be None. + """ + ## calendar-cli format, 1998-10-03 15:00+2h + if '+' in timespec: + rx = re.match(r'(.*)\+((?:\d+(?:\.\d+)?[smhdwy])+)$', timespec) + if rx: + start = parse_dt(rx.group(1)) + end = parse_add_dur(start, rx.group(2)) + return (start, end) + try: + ## parse("2015-05-05 2015-05-05") does not throw the ParserError + if timespec.count('-')>3: + raise dateutil.parser.ParserError("Seems to be two dates here") + ret = parse_dt(timespec) + return (ret,None) + except dateutil.parser.ParserError: + split_by_space = timespec.split(' ') + if len(split_by_space) == 2: + return (parse_dt(split_by_space[0]), parse_dt(split_by_space[1])) + elif len(split_by_space) == 4: + return (parse_dt(f"{split_by_space[0]} {split_by_space[1]}"), parse_dt(f"{split_by_space[2]} {split_by_space[3]}")) + else: + raise ValueError(f"couldn't parse time interval {timespec}") + + raise NotImplementedError("possibly a ISO time interval") @click.group() ## TODO @@ -100,22 +176,37 @@ def test(ctx): """ click.echo("Seems like everything is OK") -def _set_attr_options(func): +def _set_attr_options_(func, verb): """ - decorator that will add options --set-categories, --set-description etc + decorator that will add options --set-category, --set-description etc """ + if verb: + verb1 = f"{verb}-" + else: + verb1 = "" + verb = "Select by " for foo in attr_txt_one: - func = click.option(f"--set-{foo}", help=f"Set ical attribute {foo}")(func) + func = click.option(f"--{verb1}{foo}", help=f"{verb} ical attribute {foo}")(func) for foo in attr_txt_many: - func = click.option(f"--set-{foo}", help=f"Set ical attribute {foo}", multiple=True)(func) + func = click.option(f"--{verb1}{foo}", help=f"{verb} ical attribute {foo}", multiple=True)(func) return func +def _set_attr_options(verb=""): + return lambda func: _set_attr_options_(func,verb) + @cli.group() @click.option('--all/--none', default=None, help='Select all (or none) of the objects. Overrides all other selection options.') -@click.option('--uid', multiple=True, help='select an object with a given uid (or select more object with given uids)') -@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)') +@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('--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.pass_context -def select(ctx, all, uid, abort_on_missing_uid, **kwargs): +def select(ctx, all, uid, abort_on_missing_uid, **kwargs_): """ select/search/filter tasks/events, for listing/editing/deleting, etc """ @@ -129,15 +220,26 @@ def select(ctx, all, uid, abort_on_missing_uid, **kwargs): return if all: for c in ctx.obj['calendars']: - objs.append(c.objects) + objs.extend(c.objects()) + return + + kwargs = {} + for kw in kwargs_: + if kwargs_[kw] is not None and kwargs_[kw] != (): + kwargs[kw] = kwargs_[kw] ## uid(s) missing_uids = [] for uid_ in uid: + comp_filter=None + if kwargs['event']: + comp_filter='VEVENT' + if kwargs_['todo']: + comp_filter='VTODO' cnt = 0 for c in ctx.obj['calendars']: try: - objs.append(c.object_by_uid(uid_)) + objs.append(c.object_by_uid(uid_, comp_filter=comp_filter)) cnt += 1 except caldav.error.NotFoundError: pass @@ -145,14 +247,49 @@ def select(ctx, all, uid, abort_on_missing_uid, **kwargs): missing_uids.append(uid_) if abort_on_missing_uid and missing_uids: raise click.Abort(f"Did not find the following uids in any calendars: {missing_uids}") + if uid: + return + + if kwargs_['start']: + kwargs['start'] = parse_dt(kwargs['start']) + if kwargs_['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']: + kwargs['start'], kwargs['end'] = parse_timespec(kwargs['timespan']) + + for attr in attr_txt_many: + if len(kwargs_[attr])>1: + raise NotImplementedError(f"is it really needed to search for more than one {attr}?") + elif kwargs_[attr]: + kwargs[attr] = kwargs[attr][0] + + for c in ctx.obj['calendars']: + objs.extend(c.search(**kwargs)) @select.command() +@click.option('--ics/--no-ics', default=False, help="Output in ics format") +@click.option('--template', default="{DUE.dt:?{DTSTART.dt:?(date missing)?}?:%F %H:%M:%S}: {SUMMARY:?{DESCRIPTION:?(no summary given)?}?}") @click.pass_context -def list(ctx, **kwargs): +def list(ctx, ics, template): """ print out a list of tasks/events/journals """ - raise NotImplementedError() + if ics: + if not ctx.obj['objs']: + return + icalendar = ctx.obj['objs'].pop(0).icalendar_instance + for obj in ctx.obj['objs']: + icalendar.subcomponents.extend(obj.icalendar_instance.subcomponents) + click.echo(icalendar.to_ical()) + return + template=Template(template) + for obj in ctx.obj['objs']: + for sub in obj.icalendar_instance.subcomponents: + click.echo(template.format(**sub)) @select.command() @click.option('--multi-delete/--no-multi-delete', default=None, help="Delete multiple things without confirmation prompt") @@ -170,6 +307,28 @@ def delete(ctx, multi_delete, **kwargs): obj.delete() @select.command() +@click.option('--add-category', default=None, help="Delete multiple things without confirmation prompt", multiple=True) +@_set_attr_options(verb='set') +@click.pass_context +def edit(ctx, add_category=None, **kwargs): + _process_set_args(ctx, kwargs) + for obj in ctx.obj['objs']: + ie = obj.icalendar_instance.subcomponents[0] + for arg in ctx.obj['set_args']: + if arg in ie: + ie.pop(arg) + ie.add(arg, ctx.obj['set_args'][arg]) + if add_category: + if 'categories' in ie: + cats = ie.pop('categories').cats + else: + cats = [] + cats.extend(add_category) + ie.add('categories', cats) + obj.save() + + +@select.command() @click.pass_context def complete(ctx, **kwargs): raise NotImplementedError() @@ -189,7 +348,6 @@ def sum_hours(ctx, **kwargs): @click.option('-l', '--add-ical-line', multiple=True, help="extra ical data to be injected") @click.option('--multi-add/--no-multi-add', default=None, help="Add things to multiple calendars") @click.option('--first-calendar/--no-first-calendar', default=None, help="Add things only to the first calendar found") -@_set_attr_options @click.pass_context def add(ctx, **kwargs): """ @@ -212,10 +370,6 @@ def add(ctx, **kwargs): raise click.Abort("Giving up: Multiple calendars found/given, please specify which calendar you want to use") ctx.obj['ical_fragment'] = "\n".join(kwargs['add_ical_line']) - ctx.obj['add_args'] = {} - for x in kwargs: - if x.startswith('set_'): - ctx.obj['add_args'][x[4:]] = kwargs[x] @add.command() @click.pass_context @@ -230,10 +384,23 @@ def ical(ctx, ical_data, ical_file): ## TODO: this may not be an event - should make a Calendar.save_object method c.save_event(ical) +def _process_set_args(ctx, kwargs): + ctx.obj['set_args'] = {} + for x in kwargs: + if x == 'set_category' and kwargs[x] != (): + ctx.obj['set_args']['categories'] = kwargs[x] + elif x.startswith('set_') and kwargs[x] is not None and kwargs[x] != (): + ctx.obj['set_args'][x[4:]] = kwargs[x] + if 'summary' in kwargs: + ctx.obj['set_args']['summary'] = ctx.obj['set_args'].get('summary', '') + kwargs['summary'] + if kwargs['ical_fragment']: + ctx.obj['set_args']['ics'] = kwargs['ical_fragment'] + @add.command() @click.argument('summary', nargs=-1) +@_set_attr_options(verb='set') @click.pass_context -def todo(ctx, summary): +def todo(ctx, **kwargs): """ Creates a new task with given SUMMARY @@ -242,21 +409,22 @@ def todo(ctx, summary): kal add todo "fix all known bugs in calendar-cli" kal add todo --set-due=2050-12-10 "release calendar-cli version 42.0.0" """ - summary = " ".join(summary) - ctx.obj['add_args']['summary'] = (ctx.obj['add_args']['summary'] or '') + summary - if not ctx.obj['add_args']['summary']: + kwargs['summary'] = " ".join(kwargs['summary']) + _process_set_args(ctx, kwargs) + if not ctx.obj['set_args']['summary']: raise click.Abort("denying to add a TODO with no summary given") return for cal in ctx.obj['calendars']: - todo = cal.save_todo(ical=ctx.obj['ical_fragment'], **ctx.obj['add_args'], no_overwrite=True) + todo = cal.save_todo(ical=ctx.obj['ical_fragment'], **ctx.obj['set_args'], no_overwrite=True) click.echo(f"uid={todo.id}") @add.command() ## TODO @click.argument('summary') @click.argument('timespec') +@_set_attr_options(verb='set') @click.pass_context -def event(ctx, summary, timespec): +def event(ctx, timespec, **kwargs): """ Creates a new event with given SUMMARY at the time specifed through TIMESPEC. @@ -267,10 +435,11 @@ def event(ctx, summary, timespec): kal add event "final bughunting session" 2004-11-25+5d kal add event "release party" 2004-11-30T19:00+2h """ + _process_set_args(ctx, kwargs) for cal in ctx.obj['calendars']: - (dtstart, dtend) = _parse_timespec(timespec) - #event = cal.add_event( - click.echo(f"uid={uid}") + (dtstart, dtend) = parse_timespec(timespec) + event = cal.save_event(dtstart=dtstart, dtend=dtend, **ctx.obj['set_args'], no_overwrite=True) + click.echo(f"uid={event.id}") def journal(): click.echo("soon you should be able to add journal entries to your calendar") |