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.
- checksums.yaml +7 -0
- data/lib/active-icalendar-events.rb +422 -0
- 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: []
|