-
Notifications
You must be signed in to change notification settings - Fork 1
/
ical.py
290 lines (232 loc) · 9.24 KB
/
ical.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
# Forked from https://github.com/jayrav13/ical_dict
###
# Imports
#
import urllib.request
import re
from collections import defaultdict
###
# class iCal
#
# Convert a .ics file into a Dictionary object.
#
class iCal():
###
# __init__
#
# Create a new iCal instance with a string representing a url to an .ics file
#
def __init__(self, ical_url: str):
self.__ical_url = ical_url
self.__data_dict = self.__load_data()
###
# get events
#
# Get events for a given day
#
# params: day (str with the format "YYYYMMDD")
# returns: list of event_dicts
#
def get_events(self, day: str) -> [dict]:
# Assert that there are events on the given day
# If no events, return an empty list
if not day in self.__data_dict:
return []
# Get the events for the day
return self.__data_dict[day].values()
###
# get event summaries
#
# Get the summary for each event on a given day
#
# params: day (str with the format "YYYYMMDD")
# returns: list of event summaries (str0
#
def get_event_summaries(self, day: str) -> [str]:
# Get events for the day
events = self.get_events(day)
# Get summaries from each event
event_summaries = [event["SUMMARY"] for event in events]
event_summaries.sort()
return event_summaries
###
# refresh
#
# Get the latest version of the ics_file
# If `compare` is set to true,
# then it will return the difference between the old and new cals
#
def refresh(self, compare=False):
if compare:
temp = iCal(self.__ical_url)
difference = temp.compare(self)
self.__data_dict = temp.__data_dict
return difference
else:
self.__data_dict = self.__load_data()
###
# compare
#
# Compares two calendars
# Returns a list of dicts containing changes, additions, and deletions
# [{ change_type: str, event: dict, previous: ?dict }]
#
def compare(self, old_cal):
# Assert that other is of type ical
if not type(old_cal) == iCal: raise TypeError("other must be of type `iCal`")
difference = []
# Get the days for the current calendar and old calendar
new_days = self.__data_dict.keys()
old_days = old_cal.__data_dict.keys()
# Compare the dict_keys to get the days added, removed, and shared
# Set operations can be used on the dict_key class
days_added = new_days - old_days
days_removed = old_days - new_days
days_shared = new_days & old_days
# Find additions
for day in days_added:
for event in self.get_events(day):
difference.append({ "change_type": "ADDITION", "event": event })
# Find changes
for day in days_shared:
difference += self.__compare_days(day, self[day], old_cal[day])
# Find removals
for day in days_removed:
for event in old_cal.get_events(day):
difference.append({ "change_type": "REMOVAL", "event": event })
return difference
###
# compare days
#
# Given two days, find all added, removed, and updated events
# Return a list of dicts in the following format
# [{ change_type: str, event: dict, previous: ?dict }]
#
def __compare_days(self, time: str, day1: dict, day2: dict):
difference = []
# Get all event_ids from the new and old days
new_events = day1.keys()
old_events = day2.keys()
# Compare the dict_keys to get the events added, removed, and shared
# Set operations can be used on the dict_key class
events_added = new_events - old_events
events_removed = old_events - new_events
events_shared = new_events & old_events
# Find additions
for event_id in events_added:
difference.append({ "change_type": "ADDITION", "event": day1[event_id] })
# Find changes
for event_id in events_shared:
current_event = day1[event_id]
previous_event = day2[event_id]
if current_event["SUMMARY"] != previous_event["SUMMARY"]:
difference.append({ "change_type": "UPDATE", "event": current_event, "previous_event": previous_event })
# Find removals
for event_id in events_removed:
difference.append({ "change_type": "REMOVAL", "event": day2[event_id] })
return difference
###
# load data
#
# load the calendar from the url and store it as a dict
#
def __load_data(self) -> dict:
# Fetch ical from url
# content: str = the ical file, with every newline indicated by "\r\n"
content = urllib.request.urlopen(self.__ical_url).read().decode()
# Dict for the calendar
# structure of ouput:
# { date (str YYYYMMDD):
# { unique_id (str):
# { event_dict }
# }
# }
output = defaultdict(dict)
# Regex Expression to match events
# This captures all of the content of the vevent as a str,
# with each vevent value on a newline (\r\n)
event_regex = r"BEGIN:VEVENT\r\n(.+?(?=END:VEVENT))"
event_matches = re.finditer(event_regex, content, re.MULTILINE | re.DOTALL)
# Iterate over matches
for event in event_matches:
# Split the vevent str by its lines,
# so that each line contains a value
# ex: ['DTSTART;VALUE=DATE:20191221', 'DTEND;VALUE=DATE:20191222', 'UID:0aolsmdd0eufajj0', 'SUMMARY:D1:Chase']
event_list = event.group(1).split("\r\n")
# Convert that list into a dict
# ex: ['SUMMARY:D1:Chase', 'UID:0aolsmdd0eufajj0'] -> { 'SUMMARY': 'D1:Chase', 'UID': '0aolsmdd0eufajj0' }
event_dict = self.__list_to_dict(event_list)
# Get the start date of the event
# If the key is not in the event dict,
# then it is not an all day event.
# Handle this cause by get the `DTSTART` and taking the substring to remove the time, leaving only the date.
# Add this start date to the dict with key `DTSTART;VALUE=DATE`, so there won't be other errors later on.
if "DTSTART;VALUE=DATE" in event_dict:
start_date = event_dict["DTSTART;VALUE=DATE"]
else:
start_date = event_dict["DTSTART;TZID=America/Los_Angeles"][0:8]
event_dict["DTSTART;VALUE=DATE"] = start_date
# Get event's unique id
unique_id = event_dict["UID"]
# Add the event to the output
output[start_date][unique_id] = event_dict
# Return the calendar dict object
return output
###
# __list_to_dict
#
# Given a list of .ics lines, return the list as a Dictionary object.
#
def __list_to_dict(self, data: list) -> dict:
# Assert that data is of type list
if not isinstance(data, list): raise Exception(self.__error_messages("array_required"))
# Event dict
event_dict = {}
# Iterate over all ical values
for line in data:
# Split line into key and value
# ex: "SUMMARY:D1: Chase" -> ["SUMMARY", "D1: Chase"]
elements = line.split(':', 1)
# Assert that elements has a key and a pair
# If it doesn't, ignore the line
if len(elements) != 2:
continue
# Add the key/value pair to the event_dict
# elements[0] is the key. ex: "SUMMARY"
# elements[1] is the value. ex: "D1: Chase"
event_dict[elements[0]] = elements[1]
return event_dict
###
# __error_messages
#
# Return an error message given an identifying key.
#
def __error_messages(self, key):
messages = {
"invalid_file": "This file is invalid. A .ics file is identified as a file in which the first line is \"BEGIN:VCALENDAR\".",
"no_events": "No Events, identified by the \"BEGIN:VEVENT\" line, have been found.",
"array_required": "An array is required to convert data to JSON. A non-array parameter has been provided.",
"invalid_element": "The following line does not follow expected convention"
}
if key not in messages: return "An unknown error has occured."
return messages[key]
###
# override getitem
#
# Add ability to use the [] operator
# Returns the value for key in self.__data_dict
def __getitem__(self, key):
return self.__data_dict[key]
###
# override str
#
# return the dict as a str
def __str__(self) -> str:
return str(self.__data_dict)
###
# Testing the above.
#
if __name__ == '__main__':
ical_url = "https://calendar.google.com/calendar/ical/uci.edu_5jklevjtcuktlt4ltl8mlfc3eo%40group.calendar.google.com/private-0b64929a1db93a0150220deec82a9e3a/basic.ics"
ical = iCal(ical_url)
print(ical)