summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTobias Brox <tobias@redpill-linpro.com>2022-11-28 01:20:40 +0100
committerTobias Brox <tobias@redpill-linpro.com>2022-11-28 01:20:40 +0100
commitf655b6b64765216f9f09a2049ccee76e83c7b05f (patch)
tree2ee7ee92beba3800ed126e9557b8da3d5ff4e408
parentc287de35b862a01ec8f6bb443502b10a1ae5eac5 (diff)
downloadcalendar-cli-f655b6b64765216f9f09a2049ccee76e83c7b05f.zip
config in place now ... and actually trying to use the new cal.py-interface for practical tasks...
-rwxr-xr-xcalendar_cli/cal.py144
-rwxr-xr-xcalendar_cli/legacy.py115
-rw-r--r--calendar_cli/metadata.py2
-rw-r--r--setup.py2
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',