-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy path__init__.py
450 lines (402 loc) · 18.2 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
"""A Mycroft AI skill for voice interaction with a Nextcloud calendar.
Main class 'NextcloudCalendar' contains intent handler
for intent files in directory locale/en-us. Each intent
handler gets the relevant calendar information for
the specific intent and chooses a suitable response
from the dialog files.
"""
import datetime as dt
from datetime import datetime
import re
from mycroft import MycroftSkill, intent_handler
from mycroft.util.format import nice_time, nice_date
from mycroft.util.parse import extract_datetime, extract_duration, extract_number
from mycroft.util.time import default_timezone
from .cal_dav_interface import CalDavInterface
def is_multiple_fulldays_event(startdatetime, enddatetime):
"""
Checks if an event is a multi-day & full-day event
by comparing start datetime and end datetime of the event
:param startdatetime: Start datetime of the event
:param enddatetime: End datetime of the event
:return: [bool] True if multi-day & full-day
"""
fullday = is_fullday_event(startdatetime, enddatetime)
delta = enddatetime - startdatetime
return fullday and delta.days > 1
def is_fullday_event(startdatetime, enddatetime):
"""
Checks if an event is a full-day event by comparing start datetime and end datetime of the event
:param startdatetime: Start datetime of the event
:param enddatetime: End datetime of the event
:return: [bool] True if full-day
"""
return (
startdatetime.hour == 0 and
startdatetime.minute == 0 and
startdatetime.second == 0 and
enddatetime.hour == 0 and
enddatetime.minute == 0 and
enddatetime.second == 0
)
def get_title_date_from_message(message):
"""
Receives the message object of an intent handler and checks
if it contains a title or date. If yes it is returned, else
None is returned
:param message: Message object of the intent handler
:return: (title, date) if exists, (None, None) otherwise
"""
title = None
date = None
if "title" in message.data:
title = message.data["title"]
extracted = extract_datetime(message.data['utterance'], datetime.today())
if extracted is not None:
date = extracted[0]
return title, date
class NextcloudCalendar(MycroftSkill):
"""
The class contains intent handlers
for intent files in directory locale/en-us. Each intent
handler gets the relevant calendar information for
the specific intent and chooses a suitable response
from the dialog files.
"""
def __init__(self):
MycroftSkill.__init__(self)
self.caldav_interface = None
self.timezone = None
def get_intro_message(self):
"""
Overwrites get intro message of MycroftSkill class. Returns string for output
after first installation.
:return: string for output
"""
return "To use the nextcloud calendar skill, please visit https://home.mycroft.ai/skills" \
" to set Nextcloud credentials. " \
"After the settings have been updated on your device you can say" \
" 'Connect to the calendar'"
def initialize(self):
"""
Checks if the credentials (url, username and password) are set on Mycroft website.
If not, a corresponding dialog will be displayed.
If the credentials are present, a CalDavInterface instance
is created.
:return: [bool] False if credentials are not set
"""
self.timezone = default_timezone()
username = self.settings.get('username')
password = self.settings.get('password')
url = self.settings.get('url')
if not username or not password or not url:
self.speak_dialog('err.nextcloud.settings.missing')
return False
self.caldav_interface = CalDavInterface(
url,
username,
password,
self.timezone
)
self.log.info(f"Initialized CaldDavInterface with timezone {self.timezone}")
return True
@intent_handler('connect.to.calendar.intent')
def handle_connect_to_calendar(self):
"""
Handler for intent to force the skill to connect to the nextcloud calendar.
Especially used when settings from home.mycroft.ai were updated.
:returns success of connect
"""
username = self.settings.get('username')
password = self.settings.get('password')
url = self.settings.get('url')
if not username or not password or not url:
self.speak_dialog('err.nextcloud.settings.missing')
return False
self.caldav_interface = CalDavInterface(
url,
username,
password,
self.timezone
)
self.log.info(f"Initialized CaldDavInterface with timezone {self.timezone}")
return True
@intent_handler('get.next.appointment.intent')
def handle_get_next_appointment(self, message):
"""
Generates the response for the intent asking for the next appointment
in the connected Nextcloud calendar.
:param message: The speech input message. Unused in this method.
"""
del message # unused by handler
next_event = self.caldav_interface.get_next_event()
output_data = {}
dialog_filename = "next.appointment"
if next_event is not None:
title = next_event["title"]
startdate_time = next_event["starttime"]
enddate_time = next_event["endtime"]
output_data["startdate"] = nice_date(startdate_time)
if is_multiple_fulldays_event(startdate_time, enddate_time):
output_data["enddate"] = nice_date(enddate_time)
else:
if not is_fullday_event(startdate_time, enddate_time):
output_data["time"] = nice_time(startdate_time)
if title is not None:
output_data["title"] = title
# because we are using Python v3.7.x
# the order of the keys of the dictionary is the the same as inserted,
# so we can iterate over the keys to generate the correct dialog filenames
for key in output_data:
dialog_filename += "." + key
self.speak_dialog(dialog_filename, output_data)
@intent_handler('get.appointment.date.intent')
def handle_get_appointment_date(self, message):
"""
Handles the intent asking for events on a specific date.
Checks the calendar on the specified date first,
then responses with the number of planned events.
If it's not 0, the user is asked if the events
should be listed. If the user responds with 'yes'
all events are listed with title, start and end time.
:param message: The speech input message. Used to extract the specified date
"""
self.log.info(f"Intent message: {message.data['utterance']}")
extracted_date = extract_datetime(message.data['utterance'], datetime.now(self.timezone))[0]
self.log.debug(f"Extracted date(s): {extracted_date}")
events = self.caldav_interface.get_events_for_date(extracted_date)
self.log.debug(f"Events on {extracted_date}: {events}")
if len(events) == 0:
self.speak_dialog(
"no.events.on.date",
{"date": nice_date(extracted_date, now=datetime.now(self.timezone))}
)
else:
self.speak_dialog(
"number.events.on.date",
{
"date": nice_date(extracted_date, now=datetime.now(self.timezone)),
"number_of_appointments": len(events),
"plural": "s" if len(events) > 1 else ""
}
)
list_events = self.ask_yesno(
"list.events.of.date",
{"date": nice_date(extracted_date, now=datetime.now(self.timezone))}
)
if list_events == "yes":
self.speak_dialog(
"events.on.date",
{"date": nice_date(extracted_date, now=datetime.now(self.timezone))}
)
for event in events:
title = event["title"]
startdate_time = event["starttime"]
enddate_time = event["endtime"]
self.speak_dialog(
"event.details",
{
"title": title,
"starttime": nice_time(startdate_time, use_ampm=True),
"endtime": nice_time(enddate_time, use_ampm=True)
}
)
@intent_handler('delete.event.intent')
def handle_delete_event(self, message):
"""
Handles the intent for deletion of an event. To delete the correct event
this steps are followed:
1. Check if a title or date of the event, which is going to be deleted, is specified.
2. If information is missing or the given information matches to multiple
events the user is asked for more information to select a unique event that
should be deleted.
3. The user is asked for confirmation before an event is deleted finally
:param message: the intent message
:return none
"""
event = self.select_event_for_altering(message, "delete")
self.delete_event_on_confirmation(event)
@intent_handler("rename.event.intent")
def handle_rename_event(self, message):
"""
Handles the intent for renaming an event. The correct event is selected
based on given title or date. The event summary is changed to the new defined
event title.
:param message: the intent message
:return: None
"""
event = self.select_event_for_altering(message, "rename")
self.rename_event(event)
@intent_handler("create.event.intent")
def handle_create_event(self, message):
"""
Handler to create a new event in the calendar. It checked first
if a title or date is given in the intent message. Then the user is
asked for missing information as start time, duration or full-day event.
If all information are present the event is created using the CalDav interface.
:param message: the intent message
:return: None
"""
title, date = get_title_date_from_message(message)
fullday = False
while title is None:
title = self.get_response("ask.for.title", {"action": "create"})
self.log.info(f"set title of new event to: {title}")
if date is None:
date_response = self.get_response("ask.for.date")
date, rest = extract_datetime(date_response)
self.log.info(f"set date of new event to: {date}")
title = self.remove_time_from_title(title)
if date.time() == dt.time(0):
time, rest = None, None
pattern = re.compile(r"full\s*day")
while time is None or time.time() == dt.time(0):
rest = self.get_response("new.event.starttime", {"event": title})
if rest is not None:
extracted = extract_datetime(rest)
if extracted is not None:
time, rest = extracted
if re.search(pattern, rest):
fullday = True
break
if not fullday:
date = datetime.combine(date.date(), time.time())
duration = None
while duration is None:
if not fullday:
duration_utterance = self.get_response("ask.duration.new.event")
duration, rest = extract_duration(duration_utterance)
else:
duration_utterance = self.get_response("ask.fullday.duration")
duration = extract_number(duration_utterance)
self.log.info(f"New calendar event will be created. Title: {title}, Date: {str(date)}")
self.caldav_interface.create_new_event(title, date, duration, fullday)
self.speak_dialog("successful.create.event", {"event": title, "date": nice_date(date)})
def select_event_for_altering(self, message, action):
"""
Method that is used to select the correct event to
rename/delete based on title and date information
given in the intent message.
If necessary the user is asked for more information to
get the correct unique event.
:param message: the intent message
:return: the event that should be renamed/deleted as a python dict
"""
title, date = get_title_date_from_message(message)
if title is not None:
title = self.remove_time_from_title(title)
self.log.info(f"Received altering intent with {title} and {date}")
if title is None and date is None:
title = self.get_response("ask.for.title", {"action": action})
if date is not None:
return self.select_event_date_not_none(title, date)
if title is not None:
return self.select_event_title_not_none(title)
return None
def select_event_title_not_none(self, title):
"""
If just the title of an event that should be deleted is given this
method is used to handle the selection of the correct event if there are
multiple events containing the defined title string. When the event is
found the user is asked for confirmation and the event is deleted.
:param title: string that the event title needs to contain
:return: None
"""
events_matching_title = self.caldav_interface.get_events_with_title(title)
if len(events_matching_title) == 0:
self.speak_dialog("no.matching.event")
if len(events_matching_title) == 1:
return events_matching_title[0]
if len(events_matching_title) > 1:
selection = [f"{event['title']} " +
f"for {nice_date(event['starttime'])} " +
f"at {nice_time(event['starttime'])}"
for event in events_matching_title]
self.speak_dialog("multiple.matching.events", {"detail": "title"})
selected_event_details = self.ask_selection(
selection, "event.selection.delete", None, 0.5
)
if selected_event_details is not None:
event = events_matching_title[selection.index(selected_event_details)]
return event
self.speak_dialog("no.event.changed", {"action": "deleted"})
return None
def select_event_date_not_none(self, title, date):
"""
If the date of an event that should be deleted is given this
method is used to handle the selection of the correct event if there are
multiple events for that date. When the event is
found the user is asked for confirmation and the event is deleted.
:param date: datetime of the event that should be deleted
:return: None
"""
events_on_date = self.caldav_interface.get_events_for_date(date)
if title is not None:
event = next((event for event in events_on_date if event["title"] == title), None)
if event is not None:
return event
if len(events_on_date) == 0:
self.speak_dialog("no.events.events.on.date", {"date": nice_date(date)})
if len(events_on_date) == 1:
return events_on_date[0]
if len(events_on_date) > 1:
title_of_events = [event["title"] for event in events_on_date
if event["title"] is not None]
self.speak_dialog("multiple.matching.events", {"detail": "date"})
title = self.ask_selection(title_of_events, "event.selection.delete", None, 0.7)
event = next((event for event in events_on_date if event["title"] == title), None)
return event
return None
def delete_event_on_confirmation(self, event):
"""
Asks the user for confirmation before a event is deleted. If user confirms the
event is deleted from the Nextcloud calendar.
:param event: dictionary with the details of the event
"""
if event is not None:
confirm = self.ask_yesno(
'confirm.delete.event',
{"title": event["title"], "date": nice_date(event["starttime"])}
)
if confirm == "yes":
self.caldav_interface.delete_event(event)
self.speak_dialog(
"successful.delete.event",
{"title": event["title"], "date": nice_date(event["starttime"])}
)
return
self.speak_dialog("no.event.changed", {"action": "deleted"})
def rename_event(self, event):
"""
Is called when a specific event for altering is found. The user is asked for a new title
of the event and the event title is changed using the CalDav interface.
:param event: a specific event in python dict representation
:return: None
"""
if event is not None:
new_title = self.get_response("rename.desired.title", {"title": event['title']})
if new_title is not None:
self.caldav_interface.rename_event(event, new_title)
self.speak_dialog("successful.rename.event", {"new_title": new_title})
return
self.speak_dialog("no.event.changed", {"action": "renamed"})
def remove_time_from_title(self, title):
"""
If the parsed title contains date or time it is removed from it
:param title: parsed title of the intent message
:return: cleaned up title
"""
extracted = extract_datetime(title, datetime.now(self.timezone))
if extracted is not None:
date_in_title, rest = extracted
if date_in_title is not None:
self.log.info("Event title included date")
date_said = title.replace(rest, "")
title = title.replace(date_said, "")
return title
def create_skill():
"""
Creates an instance of the skill class. Required from MycroftAI
:return: instance of skill class
"""
return NextcloudCalendar()