Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add agendaupdate subcommand #553

Merged
merged 18 commits into from
Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
935013d
refactor: move _tsv() implementation to gcalcli.details
michaelmhoffman Aug 2, 2020
7f5e741
ci: eliminate flake8-docstrings method docstring checks for details.py
michaelmhoffman Aug 2, 2020
d46a950
refactor: derive `argparsers.DETAILS` from `details.HANDLERS`
michaelmhoffman Aug 3, 2020
6139d0b
fix: change SimpleSingleColumnHandler.header to a simple list
michaelmhoffman Aug 3, 2020
97e21b1
feat!: add header row for `gcalcli agenda --tsv`
michaelmhoffman Aug 3, 2020
784d812
feat: add `--detail id` for `gcalcli agenda --tsv`
michaelmhoffman Aug 3, 2020
72710b4
refactor: change `header` to `fieldnames` and `column` to `field`
michaelmhoffman Aug 4, 2020
abb964f
feat: add agendaupdate for title, id, location, description
michaelmhoffman Aug 4, 2020
fea6ad7
fix: gcalcli agenda to work with refactor 72710b4cb
michaelmhoffman Aug 4, 2020
ba7e543
refactor: change `Handler.patch()` to include `fieldname` argument
michaelmhoffman Aug 4, 2020
06f409a
feat: add agendaupdate for time
michaelmhoffman Aug 5, 2020
8849d71
refactor: move gcal.GoogleCalendarInterface._isallday() to utils.is_a…
michaelmhoffman Aug 5, 2020
e0780dd
feat!: gcal agenda --tsv prints empty time fields for all-day events
michaelmhoffman Aug 5, 2020
384ba5d
refactor: `details.Url` uses `details.URL_PROPS` to identify properti…
michaelmhoffman Sep 6, 2020
719f85b
feat: add agendaupdate for url
michaelmhoffman Sep 6, 2020
f14e6f3
feat: add agendaupdate for conference
michaelmhoffman Sep 6, 2020
5a3179b
refactor: add `exceptions.ReadonlyError`, `exceptions.ReadonlyCheckEr…
michaelmhoffman Sep 6, 2020
d7589ed
feat: add agendaupdate for calendar
michaelmhoffman Sep 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions gcalcli/argparsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,15 @@
import argparse
import gcalcli
from gcalcli import utils
from gcalcli.details import DETAILS
from gcalcli.deprecations import parser_allow_deprecated, DeprecatedStoreTrue
from gcalcli.printer import valid_color_name
from oauth2client import tools
from shutil import get_terminal_size
import copy as _copy
import datetime
import locale

DETAILS = ['calendar', 'location', 'length', 'reminders', 'description',
'url', 'conference', 'attendees', 'email', 'attachments', 'end']

import sys

PROGRAM_OPTIONS = {
'--client-id': {'default': gcalcli.__API_CLIENT_ID__,
Expand Down Expand Up @@ -308,6 +306,13 @@ def get_argument_parser():
help='get an agenda for a time period',
description='Get an agenda for a time period.')

agendaupdate = sub.add_parser(
'agendaupdate',
help='update calendar from agenda TSV file',
description='Update calendar from agenda TSV file.')
agendaupdate.add_argument(
'file', type=argparse.FileType('r'), nargs='?', default=sys.stdin)

sub.add_parser(
'updates',
parents=[details_parser, output_parser, updates_parser],
Expand Down
3 changes: 3 additions & 0 deletions gcalcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ def main():
elif parsed_args.command == 'agenda':
gcal.AgendaQuery(start=parsed_args.start, end=parsed_args.end)

elif parsed_args.command == 'agendaupdate':
gcal.AgendaUpdate(parsed_args.file)

elif parsed_args.command == 'updates':
gcal.UpdatesQuery(
last_updated_datetime=parsed_args.since,
Expand Down
276 changes: 276 additions & 0 deletions gcalcli/details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
"""Handlers for specific details of events."""

from collections import OrderedDict
from datetime import datetime
from itertools import chain
from typing import List

from dateutil.parser import isoparse, parse

from gcalcli.exceptions import ReadonlyError, ReadonlyCheckError
from gcalcli.utils import is_all_day

FMT_DATE = '%Y-%m-%d'
FMT_TIME = '%H:%M'
TODAY = datetime.now().date()

URL_PROPS = OrderedDict([('html_link', 'htmlLink'),
('hangout_link', 'hangoutLink')])
ENTRY_POINT_PROPS = OrderedDict([('conference_entry_point_type',
'entryPointType'),
('conference_uri', 'uri')])


def _valid_title(event):
if 'summary' in event and event['summary'].strip():
return event['summary']
else:
return '(No title)'


class Handler:
"""Handler for a specific detail of an event."""

# list of strings for fieldnames provided by this object
# XXX: py36+: change to `fieldnames: List[str]`
fieldnames = [] # type: List[str]

@classmethod
def get(cls, event):
"""Return simple string representation for columnar output."""
raise NotImplementedError

@classmethod
def patch(cls, cal, event, fieldname, value):
"""Patch event from value."""
raise NotImplementedError


class SingleFieldHandler(Handler):
"""Handler for a detail that only produces one column."""

@classmethod
def get(cls, event):
return [cls._get(event).strip()]

@classmethod
def patch(cls, cal, event, fieldname, value):
return cls._patch(event, value)


class SimpleSingleFieldHandler(SingleFieldHandler):
"""Handler for single-string details that require no special processing."""

@classmethod
def _get(cls, event):
return event.get(cls.fieldnames[0], '')

@classmethod
def _patch(cls, event, value):
event[cls.fieldnames[0]] = value


class Time(Handler):
"""Handler for dates and times."""

fieldnames = ['start_date', 'start_time', 'end_date', 'end_time']

@classmethod
def _datetime_to_fields(cls, instant, all_day):
instant_date = instant.strftime(FMT_DATE)

if all_day:
instant_time = ''
else:
instant_time = instant.strftime(FMT_TIME)

return [instant_date, instant_time]

@classmethod
def get(cls, event):
all_day = is_all_day(event)

start_fields = cls._datetime_to_fields(event['s'], all_day)
end_fields = cls._datetime_to_fields(event['e'], all_day)

return start_fields + end_fields

@classmethod
def patch(cls, cal, event, fieldname, value):
instant_name, _, unit = fieldname.partition('_')

assert instant_name in {'start', 'end'}

if unit == 'date':
instant = event[instant_name] = {}
instant_date = parse(value, default=TODAY)

instant['date'] = instant_date.isoformat()
instant['dateTime'] = None # clear any previous non-all-day time
else:
assert unit == 'time'

# If the time field is empty, do nothing.
# This enables all day events.
if not value.strip():
return

# date must be an earlier TSV field than time
instant = event[instant_name]
instant_date = isoparse(instant['date'])
instant_datetime = parse(value, default=instant_date)

instant['date'] = None # clear all-day date
instant['dateTime'] = instant_datetime.isoformat()
instant['timeZone'] = cal['timeZone']


class Url(Handler):
"""Handler for HTML and legacy Hangout links."""

fieldnames = list(URL_PROPS.keys())

@classmethod
def get(cls, event):
return [event.get(prop, '') for prop in URL_PROPS.values()]

@classmethod
def patch(cls, cal, event, fieldname, value):
if fieldname == 'html_link':
raise ReadonlyError(fieldname,
'It is not possible to verify that the value '
'has not changed. '
'Remove it from the input.')

prop = URL_PROPS[fieldname]

# Fail if the current value doesn't
# match the desired patch. This requires an additional API query for
# each row, so best to avoid attempting to update these fields.

curr_value = event.get(prop, '')

if curr_value != value:
raise ReadonlyCheckError(fieldname, curr_value, value)


class Conference(Handler):
"""Handler for videoconference and teleconference details."""

fieldnames = list(ENTRY_POINT_PROPS.keys())

@classmethod
def get(cls, event):
if 'conferenceData' not in event:
return ['', '']

data = event['conferenceData']

# only display first entry point for TSV
# https://github.com/insanum/gcalcli/issues/533
entry_point = data['entryPoints'][0]

return [entry_point.get(prop, '')
for prop in ENTRY_POINT_PROPS.values()]

@classmethod
def patch(cls, cal, event, fieldname, value):
if not value:
return

prop = ENTRY_POINT_PROPS[fieldname]

data = event.setdefault('conferenceData', {})
entry_points = data.setdefault('entryPoints', [])
if not entry_points:
entry_points.append({})

entry_point = entry_points[0]
entry_point[prop] = value


class Title(SingleFieldHandler):
"""Handler for title."""

fieldnames = ['title']

@classmethod
def _get(cls, event):
return _valid_title(event)

@classmethod
def _patch(cls, event, value):
event['summary'] = value


class Location(SimpleSingleFieldHandler):
"""Handler for location."""

fieldnames = ['location']


class Description(SimpleSingleFieldHandler):
"""Handler for description."""

fieldnames = ['description']


class Calendar(SingleFieldHandler):
"""Handler for calendar."""

fieldnames = ['calendar']

@classmethod
def _get(cls, event):
return event['gcalcli_cal']['summary']

@classmethod
def patch(cls, cal, event, fieldname, value):
curr_value = cal['summary']

if curr_value != value:
raise ReadonlyCheckError(fieldname, curr_value, value)


class Email(SingleFieldHandler):
"""Handler for emails."""

fieldnames = ['email']

@classmethod
def _get(cls, event):
return event['creator'].get('email', '')


class ID(SimpleSingleFieldHandler):
"""Handler for event ID."""

fieldnames = ['id']


HANDLERS = OrderedDict([('id', ID),
('time', Time),
('url', Url),
('conference', Conference),
('title', Title),
('location', Location),
('description', Description),
('calendar', Calendar),
('email', Email)])
HANDLERS_READONLY = {Url, Calendar}

FIELD_HANDLERS = dict(chain.from_iterable(
(((fieldname, handler)
for fieldname in handler.fieldnames)
for handler in HANDLERS.values())))

FIELDNAMES_READONLY = frozenset(fieldname
for fieldname, handler
in FIELD_HANDLERS.items()
if handler in HANDLERS_READONLY)

_DETAILS_WITHOUT_HANDLERS = ['length', 'reminders', 'attendees',
'attachments', 'end']
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to incorporate the 'end' detail into the Time handler?


DETAILS = list(HANDLERS.keys()) + _DETAILS_WITHOUT_HANDLERS
DETAILS_DEFAULT = {'time', 'title'}
13 changes: 13 additions & 0 deletions gcalcli/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ def __init__(self, message):
self.message = message


class ReadonlyError(Exception):
def __init__(self, fieldname, message):
message = 'Field {} is read-only. {}'.format(fieldname, message)
super(ReadonlyError, self).__init__(message)


class ReadonlyCheckError(ReadonlyError):
_fmt = 'Current value "{}" does not match update value "{}"'

def __init__(self, fieldname, curr_value, mod_value):
message = self._fmt.format(curr_value, mod_value)
super(ReadonlyCheckError, self).__init__(fieldname, message)

def raise_one_cal_error(cals):
raise GcalcliError(
'You must only specify a single calendar\n'
Expand Down
Loading