active-icalendar-events 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/active-icalendar-events.rb +422 -0
  3. metadata +71 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0dd12f4342f70824ee8f51ec64c712bb2277948f6e12a077cba2f6b2768c2273
4
+ data.tar.gz: af303b6d8d5fa6148528c404a7142c7e5627cf2a76f1402714a8166952b44f1b
5
+ SHA512:
6
+ metadata.gz: 38d5c9328f3c6c3817f2f2d47fd47de1143a4e472498fdcc15349d17977f2016ce121e2b1198db4987f294bd7ebc25995f18ce9fd8144606fde7a58565d63e6f
7
+ data.tar.gz: 6b4effda0225339f70f23faab8df05da8933fbd379c81cf46556e814e4fe58375513c91a4c365fc6d1143c83eb57b40e448322e8930f18fc37a6a0f61a1d8fb5
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler'
4
+ Bundler.require
5
+
6
+ require 'active_support'
7
+ require 'active_support/core_ext'
8
+ require 'date'
9
+ require 'icalendar'
10
+ require 'set'
11
+
12
+ # See https://datatracker.ietf.org/doc/html/rfc5545 for more details about icalendar format
13
+ #
14
+ # "rrule" structure that we are supporting (we only support array size of one)
15
+ # - frequency: String
16
+ # - DAILY
17
+ # - WEEKLY
18
+ # - MONTHLY
19
+ # - YEARLY
20
+ # - until: String Date (e.g. 20220324T235959Z). Can't be used with "count"
21
+ # - count: Integer. Can't be used with "until"
22
+ # - interval: Integer (modifier for frequency e.g. every 2 days)
23
+ # - by_second: UNSUPPORTED
24
+ # - by_minute: UNSUPPORTED
25
+ # - by_hour: UNSUPPORTED
26
+ # - by_day: Array of String
27
+ # - When frequency is WEEKLY:
28
+ # - MO
29
+ # - TU
30
+ # - WE
31
+ # - TH
32
+ # - FR
33
+ # - SA
34
+ # - SU
35
+ # - When frequency is MONTHLY:
36
+ # - 4FR etc. (i.e. 4th Friday of every month)
37
+ # - by_month_day: Array of Numerical Strings (When frequency is MONTHLY)
38
+ # - 1
39
+ # - 2
40
+ # - ...
41
+ # - 31
42
+ # - by_year_day: UNSUPPORTED
43
+ # - by_week_number: UNSUPPORTED
44
+ # - by_month: UNSUPPORTED
45
+ # - by_set_position: UNSUPPORTED
46
+ # - week_start: UNSUPPORTED
47
+
48
+ module ActiveIcalendarEvents
49
+ module_function
50
+
51
+ # datetime: instance of DateTime
52
+ # icalendar_data: output of Icalendar::Calendar.parse(cal_file)
53
+ def all_active_events(datetime, icalendar_data)
54
+ active_events = Set.new
55
+
56
+ format_icalendar_data(icalendar_data).each do |_, events|
57
+ recurrence_definition = events.select { |e|
58
+ !e[:recurrence_rule].empty? || !e[:recurrence_dates].empty?
59
+ }
60
+ if recurrence_definition.size > 1
61
+ raise RuntimeError, 'Should only have one event that defines the recurrence in a group'
62
+ elsif recurrence_definition.size == 1
63
+ r = recurrence_definition.first
64
+ if r[:recurrence_rule].size > 1
65
+ raise RuntimeError, 'Multiple recurrence rules not supported'
66
+ elsif r[:recurrence_rule].size == 1
67
+ # TODO: Validate the overrides
68
+ active_events << get_active_event_for_datetime(
69
+ :datetime => datetime,
70
+ :name => r[:name],
71
+ :event_start => r[:event_start],
72
+ :event_end => r[:event_end],
73
+ :recurrence_rule => r[:recurrence_rule].first,
74
+ :recurrence_dates => r[:recurrence_dates],
75
+ :excluding_dates => r[:excluding_dates],
76
+ :overrides => events.reject { |e| e == r }.group_by { |e| e[:recurrence_id] }
77
+ )
78
+ else
79
+ # TODO: Haven't bothered implementing this as Google Calendar doesn't seem to use these
80
+ raise RuntimeError, 'Not yet implemented when only recurrence_dates are provided'
81
+ end
82
+ else
83
+ # Non reccurring events
84
+ events.each { |e|
85
+ active_events.add(e[:name]) if is_event_active?(datetime, e[:event_start].to_time, e[:event_end].to_time)
86
+ }
87
+ end
88
+ end
89
+
90
+ # Remove 'nil' if it has been put in the set
91
+ active_events.delete nil
92
+
93
+ active_events
94
+ end
95
+
96
+ def format_icalendar_data(icalendar_data)
97
+ icalendar_data.first.events.map { |e|
98
+ {
99
+ name: e.summary.downcase,
100
+ event_start: e.dtstart,
101
+ event_end: e.dtend,
102
+ recurrence_rule: e.rrule,
103
+ recurrence_dates: e.rdate,
104
+ excluding_dates: e.exdate,
105
+ recurrence_id: e.recurrence_id,
106
+ uid: e.uid
107
+ }
108
+ }.group_by { |e| e[:uid] }
109
+ end
110
+
111
+ def is_event_active?(datetime, event_start, event_end)
112
+ event_start <= datetime.to_time &&
113
+ event_end > datetime.to_time
114
+ end
115
+
116
+ def until_datetime_passed?(considered_datetime, until_datetime)
117
+ !until_datetime.nil? && considered_datetime > until_datetime
118
+ end
119
+
120
+ def instance_count_exceeded?(considered_count, count)
121
+ !count.nil? && considered_count > count
122
+ end
123
+
124
+ def is_daily_event_active_for_datetime?(datetime,
125
+ event_start,
126
+ event_end,
127
+ until_datetime,
128
+ count,
129
+ interval,
130
+ excluding_dates,
131
+ overridden_dates)
132
+ event_start_considered = event_start
133
+ event_end_considered = event_end
134
+ considered_count = 1
135
+ while !until_datetime_passed?(event_start_considered, until_datetime) &&
136
+ !instance_count_exceeded?(considered_count, count) &&
137
+ event_start_considered <= datetime
138
+
139
+ if is_event_active?(datetime, event_start_considered, event_end_considered)
140
+ return !excluding_dates.include?(event_start_considered) &&
141
+ !overridden_dates.include?(event_start_considered)
142
+ end
143
+
144
+ if !excluding_dates.include?(event_start_considered)
145
+ considered_count += 1
146
+ end
147
+
148
+ event_start_considered = event_start_considered + interval.days
149
+ event_end_considered = event_end_considered + interval.days
150
+ end
151
+
152
+ false
153
+ end
154
+
155
+ def is_weekly_event_active_for_datetime?(datetime,
156
+ event_start,
157
+ event_end,
158
+ until_datetime,
159
+ count,
160
+ interval,
161
+ by_day,
162
+ excluding_dates,
163
+ overridden_dates)
164
+ event_start_considered = event_start
165
+ event_end_considered = event_end
166
+ considered_count = 1
167
+ while !instance_count_exceeded?(considered_count, count)
168
+
169
+ if by_day.empty?
170
+ if until_datetime_passed?(event_start_considered, until_datetime) ||
171
+ event_start_considered > datetime
172
+ return false
173
+ end
174
+
175
+ if is_event_active?(datetime, event_start_considered, event_end_considered)
176
+ return !excluding_dates.include?(event_start_considered) &&
177
+ !overridden_dates.include?(event_start_considered)
178
+ end
179
+
180
+ if !excluding_dates.include?(event_start_considered)
181
+ considered_count += 1
182
+ end
183
+ else
184
+ week_event_start_considered =
185
+ event_start_considered.monday? ? event_start_considered :
186
+ event_start_considered.prev_occurring(:monday)
187
+ week_event_end_considered =
188
+ (week_event_start_considered.to_time + (event_end.to_time - event_start.to_time)).to_datetime
189
+
190
+ (1..7).each { |_|
191
+ if week_event_start_considered >= event_start
192
+ if until_datetime_passed?(week_event_start_considered, until_datetime) ||
193
+ instance_count_exceeded?(considered_count, count) ||
194
+ week_event_start_considered > datetime
195
+ return false
196
+ end
197
+
198
+ day_code = week_event_start_considered.strftime("%^a").chop
199
+
200
+ if by_day.include?(day_code)
201
+ if is_event_active?(datetime, week_event_start_considered, week_event_end_considered)
202
+ return !excluding_dates.include?(week_event_start_considered) &&
203
+ !overridden_dates.include?(week_event_start_considered)
204
+ end
205
+
206
+ if !excluding_dates.include?(week_event_start_considered)
207
+ considered_count += 1
208
+ end
209
+ end
210
+ end
211
+
212
+ week_event_start_considered = week_event_start_considered + 1.days
213
+ week_event_end_considered = week_event_end_considered + 1.days
214
+ }
215
+ end
216
+
217
+ event_start_considered = event_start_considered + interval.weeks
218
+ event_end_considered = event_end_considered + interval.weeks
219
+ end
220
+
221
+ false
222
+ end
223
+
224
+ def get_nth_day_in_month(datetime, day)
225
+ matches = day.match /^([0-9]+)([A-Z]+)$/
226
+ if matches.nil?
227
+ raise RuntimeError, "Unexpected by_day format found"
228
+ end
229
+
230
+ number, day_code = matches.captures
231
+
232
+ day_label = case day_code
233
+ when 'MO'
234
+ :monday
235
+ when 'TU'
236
+ :tuesday
237
+ when 'WE'
238
+ :wednesday
239
+ when 'TH'
240
+ :thursday
241
+ when 'FR'
242
+ :friday
243
+ when 'SA'
244
+ :saturday
245
+ when 'SU'
246
+ :sunday
247
+ else
248
+ raise RuntimeError, "Unexpected day code used"
249
+ end
250
+
251
+ target_day = datetime.beginning_of_month
252
+
253
+ if target_day.strftime("%^a").chop != day_code
254
+ target_day = target_day.next_occurring(day_label)
255
+ end
256
+
257
+ (2..number.to_i).each { |_|
258
+ target_day = target_day.next_occurring(day_label)
259
+ }
260
+
261
+ target_day
262
+ end
263
+
264
+ def is_monthly_event_active_for_datetime?(datetime,
265
+ event_start,
266
+ event_end,
267
+ until_datetime,
268
+ count,
269
+ interval,
270
+ by_day,
271
+ by_month_day,
272
+ excluding_dates,
273
+ overridden_dates)
274
+ # TODO: We will ignore the contents of "by_month_day" for now and assume
275
+ # always contains one number which is the same as the day of
276
+ # "event_start". We additionally assume that "by_day" will only contain
277
+ # a single value.
278
+
279
+ event_start_considered = event_start
280
+ event_end_considered = event_end
281
+ considered_count = 1
282
+ while !until_datetime_passed?(event_start_considered, until_datetime) &&
283
+ !instance_count_exceeded?(considered_count, count) &&
284
+ event_start_considered <= datetime
285
+
286
+ if is_event_active?(datetime, event_start_considered, event_end_considered)
287
+ return !excluding_dates.include?(event_start_considered) &&
288
+ !overridden_dates.include?(event_start_considered)
289
+ end
290
+
291
+ if !excluding_dates.include?(event_start_considered)
292
+ considered_count += 1
293
+ end
294
+
295
+ if by_day.nil? || by_day.empty?
296
+ event_start_considered = event_start_considered + interval.month
297
+ event_end_considered = event_end_considered + interval.month
298
+ else
299
+ event_start_considered =
300
+ get_nth_day_in_month(event_start_considered.beginning_of_month + interval.month,
301
+ by_day.first)
302
+ event_end_considered =
303
+ (event_start_considered.to_time + (event_end.to_time - event_start.to_time)).to_datetime
304
+ end
305
+ end
306
+
307
+ false
308
+ end
309
+
310
+ def is_yearly_event_active_for_datetime?(datetime,
311
+ event_start,
312
+ event_end,
313
+ until_datetime,
314
+ count,
315
+ interval,
316
+ excluding_dates,
317
+ overridden_dates)
318
+ event_start_considered = event_start
319
+ event_end_considered = event_end
320
+ considered_count = 1
321
+ while !until_datetime_passed?(event_start_considered, until_datetime) &&
322
+ !instance_count_exceeded?(considered_count, count) &&
323
+ event_start_considered <= datetime
324
+
325
+ if is_event_active?(datetime, event_start_considered, event_end_considered)
326
+ return !excluding_dates.include?(event_start_considered) &&
327
+ !overridden_dates.include?(event_start_considered)
328
+ end
329
+
330
+ if !excluding_dates.include?(event_start_considered)
331
+ considered_count += 1
332
+ end
333
+
334
+ event_start_considered = event_start_considered + interval.years
335
+ event_end_considered = event_end_considered + interval.years
336
+ end
337
+
338
+ false
339
+ end
340
+
341
+ def get_active_event_for_datetime(datetime: DateTime.now,
342
+ name:,
343
+ event_start:,
344
+ event_end:,
345
+ recurrence_rule:,
346
+ recurrence_dates: [],
347
+ excluding_dates: [],
348
+ overrides:)
349
+ # Can return early if one of the overrides matches as they always take precendence
350
+ overrides.values.flatten.each { |e|
351
+ return e[:name] if e[:event_start] <= datetime.to_time &&
352
+ e[:event_end] > datetime.to_time
353
+ }
354
+
355
+ # Can return early if one of the recurrence dates matches and is not overridden
356
+ # Note: I've just made an assumption about how this data could be presented.
357
+ # Google Calendar does not seem to create rdates, only rrules.
358
+ (recurrence_dates - overrides.keys).each { |recurrence_event_start|
359
+ recurrence_event_end = recurrence_event_start + (event_end.to_time - event_start.to_time)
360
+ return name if is_event_active?(datetime, recurrence_event_start, recurrence_event_end)
361
+ }
362
+
363
+ until_datetime = nil
364
+ if !recurrence_rule.until.nil?
365
+ until_datetime = DateTime.parse(recurrence_rule.until)
366
+ end
367
+
368
+ case recurrence_rule.frequency
369
+ when "DAILY"
370
+ return name if is_daily_event_active_for_datetime?(
371
+ datetime,
372
+ event_start,
373
+ event_end,
374
+ until_datetime,
375
+ recurrence_rule.count,
376
+ recurrence_rule.interval.nil? ? 1 : recurrence_rule.interval,
377
+ excluding_dates,
378
+ overrides.keys
379
+ )
380
+ when "WEEKLY"
381
+ return name if is_weekly_event_active_for_datetime?(
382
+ datetime,
383
+ event_start,
384
+ event_end,
385
+ until_datetime,
386
+ recurrence_rule.count,
387
+ recurrence_rule.interval.nil? ? 1 : recurrence_rule.interval,
388
+ recurrence_rule.by_day,
389
+ excluding_dates,
390
+ overrides.keys
391
+ )
392
+ when "MONTHLY"
393
+ return name if is_monthly_event_active_for_datetime?(
394
+ datetime,
395
+ event_start,
396
+ event_end,
397
+ until_datetime,
398
+ recurrence_rule.count,
399
+ recurrence_rule.interval.nil? ? 1 : recurrence_rule.interval,
400
+ recurrence_rule.by_day,
401
+ recurrence_rule.by_month_day,
402
+ excluding_dates,
403
+ overrides.keys
404
+ )
405
+ when "YEARLY"
406
+ return name if is_yearly_event_active_for_datetime?(
407
+ datetime,
408
+ event_start,
409
+ event_end,
410
+ until_datetime,
411
+ recurrence_rule.count,
412
+ recurrence_rule.interval.nil? ? 1 : recurrence_rule.interval,
413
+ excluding_dates,
414
+ overrides.keys
415
+ )
416
+ else
417
+ throw RuntimeError, "Invalid event frequency"
418
+ end
419
+
420
+ nil
421
+ end
422
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active-icalendar-events
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - William Starling
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-12-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: icalendar
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
41
+ description:
42
+ email: w.starling+icalendar@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/active-icalendar-events.rb
48
+ homepage: https://github.com/foygl/active-icalendar-events
49
+ licenses:
50
+ - MIT
51
+ metadata: {}
52
+ post_install_message:
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.1.2
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: Get all events active at a timestamp for an icalendar file
71
+ test_files: []