Skip to content

Commit bc00f41

Browse files
feat: Add agendaupdate subcommand (#553)
* feat: add agendaupdate for calendar This treats calendar as a readonly field. Actually moving calendar would be a bit more involved, and would necessitate: - changing agendaupdate from its current one-calendar-only restriction - using the events().move() method to do the move. It's not done by events().patch() That can be a future feature. Partially implements #550. * feat!: add header row for `gcalcli agenda --tsv` BREAKING CHANGE: having a header row will break any software that assumes that the output of `gcalcli agenda --tsv` doesn't have one. Also slightly refactored the output printing to use print() like in most parts of the code. * feat!: gcal agenda --tsv prints empty time fields for all-day events BREAKING CHANGE: this changes the previous behavior which was to depict all-day events as starting and ending at midnight. * feat: add `--detail id` for `gcalcli agenda --tsv` * feat: add agendaupdate for title, id, location, description argparsers.py, cli.py: add `agendaupdate` subcommand gcal.py: add `AgendaUpdate()` details.py: - `Title`, `SimpleSingleFieldHandler`: add `patch()` - add `FIELD_HANDLERS` * feat: add agendaupdate for time Round trips don't work for all day events. The current output format is to present them as events starting and ending at midnight of the same day. But this keeps us from having events starting and ending at midnight of the same day. Also: - change signature of `Handler.patch()` to include `cal`, which is necessary to extract the timezone - change default of `Handler.fieldnames` from `None` to `[]` to silence a mypy warning. * feat: add agendaupdate for url Both these fields are read-only. If html_link is in the input, always fail. If hangout_link is in the input, check against the current value. If they don't match, fail. * feat: add agendaupdate for conference Partially implements #522. * refactor: move _tsv() implementation to gcalcli.details Refactor `gcalcli.gcal.GoogleCalendarInterface._tsv()`, replacing the long list of if statements with a modular design using a series of `gcalcli.details.Handler` classes. Each `Handler` represents the set of event properties that are captured by each item of `gcalcli.argparsers.DETAILS`. So far only the `header` class attribute and the `get()` methods are implemented. This is what is necessary for TSV output. * fix: change SimpleSingleColumnHandler.header to a simple list Can't use a property on a classmethod without making a custom metaclass. Without that, the property is only enacted upon class instantiation, which we aren't doing here. A custom metaclass is too magic, and thus far we have avoided instantiating what would be singleton objects. Also changed `SimpleSingleColumnHandler._get()` to use `cls.header[0]` instead of `cls._key` which is removed. * ci: eliminate flake8-docstrings method docstring checks for details.py These are mostly overriding pure virtual classes and adding a docstring to each one would be superfluous and make the code harder to read. * refactor: derive `argparsers.DETAILS` from `details.HANDLERS` Set `gcalcli.argparsers.DETAILS` to a superset of `gcalcli.details.HANDLERS.keys()` to avoid duplication. * refactor: change `header` to `fieldnames` and `column` to `field` * refactor: change `Handler.patch()` to include `fieldname` argument This will enable `Handler`s with multiple fields to work for patching. Necessary for `Time.patch()` and `Conference.patch()` Also introduce `SingleFieldHandler.patch(cls,` ...`)` which dispatches to `cls._patch()`. * refactor: move gcal.GoogleCalendarInterface._isallday() to utils.is_all_day() This enables its use by details.py. * refactor: `details.Url` uses `details.URL_PROPS` to identify properties to get The new `details.URL_PROPS` constant `OrderedDict` will also work for use in `Url.patch()`, which should be implemented in the next commit. This will allow automated handling of two property/fieldname pairs for this `Handler`. * refactor: add `exceptions.ReadonlyError`, `exceptions.ReadonlyCheckError`
1 parent aa287f9 commit bc00f41

File tree

7 files changed

+390
-64
lines changed

7 files changed

+390
-64
lines changed

gcalcli/argparsers.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22
import argparse
33
import gcalcli
44
from gcalcli import utils
5+
from gcalcli.details import DETAILS
56
from gcalcli.deprecations import parser_allow_deprecated, DeprecatedStoreTrue
67
from gcalcli.printer import valid_color_name
78
from oauth2client import tools
89
from shutil import get_terminal_size
910
import copy as _copy
1011
import datetime
1112
import locale
12-
13-
DETAILS = ['calendar', 'location', 'length', 'reminders', 'description',
14-
'url', 'conference', 'attendees', 'email', 'attachments', 'end']
15-
13+
import sys
1614

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

309+
agendaupdate = sub.add_parser(
310+
'agendaupdate',
311+
help='update calendar from agenda TSV file',
312+
description='Update calendar from agenda TSV file.')
313+
agendaupdate.add_argument(
314+
'file', type=argparse.FileType('r'), nargs='?', default=sys.stdin)
315+
311316
sub.add_parser(
312317
'updates',
313318
parents=[details_parser, output_parser, updates_parser],

gcalcli/cli.py

+3
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ def main():
151151
elif parsed_args.command == 'agenda':
152152
gcal.AgendaQuery(start=parsed_args.start, end=parsed_args.end)
153153

154+
elif parsed_args.command == 'agendaupdate':
155+
gcal.AgendaUpdate(parsed_args.file)
156+
154157
elif parsed_args.command == 'updates':
155158
gcal.UpdatesQuery(
156159
last_updated_datetime=parsed_args.since,

gcalcli/details.py

+276
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Handlers for specific details of events."""
2+
3+
from collections import OrderedDict
4+
from datetime import datetime
5+
from itertools import chain
6+
from typing import List
7+
8+
from dateutil.parser import isoparse, parse
9+
10+
from gcalcli.exceptions import ReadonlyError, ReadonlyCheckError
11+
from gcalcli.utils import is_all_day
12+
13+
FMT_DATE = '%Y-%m-%d'
14+
FMT_TIME = '%H:%M'
15+
TODAY = datetime.now().date()
16+
17+
URL_PROPS = OrderedDict([('html_link', 'htmlLink'),
18+
('hangout_link', 'hangoutLink')])
19+
ENTRY_POINT_PROPS = OrderedDict([('conference_entry_point_type',
20+
'entryPointType'),
21+
('conference_uri', 'uri')])
22+
23+
24+
def _valid_title(event):
25+
if 'summary' in event and event['summary'].strip():
26+
return event['summary']
27+
else:
28+
return '(No title)'
29+
30+
31+
class Handler:
32+
"""Handler for a specific detail of an event."""
33+
34+
# list of strings for fieldnames provided by this object
35+
# XXX: py36+: change to `fieldnames: List[str]`
36+
fieldnames = [] # type: List[str]
37+
38+
@classmethod
39+
def get(cls, event):
40+
"""Return simple string representation for columnar output."""
41+
raise NotImplementedError
42+
43+
@classmethod
44+
def patch(cls, cal, event, fieldname, value):
45+
"""Patch event from value."""
46+
raise NotImplementedError
47+
48+
49+
class SingleFieldHandler(Handler):
50+
"""Handler for a detail that only produces one column."""
51+
52+
@classmethod
53+
def get(cls, event):
54+
return [cls._get(event).strip()]
55+
56+
@classmethod
57+
def patch(cls, cal, event, fieldname, value):
58+
return cls._patch(event, value)
59+
60+
61+
class SimpleSingleFieldHandler(SingleFieldHandler):
62+
"""Handler for single-string details that require no special processing."""
63+
64+
@classmethod
65+
def _get(cls, event):
66+
return event.get(cls.fieldnames[0], '')
67+
68+
@classmethod
69+
def _patch(cls, event, value):
70+
event[cls.fieldnames[0]] = value
71+
72+
73+
class Time(Handler):
74+
"""Handler for dates and times."""
75+
76+
fieldnames = ['start_date', 'start_time', 'end_date', 'end_time']
77+
78+
@classmethod
79+
def _datetime_to_fields(cls, instant, all_day):
80+
instant_date = instant.strftime(FMT_DATE)
81+
82+
if all_day:
83+
instant_time = ''
84+
else:
85+
instant_time = instant.strftime(FMT_TIME)
86+
87+
return [instant_date, instant_time]
88+
89+
@classmethod
90+
def get(cls, event):
91+
all_day = is_all_day(event)
92+
93+
start_fields = cls._datetime_to_fields(event['s'], all_day)
94+
end_fields = cls._datetime_to_fields(event['e'], all_day)
95+
96+
return start_fields + end_fields
97+
98+
@classmethod
99+
def patch(cls, cal, event, fieldname, value):
100+
instant_name, _, unit = fieldname.partition('_')
101+
102+
assert instant_name in {'start', 'end'}
103+
104+
if unit == 'date':
105+
instant = event[instant_name] = {}
106+
instant_date = parse(value, default=TODAY)
107+
108+
instant['date'] = instant_date.isoformat()
109+
instant['dateTime'] = None # clear any previous non-all-day time
110+
else:
111+
assert unit == 'time'
112+
113+
# If the time field is empty, do nothing.
114+
# This enables all day events.
115+
if not value.strip():
116+
return
117+
118+
# date must be an earlier TSV field than time
119+
instant = event[instant_name]
120+
instant_date = isoparse(instant['date'])
121+
instant_datetime = parse(value, default=instant_date)
122+
123+
instant['date'] = None # clear all-day date
124+
instant['dateTime'] = instant_datetime.isoformat()
125+
instant['timeZone'] = cal['timeZone']
126+
127+
128+
class Url(Handler):
129+
"""Handler for HTML and legacy Hangout links."""
130+
131+
fieldnames = list(URL_PROPS.keys())
132+
133+
@classmethod
134+
def get(cls, event):
135+
return [event.get(prop, '') for prop in URL_PROPS.values()]
136+
137+
@classmethod
138+
def patch(cls, cal, event, fieldname, value):
139+
if fieldname == 'html_link':
140+
raise ReadonlyError(fieldname,
141+
'It is not possible to verify that the value '
142+
'has not changed. '
143+
'Remove it from the input.')
144+
145+
prop = URL_PROPS[fieldname]
146+
147+
# Fail if the current value doesn't
148+
# match the desired patch. This requires an additional API query for
149+
# each row, so best to avoid attempting to update these fields.
150+
151+
curr_value = event.get(prop, '')
152+
153+
if curr_value != value:
154+
raise ReadonlyCheckError(fieldname, curr_value, value)
155+
156+
157+
class Conference(Handler):
158+
"""Handler for videoconference and teleconference details."""
159+
160+
fieldnames = list(ENTRY_POINT_PROPS.keys())
161+
162+
@classmethod
163+
def get(cls, event):
164+
if 'conferenceData' not in event:
165+
return ['', '']
166+
167+
data = event['conferenceData']
168+
169+
# only display first entry point for TSV
170+
# https://github.com/insanum/gcalcli/issues/533
171+
entry_point = data['entryPoints'][0]
172+
173+
return [entry_point.get(prop, '')
174+
for prop in ENTRY_POINT_PROPS.values()]
175+
176+
@classmethod
177+
def patch(cls, cal, event, fieldname, value):
178+
if not value:
179+
return
180+
181+
prop = ENTRY_POINT_PROPS[fieldname]
182+
183+
data = event.setdefault('conferenceData', {})
184+
entry_points = data.setdefault('entryPoints', [])
185+
if not entry_points:
186+
entry_points.append({})
187+
188+
entry_point = entry_points[0]
189+
entry_point[prop] = value
190+
191+
192+
class Title(SingleFieldHandler):
193+
"""Handler for title."""
194+
195+
fieldnames = ['title']
196+
197+
@classmethod
198+
def _get(cls, event):
199+
return _valid_title(event)
200+
201+
@classmethod
202+
def _patch(cls, event, value):
203+
event['summary'] = value
204+
205+
206+
class Location(SimpleSingleFieldHandler):
207+
"""Handler for location."""
208+
209+
fieldnames = ['location']
210+
211+
212+
class Description(SimpleSingleFieldHandler):
213+
"""Handler for description."""
214+
215+
fieldnames = ['description']
216+
217+
218+
class Calendar(SingleFieldHandler):
219+
"""Handler for calendar."""
220+
221+
fieldnames = ['calendar']
222+
223+
@classmethod
224+
def _get(cls, event):
225+
return event['gcalcli_cal']['summary']
226+
227+
@classmethod
228+
def patch(cls, cal, event, fieldname, value):
229+
curr_value = cal['summary']
230+
231+
if curr_value != value:
232+
raise ReadonlyCheckError(fieldname, curr_value, value)
233+
234+
235+
class Email(SingleFieldHandler):
236+
"""Handler for emails."""
237+
238+
fieldnames = ['email']
239+
240+
@classmethod
241+
def _get(cls, event):
242+
return event['creator'].get('email', '')
243+
244+
245+
class ID(SimpleSingleFieldHandler):
246+
"""Handler for event ID."""
247+
248+
fieldnames = ['id']
249+
250+
251+
HANDLERS = OrderedDict([('id', ID),
252+
('time', Time),
253+
('url', Url),
254+
('conference', Conference),
255+
('title', Title),
256+
('location', Location),
257+
('description', Description),
258+
('calendar', Calendar),
259+
('email', Email)])
260+
HANDLERS_READONLY = {Url, Calendar}
261+
262+
FIELD_HANDLERS = dict(chain.from_iterable(
263+
(((fieldname, handler)
264+
for fieldname in handler.fieldnames)
265+
for handler in HANDLERS.values())))
266+
267+
FIELDNAMES_READONLY = frozenset(fieldname
268+
for fieldname, handler
269+
in FIELD_HANDLERS.items()
270+
if handler in HANDLERS_READONLY)
271+
272+
_DETAILS_WITHOUT_HANDLERS = ['length', 'reminders', 'attendees',
273+
'attachments', 'end']
274+
275+
DETAILS = list(HANDLERS.keys()) + _DETAILS_WITHOUT_HANDLERS
276+
DETAILS_DEFAULT = {'time', 'title'}

gcalcli/exceptions.py

+13
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ def __init__(self, message):
88
self.message = message
99

1010

11+
class ReadonlyError(Exception):
12+
def __init__(self, fieldname, message):
13+
message = 'Field {} is read-only. {}'.format(fieldname, message)
14+
super(ReadonlyError, self).__init__(message)
15+
16+
17+
class ReadonlyCheckError(ReadonlyError):
18+
_fmt = 'Current value "{}" does not match update value "{}"'
19+
20+
def __init__(self, fieldname, curr_value, mod_value):
21+
message = self._fmt.format(curr_value, mod_value)
22+
super(ReadonlyCheckError, self).__init__(fieldname, message)
23+
1124
def raise_one_cal_error(cals):
1225
raise GcalcliError(
1326
'You must only specify a single calendar\n'

0 commit comments

Comments
 (0)