diff options
author | Tobias Brox <tobias@redpill-linpro.com> | 2017-01-23 18:28:47 +0100 |
---|---|---|
committer | Tobias Brox <tobias@redpill-linpro.com> | 2017-01-23 18:28:47 +0100 |
commit | 5d57eb01e5d18377e0bd7bc878df618075f131dc (patch) | |
tree | cf090184ac708f856925ad143e1017d2d4a3a34f /calendar-cli.py | |
parent | 36091f0336c8990671b07951351025227430c291 (diff) | |
parent | c63a7be44aa0ab31fb3764721f1bc6cd68651884 (diff) | |
download | calendar-cli-5d57eb01e5d18377e0bd7bc878df618075f131dc.zip |
Merge branch 'v0.11.0.dev' with conflict handling
Diffstat (limited to 'calendar-cli.py')
-rwxr-xr-x | calendar-cli.py | 250 |
1 files changed, 205 insertions, 45 deletions
diff --git a/calendar-cli.py b/calendar-cli.py index 4c349b8..865f740 100755 --- a/calendar-cli.py +++ b/calendar-cli.py @@ -24,15 +24,17 @@ import tzlocal import time from datetime import datetime, timedelta, date import dateutil.parser -from icalendar import Calendar,Event,Todo +from dateutil.rrule import rrulestr +from icalendar import Calendar,Event,Todo,Journal,Alarm import caldav import uuid import json import os import logging import sys +import re -__version__ = "0.10" +__version__ = "0.11.0-dev" __author__ = "Tobias Brox" __author_short__ = "tobixen" __copyright__ = "Copyright 2013-2016, Tobias Brox" @@ -45,14 +47,30 @@ __status__ = "Development" __product__ = "calendar-cli" __description__ = "high-level cli against caldav servers" -def _force_datetime(t): +def _force_datetime(t, args): """ date objects cannot be compared with timestamp objects, neither in python2 nor python3. Silly. + also, objects with time zone info cannot be compared with timestamps without time zone info. + and both datetime.now() and datetime.utcnow() seems to be without those bits. Silly. """ if type(t) == date: - return datetime(t.year, t.month, t.day) + t = datetime(t.year, t.month, t.day) + if t.tzinfo is None: + return t.replace(tzinfo=_tz(args)) + return t + +def _now(): + """ + python datetime is ... crap! + """ + return datetime.utcnow().replace(tzinfo=pytz.utc) + +def _tz(args): + if args.timezone: + return pytz.timezone(args.timezone) else: - return t + return tzlocal.get_localzone() + ## global constant ## (todo: this doesn't really work out that well, leap seconds/days are not considered, and we're missing the month unit) @@ -63,6 +81,8 @@ time_units = { vtodo_txt_one = ['location', 'description', 'geo', 'organizer', 'summary'] vtodo_txt_many = ['categories', 'comment', 'contact', 'resources'] +vcal_txt_one = ['location', 'description'] +vcal_txt_many = [] def niy(*args, **kwargs): if 'feature' in kwargs: @@ -70,8 +90,22 @@ def niy(*args, **kwargs): raise NotImplementedError def caldav_connect(args): + ## args.ssl_verify_cert is a string and can be a path or 'yes'/'no'. + ## the library expects a path or a boolean. + ## Translate 'yes' and 'no' to True and False, or pass the raw string: + ssl_verify_cert = { + 'yes': True, + 'no': False + }.get(args.ssl_verify_cert, args.ssl_verify_cert) # Create the account - return caldav.DAVClient(url=args.caldav_url, username=args.caldav_user, password=args.caldav_pass) + return caldav.DAVClient(url=args.caldav_url, username=args.caldav_user, password=args.caldav_pass, ssl_verify_cert=ssl_verify_cert, proxy=args.caldav_proxy) + +def parse_time_delta(delta_string): + # TODO: handle bad strings more gracefully + if len(delta_string) < 2 or delta_string[-1].lower() not in time_units: + raise ValueError("Invalid time delta: %s" % delta_string) + num = int(delta_string[:-1]) + return timedelta(0, num*time_units[delta_string[-1].lower()]) def find_calendar(caldav_conn, args): if args.calendar_url: @@ -171,7 +205,7 @@ def interactive_config(args, config, remaining_argv): if not section in config: config[section] = {} - for config_key in ('caldav_url', 'caldav_user', 'caldav_pass', 'language', 'timezone', 'inherits'): + for config_key in ('caldav_url', 'caldav_user', 'caldav_pass', 'caldav_proxy', 'ssl_verify_cert', 'language', 'timezone', 'inherits'): 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: @@ -211,6 +245,13 @@ def interactive_config(args, config, remaining_argv): 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') + alarm.add('DESCRIPTION', message) + alarm.add('TRIGGER', relative_timedelta, parameters={'VALUE':'DURATION'}) + return alarm def calendar_add(caldav_conn, args): cal = Calendar() @@ -251,10 +292,16 @@ def calendar_add(caldav_conn, args): event.add('dtend', dtstart + timedelta(0,event_duration_secs)) ## TODO: what does the cryptic comment here really mean, and why was the dtstamp commented out? dtstamp is required according to the RFC. ## not really correct, and it breaks i.e. with google calendar - event.add('dtstamp', datetime.now()) + event.add('dtstamp', _now()) ## maybe we should generate some uid? uid = uuid.uuid1() event.add('uid', str(uid)) + for attr in vcal_txt_one + vcal_txt_many: + if attr == 'summary': + continue + val = getattr(args, 'set_'+attr) + if val: + event.add(attr, val) event.add('summary', ' '.join(args.summary)) cal.add_component(event) _calendar_addics(caldav_conn, cal.to_ical(), uid, args) @@ -263,11 +310,7 @@ def calendar_add(caldav_conn, args): def calendar_delete(caldav_conn, args): cal = find_calendar(caldav_conn, args) if args.event_uid: - ## TODO: backwards compatibility hack, and/or caldav API in flux hack. Should go away at some point. - if hasattr(cal, 'object_by_uid'): - event = cal.object_by_uid(args.event_uid) - else: - event = cal.event_by_uid(args.event_uid) + event = cal.event_by_uid(args.event_uid) elif args.event_url: event = cal.event_by_url(args.event_url) elif args.event_timestamp: @@ -288,6 +331,24 @@ def calendar_delete(caldav_conn, args): raise ValueError("Event deletion failed: either uid, url or timestamp is needed") event.delete() +def journal_add(caldav_conn, args): + ## TODO: copied from todo_add, should probably be consolidated + cal = Calendar() + cal.add('prodid', '-//{author_short}//{product}//{language}'.format(author_short=__author_short__, product=__product__, language=args.language)) + cal.add('version', '2.0') + journal = Journal() + ## TODO: what does the cryptic comment here really mean, and why was the dtstamp commented out? dtstamp is required according to the RFC. + ## TODO: (cryptic old comment:) not really correct, and it breaks i.e. with google calendar + journal.add('dtstamp', datetime.now()) + journal.add('dtstart', date.today()) + journal.add('summary', ' '.join(args.summaryline)) + uid = uuid.uuid1() + journal.add('uid', str(uid)) + cal.add_component(journal) + _calendar_addics(caldav_conn, cal.to_ical(), uid, args) + print("Added journal item with uid=%s" % uid) + ## FULL STOP - should do some major refactoring before doing more work here! + def todo_add(caldav_conn, args): ## TODO: copied from calendar_add, should probably be consolidated if args.icalendar or args.nocaldav: @@ -300,17 +361,15 @@ def todo_add(caldav_conn, args): cal.add('prodid', '-//{author_short}//{product}//{language}'.format(author_short=__author_short__, product=__product__, language=args.language)) cal.add('version', '2.0') todo = Todo() - ## TODO: what does the cryptic comment here really mean, and why was the dtstamp commented out? dtstamp is required according to the RFC. - ## TODO: (cryptic old comment:) not really correct, and it breaks i.e. with google calendar - todo.add('dtstamp', datetime.now()) + todo.add('dtstamp', _now()) - for arg in ('set_due', 'set_dtstart'): - if getattr(args, arg): - if type(getattr(args, arg)) == str: - val = dateutil.parser.parse(getattr(args, arg)) + for setarg in ('due', 'dtstart'): + if getattr(args, 'set_'+setarg): + if type(getattr(args, 'set_'+setarg)) == str: + val = dateutil.parser.parse(getattr(args, 'set_'+setarg)) else: - val = getattr(args, arg) - todo.add(arg, val) + val = getattr(args, 'set_'+setarg) + todo.add(setarg, val) todo.add('uid', str(uid)) todo.add('summary', ' '.join(args.summaryline)) todo.add('status', 'NEEDS-ACTION') @@ -323,12 +382,26 @@ def todo_add(caldav_conn, args): rt.value = str(uid) t.save() - for attr in vtodo_txt_one + vtodo_txt_many: + for attr in vtodo_txt_one: if attr == 'summary': continue val = getattr(args, 'set_'+attr) if val: todo.add(attr, val) + ## TODO: this doesn't currently work quite the way we'd like it to + ## work (it adds to lines to the ical, and vobject cares only + ## about one of them), and if we do get it to work, we'd like to + ## refactor and get the same logic in the edit-function + for attr in vtodo_txt_many: + val = getattr(args, 'set_'+attr) + if val: + vals = val.split(',') + todo.add(attr, vals) + + if args.alarm is not None: + alarm = create_alarm(' '.join(args.summaryline), parse_time_delta(args.alarm)) + todo.add_component(alarm) + cal.add_component(todo) _calendar_addics(caldav_conn, cal.to_ical(), uid, args) print("Added todo item with uid=%s" % uid) @@ -343,7 +416,7 @@ def calendar_agenda(caldav_conn, args): if args.from_time: dtstart = dateutil.parser.parse(args.from_time) else: - dtstart = datetime.now() + dtstart = _now() if args.to_time: dtend = dateutil.parser.parse(args.to_time) elif args.agenda_mins: @@ -369,11 +442,11 @@ def calendar_agenda(caldav_conn, args): else: raise Exception("Panic") for event in events__: - dtstart = event.dtstart.value if hasattr(event, 'dtstart') else datetime.now() + dtstart = event.dtstart.value if hasattr(event, 'dtstart') else _now() if not isinstance(dtstart, datetime): dtstart = datetime(dtstart.year, dtstart.month, dtstart.day) if not dtstart.tzinfo: - dtstart = args.timezone.localize(dtstart) + dtstart = _tz(args).localize(dtstart) events.append({'dtstart': dtstart, 'instance': event}) events.sort(lambda a,b: cmp(a['dtstart'], b['dtstart'])) for event in events: @@ -385,7 +458,7 @@ def calendar_agenda(caldav_conn, args): break event['uid'] = event['instance'].uid.value if hasattr(event['instance'], 'uid') else '<no uid>' ## TODO: this will probably break and is probably moot on python3? - if hasattr(event['summary'], 'encode'): + if isinstance(event['summary'], unicode): event['summary'] = event['summary'].encode('utf-8') print(args.event_template.format(**event)) @@ -398,12 +471,34 @@ def todo_select(caldav_conn, args): ## TODO: we're fetching everything from the server, and then doing the filtering here. It would be better to let the server do the filtering, though that requires library modifications. ## TODO: current release of the caldav library doesn't support the multi-key sort_keys attribute. The try-except construct should be removed at some point in the future, when caldav 0.5 is released. try: - tasks = find_calendar(caldav_conn, args).todos(sort_keys=('dtstart', 'due', 'priority')) + tasks = find_calendar(caldav_conn, args).todos(sort_keys=('isnt_overdue', 'hasnt_started', 'due', 'dtstart', 'priority')) except: tasks = find_calendar(caldav_conn, args).todos() for attr in vtodo_txt_one + vtodo_txt_many: ## TODO: now we have _exact_ match on items in the the array attributes, and substring match on items that cannot be duplicated. Does that make sense? Probably not. if getattr(args, attr): tasks = [x for x in tasks if hasattr(x.instance.vtodo, attr) and getattr(args, attr) in getattr(x.instance.vtodo, attr).value] + if getattr(args, 'no'+attr): + tasks = [x for x in tasks if not hasattr(x.instance.vtodo, attr)] + if args.overdue: + tasks = [x for x in tasks if hasattr(x.instance.vtodo, 'due') and _force_datetime(x.instance.vtodo.due.value, args) < _force_datetime(datetime.now(), args)] + if args.hide_future: + tasks = [x for x in tasks if not(hasattr(x.instance.vtodo, 'dtstart') and _force_datetime(x.instance.vtodo.dtstart.value, args) > _force_datetime(datetime.now(), args))] + if args.hide_parents or args.hide_children: + tasks_by_uid = {} + for task in tasks: + tasks_by_uid[task.instance.vtodo.uid.value] = task + for task in tasks: + if hasattr(task.instance.vtodo, 'related_to'): + uid = task.instance.vtodo.uid.value + rel_uid = task.instance.vtodo.related_to.value + rel_type = task.instance.vtodo.related_to.params.get('RELTYPE', 'PARENT') + if ((rel_type == 'CHILD' and args.hide_parents) or (rel_type == 'PARENT' and args.hide_children)) and \ + rel_uid in tasks_by_uid and uid in tasks_by_uid: + del tasks_by_uid[uid] + if ((rel_type == 'PARENT' and args.hide_parents) or (rel_type == 'CHILD' and args.hide_children)) and \ + rel_uid in tasks_by_uid: + del tasks_by_uid[rel_uid] + tasks = [x for x in tasks if x.instance.vtodo.uid.value in tasks_by_uid] if args.top+args.limit: tasks = tasks[args.offset+args.offsetn:args.top+args.limit+args.offset+args.offsetn] elif args.offset+args.offsetn: @@ -445,7 +540,7 @@ def todo_postpone(caldav_conn, args): if args.until.startswith('+'): rel_skew = timedelta(seconds=int(args.until[1:-1])*time_units[args.until[-1]]) elif args.until.startswith('in'): - new_ts = datetime.now()+timedelta(seconds=int(args.until[2:-1])*time_units[args.until[-1]]) + new_ts = _now()+timedelta(seconds=int(args.until[2:-1])*time_units[args.until[-1]]) else: new_ts = dateutil.parser.parse(args.until) if not new_ts.time(): @@ -496,16 +591,18 @@ def todo_list(caldav_conn, args): for task in tasks: t = {'instance': task} t['dtstart'] = task.instance.vtodo.dtstart.value if hasattr(task.instance.vtodo,'dtstart') else date.today() - t['dtstart_passed_mark'] = '!' if _force_datetime(t['dtstart']) <= datetime.now() else ' ' - t['due'] = task.instance.vtodo.due.value if hasattr(task.instance.vtodo,'due') else date.today()+timedelta(365) - t['due_passed_mark'] = '!' if _force_datetime(t['due']) < datetime.now() else ' ' + t['dtstart_passed_mark'] = '!' if _force_datetime(t['dtstart'], args) <= _now() else ' ' + t['due'] = task.instance.vtodo.due.value if hasattr(task.instance.vtodo,'due') else date.today()+timedelta(args.default_due) + t['due_passed_mark'] = '!' if _force_datetime(t['due'], args) < _now() else ' ' + for timeattr in ('dtstart', 'due'): + t[timeattr] = t[timeattr].strftime(args.timestamp_format) for summary_attr in ('summary', 'location', 'description', 'url', 'uid'): if hasattr(task.instance.vtodo, summary_attr): t['summary'] = getattr(task.instance.vtodo, summary_attr).value break t['uid'] = task.instance.vtodo.uid.value ## TODO: this will probably break and is probably moot on python3? - if hasattr(t['summary'], 'encode'): + if hasattr(t['summary'], 'encode') and isinstance(t['summary'], unicode): t['summary'] = t['summary'].encode('utf-8') print(args.todo_template.format(**t)) @@ -514,7 +611,43 @@ def todo_complete(caldav_conn, args): raise ValueError("No caldav connection, aborting") tasks = todo_select(caldav_conn, args) for task in tasks: + if hasattr(task.instance.vtodo, 'rrule'): + rrule = rrulestr(task.instance.vtodo.rrule.value) + try: + next = rrule.after(datetime.now()) + except TypeError: ## pesky problem with comparition of timestamps with and without tzinfo + next = rrule.after(datetime.now(tz=tzlocal.get_localzone())) + if next: + ## new_task is to be completed and we keep the original task open + completed_task = task.copy() + remaining_task = task + + ## the remaining task should have recurrence id set to next start time, and range THISANDFUTURE + if hasattr(remaining_task.instance.vtodo, 'recurrence_id'): + del remaining_task.instance.vtodo.recurrence_id + remaining_task.instance.vtodo.add('recurrence-id') + remaining_task.instance.vtodo.recurrence_id.value = next ## TODO: should be same type as dtstart (date or datetime) + remaining_task.instance.vtodo.dtstart.value = next ## TODO: should be same type as dtstart (date or datetime) + remaining_task.instance.vtodo.recurrence_id.params['RANGE'] = [ 'THISANDFUTURE' ] + remaining_task.instance.vtodo.rrule + remaining_task.save() + + ## the completed task should have recurrence id set to current time + ## count in rrule should decrease + if hasattr(completed_task.instance.vtodo, 'recurrence_id'): + del completed_task.instance.vtodo.recurrence_id + completed_task.instance.vtodo.add('recurrence-id') + completed_task.instance.vtodo.recurrence_id.value = datetime.now() + completed_task.instance.vtodo.dtstart.value = datetime.now() + count_search = re.search('COUNT=(\d+)', completed_task.instance.vtodo.rrule.value) + if count_search: + completed_task.instance.vtodo.rrule.value = re.replace('COUNT=(\d+)', 'COUNT=%d' % int(count_search.group(1))-1) + completed_task.complete() + + continue + task.complete() + def todo_delete(caldav_conn, args): if args.nocaldav: @@ -524,11 +657,12 @@ def todo_delete(caldav_conn, args): task.delete() def config_section(config, section='default'): - if 'inherits' in config[section]: + if section in config and 'inherits' in config[section]: ret = config_section(config, config[section]['inherits']) else: ret = {} - ret.update(config[section]) + if section in config: + ret.update(config[section]) return ret def main(): @@ -579,6 +713,11 @@ def main(): return else: defaults = config_section(config, args.config_section) + if not 'ssl_verify_cert' in defaults: + defaults['ssl_verify_cert'] = 'yes' + if not 'language' in defaults: + ## TODO: shouldn't this be lower case? + defaults['language'] = 'EN' # Parse rest of arguments # Don't suppress add_help here so it will handle -h @@ -594,10 +733,12 @@ def main(): parser.add_argument("--nocaldav", help="Do not connect to CalDAV server, but read/write icalendar format from stdin/stdout", action="store_true") parser.add_argument("--icalendar", help="Read/write icalendar format from stdin/stdout", action="store_true") parser.add_argument("--timezone", help="Timezone to use") - parser.add_argument('--language', help="language used", default="EN") + parser.add_argument('--language', help="language used") parser.add_argument("--caldav-url", help="Full URL to the caldav server", metavar="URL") parser.add_argument("--caldav-user", help="username to log into the caldav server", metavar="USER") parser.add_argument("--caldav-pass", help="password to log into the caldav server", metavar="PASS") + parser.add_argument("--caldav-proxy", help="HTTP proxy server to use (if any)") + parser.add_argument("--ssl-verify-cert", help="verification of the SSL cert - 'yes' to use the OS-provided CA-bundle, 'no' to trust any cert and the path to a CA-bundle") parser.add_argument("--debug-logging", help="turn on debug logging", action="store_true") parser.add_argument("--calendar-url", help="URL for calendar to be used (may be absolute or relative to caldav URL, or just the name of the calendar)") @@ -612,28 +753,40 @@ def main(): todo_parser.add_argument('--offsetn', type=int, default=0) todo_parser.add_argument('--limit', type=int, default=0) todo_parser.add_argument('--todo-uid') + todo_parser.add_argument('--hide-parents', help='Hide the parent if you need to work on children tasks first (parent task depends on children tasks to be done first)', action='store_true') + todo_parser.add_argument('--hide-children', help='Hide the parent if you need to work on children tasks first (parent task depends on children tasks to be done first)', action='store_true') + todo_parser.add_argument('--overdue', help='Only show overdue tasks', action='store_true') + todo_parser.add_argument('--hide-future', help='Hide events with future dtstart', action='store_true') for attr in vtodo_txt_one + vtodo_txt_many: todo_parser.add_argument('--'+attr, help="for filtering tasks") - + + for attr in vtodo_txt_one + vtodo_txt_many: + todo_parser.add_argument('--no'+attr, help="for filtering tasks", action='store_true') + #todo_parser.add_argument('--priority', ....) #todo_parser.add_argument('--sort-by', ....) #todo_parser.add_argument('--due-before', ....) todo_subparsers = todo_parser.add_subparsers(title='tasks subcommand') todo_add_parser = todo_subparsers.add_parser('add') todo_add_parser.add_argument('summaryline', nargs='+') - todo_add_parser.add_argument('--set-due', default=date.today()+timedelta(7)) + todo_add_parser.add_argument('--set-due', default=date.today()+timedelta(365)) todo_add_parser.add_argument('--set-dtstart', default=date.today()+timedelta(1)) todo_add_parser.add_argument('--is-child', help="the new task is a child-task of the selected task(s)", action='store_true') for attr in vtodo_txt_one + vtodo_txt_many: if attr != 'summary': todo_add_parser.add_argument('--set-'+attr, help="Set "+attr) + # TODO: we probably want to be able to set or delete alarms in other situations, yes? generalize? + todo_add_parser.add_argument('--alarm', metavar='DURATION_BEFORE', + help="specifies a time at which a reminder should be presented for this task, " \ + "relative to the start time of the task (as a timestamp delta)") todo_add_parser.set_defaults(func=todo_add) todo_list_parser = todo_subparsers.add_parser('list') todo_list_parser.add_argument('--todo-template', help="Template for printing out the event", default="{dtstart}{dtstart_passed_mark} {due}{due_passed_mark} {summary}") - todo_list_parser.add_argument('--default-due', help="Default number of days from a task is submitted until it's considered due", default=14) + todo_list_parser.add_argument('--default-due', help="Default number of days from a task is submitted until it's considered due", type=int, default=365) todo_list_parser.add_argument('--list-categories', help="Instead of listing the todo-items, list the unique categories used", action='store_true') + todo_list_parser.add_argument('--timestamp-format', help="strftime-style format string for the output timestamps", default="%F (%a)") todo_list_parser.set_defaults(func=todo_list) todo_edit_parser = todo_subparsers.add_parser('edit') @@ -654,7 +807,14 @@ def main(): todo_delete_parser = todo_subparsers.add_parser('delete') todo_delete_parser.set_defaults(func=todo_delete) - + + ## journal + journal_parser = subparsers.add_parser('journal') + journal_subparsers = journal_parser.add_subparsers(title='tasks subcommand') + journal_add_parser = journal_subparsers.add_parser('add') + journal_add_parser.add_argument('summaryline', nargs='+') + journal_add_parser.set_defaults(func=journal_add) + calendar_parser = subparsers.add_parser('calendar') calendar_subparsers = calendar_parser.add_subparsers(title='cal subcommand') calendar_add_parser = calendar_subparsers.add_parser('add') @@ -663,6 +823,9 @@ def main(): calendar_add_parser.set_defaults(func=calendar_add) calendar_add_parser.add_argument('--whole-day', help='Whole-day event', action='store_true', default=False) + for attr in vcal_txt_one + vcal_txt_many: + calendar_add_parser.add_argument('--set-'+attr, help='Set '+attr) + calendar_addics_parser = calendar_subparsers.add_parser('addics') calendar_addics_parser.add_argument('--file', help="ICS file to upload", default='-') calendar_addics_parser.set_defaults(func=calendar_addics) @@ -684,13 +847,10 @@ def main(): args = parser.parse_args(remaining_argv) - if args.timezone: - args.timezone = pytz.timezone(args.timezone) - else: - args.timezone = tzlocal.get_localzone() - if not args.nocaldav: caldav_conn = caldav_connect(args) + else: + caldav_conn = None ret = args.func(caldav_conn, args) |