active-icalendar-events 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []