summaryrefslogtreecommitdiff
path: root/cal.py
diff options
context:
space:
mode:
authorTobias Brox <tobias@redpill-linpro.com>2022-09-30 01:29:58 +0200
committerTobias Brox <tobias@redpill-linpro.com>2022-10-09 01:55:25 +0200
commite9a968157e65e18d1b285a7765ecb641175ba370 (patch)
tree4c770db41f5effaa79447df59023b536a5a60738 /cal.py
parentfd834cd17933bad5567f1315ddc800145081dcd6 (diff)
downloadcalendar-cli-e9a968157e65e18d1b285a7765ecb641175ba370.zip
More work on the kal aka cal.py command. It can now do around 80% of all the stuff calendar-cli can do, plus more.
Diffstat (limited to 'cal.py')
-rwxr-xr-xcal.py237
1 files changed, 203 insertions, 34 deletions
diff --git a/cal.py b/cal.py
index a4f4b19..88ce91b 100755
--- a/cal.py
+++ b/cal.py
@@ -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")