jscalendar 0.1.0
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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/SECURITY.md +9 -0
- data/data/enums.yml +450 -0
- data/data/types.yml +706 -0
- data/lib/jscalendar/calendar.rb +75 -0
- data/lib/jscalendar/errors.rb +27 -0
- data/lib/jscalendar/model.rb +200 -0
- data/lib/jscalendar/patch_object.rb +84 -0
- data/lib/jscalendar/recurrence.rb +194 -0
- data/lib/jscalendar/temporal.rb +118 -0
- data/lib/jscalendar/time_zone.rb +99 -0
- data/lib/jscalendar/types.rb +210 -0
- data/lib/jscalendar/validator.rb +400 -0
- data/lib/jscalendar/version.rb +5 -0
- data/lib/jscalendar.rb +55 -0
- data/sig/jscalendar.rbs +652 -0
- metadata +117 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
class Model
|
|
5
|
+
def self.build(**properties)
|
|
6
|
+
data = { "@type" => name.split("::").last }
|
|
7
|
+
data.merge!("uid" => SecureRandom.uuid, "updated" => Time.now.utc.iso8601) if %w[Event Task Group].include?(data["@type"])
|
|
8
|
+
data.merge!(camel_properties(properties))
|
|
9
|
+
item = new(data)
|
|
10
|
+
yield item if block_given?
|
|
11
|
+
item.validate!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.camel_properties(value)
|
|
15
|
+
case value
|
|
16
|
+
when Hash
|
|
17
|
+
value.to_h { |key, item| [key.is_a?(Symbol) ? key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } : key, camel_properties(item)] }
|
|
18
|
+
when Array then value.map { |item| camel_properties(item) }
|
|
19
|
+
else value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def localize(language)
|
|
24
|
+
patch = @data.fetch("localizations", {})[language]
|
|
25
|
+
return self.class.new(to_h, time_zones: @time_zones, **@limits) unless patch
|
|
26
|
+
|
|
27
|
+
data = PatchObject.new(PatchObject.localization(patch), **@limits).apply(to_h, validate: false)
|
|
28
|
+
data["locale"] = language
|
|
29
|
+
self.class.new(data, strict: true, time_zones: @time_zones, **@limits)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module RecurringCalendar
|
|
34
|
+
def start_time(time_zone: nil)
|
|
35
|
+
value = @data["start"] || @data["due"]
|
|
36
|
+
raise ValidationError, "calendar has no start or due" unless value
|
|
37
|
+
|
|
38
|
+
zone = @data["timeZone"] || time_zone
|
|
39
|
+
raise UnsupportedError, "floating time requires time_zone" unless zone
|
|
40
|
+
|
|
41
|
+
TimeZoneResolver.new(zone, effective_time_zones).to_utc(LocalDateTime.new(value))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def end_time(time_zone: nil)
|
|
45
|
+
zone = @data["timeZone"] || time_zone
|
|
46
|
+
raise UnsupportedError, "floating time requires time_zone" unless zone
|
|
47
|
+
|
|
48
|
+
return TimeZoneResolver.new(zone, effective_time_zones).to_utc(LocalDateTime.new(@data["due"])) if is_a?(Task) && @data["due"]
|
|
49
|
+
|
|
50
|
+
duration = Duration.new(@data.fetch("duration", "PT0S"))
|
|
51
|
+
local = duration.add_to(@data.fetch("start"), time_zone: zone, time_zones: effective_time_zones)
|
|
52
|
+
TimeZoneResolver.new(zone, effective_time_zones).to_utc(local)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def occurrences(from:, to:, limit: 1000, time_zone: nil, timeout: 2.0)
|
|
56
|
+
validate!
|
|
57
|
+
Occurrences.new(self, from: from, to: to, limit: limit, time_zone: time_zone, timeout: timeout)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_recurrence_rule(**properties)
|
|
61
|
+
rule = Model.camel_properties(properties)
|
|
62
|
+
rule["@type"] = "RecurrenceRule"
|
|
63
|
+
rule.fetch("byDay", []).each { |day| day["@type"] ||= "NDay" }
|
|
64
|
+
(@data["recurrenceRules"] ||= []) << rule
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def effective_time_zones
|
|
69
|
+
@time_zones.merge(@data.fetch("timeZones", {}))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
Event.include(RecurringCalendar)
|
|
74
|
+
Task.include(RecurringCalendar)
|
|
75
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
Violation = Struct.new(:pointer, :message, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :pointer
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, pointer: nil)
|
|
10
|
+
@pointer = pointer
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ParseError < Error; end
|
|
16
|
+
class ConversionError < Error; end
|
|
17
|
+
class LimitError < Error; end
|
|
18
|
+
|
|
19
|
+
class ValidationError < Error
|
|
20
|
+
attr_reader :errors
|
|
21
|
+
|
|
22
|
+
def initialize(errors, pointer: nil)
|
|
23
|
+
@errors = errors.is_a?(Array) ? errors : [Violation.new(pointer: pointer || "", message: errors)]
|
|
24
|
+
super(@errors.map { |error| "#{error.pointer}: #{error.message}" }.join("; "), pointer: pointer || @errors.first&.pointer)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
# A typed view over a JSON object; nested views write into their owning model.
|
|
5
|
+
class Model
|
|
6
|
+
attr_reader :limits
|
|
7
|
+
|
|
8
|
+
def initialize(data = nil, strict: false, time_zones: {}, **limits)
|
|
9
|
+
@limits = DEFAULT_LIMITS.merge(limits).freeze
|
|
10
|
+
validator = Validator.new(**limits)
|
|
11
|
+
@time_zones = Jscalendar.deep_copy(time_zones)
|
|
12
|
+
data ||= { "@type" => type_name }
|
|
13
|
+
raise ValidationError, "expected a JSON object" unless data.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
@data = Jscalendar.deep_copy(data, max_depth: @limits[:max_depth])
|
|
16
|
+
violations = validator.validate(@data, type: type_name, time_zones: @time_zones)
|
|
17
|
+
raise ValidationError, violations if strict && !violations.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.wrap(data, limits, time_zones = {})
|
|
21
|
+
value = allocate
|
|
22
|
+
value.instance_variable_set(:@data, data)
|
|
23
|
+
value.instance_variable_set(:@limits, limits)
|
|
24
|
+
value.instance_variable_set(:@time_zones, time_zones)
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](name)
|
|
29
|
+
name = name.to_s
|
|
30
|
+
rule = SCHEMA.fetch(type_name).fetch("properties")[name]
|
|
31
|
+
return @data[name] unless rule
|
|
32
|
+
return wrap_value(Jscalendar.deep_copy(rule["default"]), rule.fetch("type")) if !@data.key?(name) && rule.key?("default")
|
|
33
|
+
|
|
34
|
+
wrap_value(@data[name], rule.fetch("type"))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def []=(name, value)
|
|
38
|
+
@data[name.to_s] = Jscalendar.deep_copy(value, max_depth: @limits[:max_depth])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def key?(name)
|
|
42
|
+
@data.key?(name.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(name)
|
|
46
|
+
@data.delete(name.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_h
|
|
50
|
+
Jscalendar.deep_copy(@data, max_depth: @limits[:max_depth])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_json(options = nil)
|
|
54
|
+
validate!
|
|
55
|
+
data = to_h
|
|
56
|
+
data["@type"] ||= type_name
|
|
57
|
+
Jscalendar.generate_json(data, options || { max_nesting: @limits[:max_depth] })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def errors
|
|
61
|
+
Validator.new(**@limits).validate(@data, type: type_name, time_zones: @time_zones)
|
|
62
|
+
end
|
|
63
|
+
alias warnings errors
|
|
64
|
+
|
|
65
|
+
def valid?
|
|
66
|
+
errors.empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate!
|
|
70
|
+
violations = errors
|
|
71
|
+
raise ValidationError, violations unless violations.empty?
|
|
72
|
+
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(Model) && to_h == other.to_h
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
protected
|
|
81
|
+
|
|
82
|
+
def type_name
|
|
83
|
+
self.class.name.split("::").last
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def wrap_value(value, signature)
|
|
89
|
+
return Jscalendar.const_get(signature).new(value) if value && %w[LocalDateTime Duration SignedDuration].include?(signature)
|
|
90
|
+
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
91
|
+
|
|
92
|
+
zones = @time_zones.merge(@data["timeZones"].is_a?(Hash) ? @data["timeZones"] : {})
|
|
93
|
+
if %w[Entry Trigger].include?(signature) && value.is_a?(Hash)
|
|
94
|
+
type = value["@type"]
|
|
95
|
+
return SCHEMA.key?(type) ? Jscalendar.const_get(type).wrap(value, @limits, zones) : value
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
if SCHEMA.key?(signature) && value.is_a?(Hash)
|
|
99
|
+
Jscalendar.const_get(signature).wrap(value, @limits, zones)
|
|
100
|
+
elsif (match = /\A(?:Id|String)\[(\w+)\]\z/.match(signature)) && SCHEMA.key?(match[1]) && value.is_a?(Hash)
|
|
101
|
+
IdMap.new(value, type: match[1], limits: @limits,
|
|
102
|
+
validate_ids: signature.start_with?("Id["), time_zones: zones)
|
|
103
|
+
elsif signature.end_with?("[]") && value.is_a?(Array)
|
|
104
|
+
value.map { |item| wrap_value(item, signature.delete_suffix("[]")) }
|
|
105
|
+
elsif signature.include?("|") && value.is_a?(Hash)
|
|
106
|
+
type = value.fetch("@type", signature.split("|").first)
|
|
107
|
+
SCHEMA.key?(type) ? Jscalendar.const_get(type).wrap(value, @limits, zones) : value
|
|
108
|
+
else
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A map preserving source identifiers and exposing typed member values.
|
|
115
|
+
class IdMap
|
|
116
|
+
include Enumerable
|
|
117
|
+
|
|
118
|
+
def initialize(data = {}, type:, limits: DEFAULT_LIMITS, validate_ids: true, time_zones: {})
|
|
119
|
+
@data = data
|
|
120
|
+
@type = type
|
|
121
|
+
@limits = limits
|
|
122
|
+
@validate_ids = validate_ids
|
|
123
|
+
@time_zones = time_zones
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def [](id)
|
|
127
|
+
value = @data[id.to_s]
|
|
128
|
+
value.is_a?(Hash) ? Jscalendar.const_get(@type).wrap(value, @limits, @time_zones) : value
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def []=(id, value)
|
|
132
|
+
id = id.to_s
|
|
133
|
+
raise ValidationError.new("invalid map Id", pointer: Jscalendar.pointer("", id)) if @validate_ids && !Types.id?(id)
|
|
134
|
+
raise LimitError, "map exceeds max_map_size" if !@data.key?(id) && @data.size >= @limits[:max_map_size]
|
|
135
|
+
|
|
136
|
+
@data[id] = Jscalendar.deep_copy(value, max_depth: @limits[:max_depth])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def each
|
|
140
|
+
return enum_for(:each) unless block_given?
|
|
141
|
+
|
|
142
|
+
@data.each_key { |id| yield id, self[id] }
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def keys
|
|
147
|
+
@data.keys
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def values
|
|
151
|
+
map { |_id, value| value }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def size
|
|
155
|
+
@data.size
|
|
156
|
+
end
|
|
157
|
+
alias length size
|
|
158
|
+
|
|
159
|
+
def empty?
|
|
160
|
+
@data.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def key?(id)
|
|
164
|
+
@data.key?(id.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def delete(id)
|
|
168
|
+
value = self[id]
|
|
169
|
+
@data.delete(id.to_s)
|
|
170
|
+
value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def add(value, prefix: "id")
|
|
174
|
+
counter = 1
|
|
175
|
+
counter += 1 while @data.key?("#{prefix}#{counter}")
|
|
176
|
+
id = "#{prefix}#{counter}"
|
|
177
|
+
self[id] = value
|
|
178
|
+
id
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def to_h
|
|
182
|
+
Jscalendar.deep_copy(@data, max_depth: @limits[:max_depth])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
SCHEMA.each do |name, definition|
|
|
187
|
+
model = const_set(name, Class.new(Model))
|
|
188
|
+
definition.fetch("properties").each_key do |property|
|
|
189
|
+
next if property == "@type"
|
|
190
|
+
|
|
191
|
+
model.define_method(property) { self[property] }
|
|
192
|
+
model.define_method("#{property}=") { |value| self[property] = value }
|
|
193
|
+
snake = property.gsub(/([A-Z])/, '_\\1').downcase
|
|
194
|
+
next if snake == property
|
|
195
|
+
|
|
196
|
+
model.alias_method(snake, property)
|
|
197
|
+
model.alias_method("#{snake}=", "#{property}=")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
class PatchObject
|
|
5
|
+
RECURRENCE_IGNORED = %w[@type excludedRecurrenceRules method privacy prodId recurrenceId recurrenceIdTimeZone
|
|
6
|
+
recurrenceOverrides recurrenceRules relatedTo replyTo sentBy timeZones uid].freeze
|
|
7
|
+
|
|
8
|
+
def self.recurrence(patches)
|
|
9
|
+
patches.reject { |path, _| RECURRENCE_IGNORED.any? { |prefix| path.start_with?(prefix) } }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.localization(patches)
|
|
13
|
+
patches.select { |path, _| !path.start_with?("recurrenceOverrides") && %w[title description name].any? { |suffix| path.end_with?(suffix) } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(patches, max_patches: 1_000, **limits)
|
|
17
|
+
raise ArgumentError, "max_patches must be a nonnegative integer" unless max_patches.is_a?(Integer) && max_patches >= 0
|
|
18
|
+
raise ValidationError, "PatchObject must be an object" unless patches.is_a?(Hash)
|
|
19
|
+
raise LimitError.new("Too many patches", pointer: "") if patches.size > max_patches
|
|
20
|
+
|
|
21
|
+
@limits = limits.merge(max_patches: max_patches)
|
|
22
|
+
@patches = Jscalendar.deep_copy(patches, max_depth: @limits.fetch(:max_depth, 32))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
Jscalendar.deep_copy(@patches, max_depth: @limits.fetch(:max_depth, 32))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# RFC 8984 section 1.4.9: resolve every path before modifying the copy.
|
|
30
|
+
def apply(data, validate: true)
|
|
31
|
+
raise ValidationError, "Patch target must be an object" unless data.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
result = Jscalendar.deep_copy(data, max_depth: @limits.fetch(:max_depth, 32))
|
|
34
|
+
type = result["@type"]
|
|
35
|
+
validator = Validator.new(**@limits, validate_patches: false)
|
|
36
|
+
errors = validator.validate_json(result) + validator.validate_json(@patches)
|
|
37
|
+
raise ValidationError, errors unless errors.empty?
|
|
38
|
+
|
|
39
|
+
paths = @patches.keys.map { |path| [tokens(path), path] }.sort_by(&:first)
|
|
40
|
+
paths.each_cons(2) do |(first, _), (second, path)|
|
|
41
|
+
invalid(path, "Patch paths must not overlap") if second.first(first.size) == first
|
|
42
|
+
end
|
|
43
|
+
operations = paths.map do |parts, path|
|
|
44
|
+
parent = parts[0...-1].reduce(result) { |value, token| member(value, token, path) }
|
|
45
|
+
key = parts.last
|
|
46
|
+
invalid(path, "Patch parent must be an object; arrays cannot be traversed") unless parent.is_a?(Hash)
|
|
47
|
+
[parent, key, @patches[path]]
|
|
48
|
+
end
|
|
49
|
+
operations.each do |parent, key, value|
|
|
50
|
+
if value.nil?
|
|
51
|
+
parent.delete(key)
|
|
52
|
+
else
|
|
53
|
+
parent[key] = Jscalendar.deep_copy(value, max_depth: @limits.fetch(:max_depth, 32))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
errors = if validate && (type || result["@type"])
|
|
57
|
+
raise ValidationError.new("Unknown patch target type", pointer: "/@type") unless SCHEMA.key?(type || result["@type"])
|
|
58
|
+
|
|
59
|
+
validator.validate(result, type: type || result["@type"])
|
|
60
|
+
else
|
|
61
|
+
validator.validate_json(result)
|
|
62
|
+
end
|
|
63
|
+
raise ValidationError, errors unless errors.empty?
|
|
64
|
+
|
|
65
|
+
result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def tokens(path)
|
|
71
|
+
invalid(path, "Invalid JSON Pointer escape") if path.match?(/~(?:[^01]|\z)/)
|
|
72
|
+
(path.empty? ? [""] : path.split("/", -1)).map { |token| token.gsub(/~[01]/) { |escape| escape == "~0" ? "~" : "/" } }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def member(parent, key, path)
|
|
76
|
+
invalid(path, "Intermediate reference must be an existing object member") unless parent.is_a?(Hash) && parent.key?(key)
|
|
77
|
+
parent[key]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def invalid(path, message)
|
|
81
|
+
raise ValidationError.new(message, pointer: "/#{path.gsub("~", "~0").gsub("/", "~1")}")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
module Recurrence
|
|
5
|
+
PARTS = { "interval" => "INTERVAL", "firstDayOfWeek" => "WKST", "byMonthDay" => "BYMONTHDAY",
|
|
6
|
+
"byMonth" => "BYMONTH", "byYearDay" => "BYYEARDAY", "byWeekNo" => "BYWEEKNO", "byHour" => "BYHOUR",
|
|
7
|
+
"byMinute" => "BYMINUTE", "bySecond" => "BYSECOND", "bySetPosition" => "BYSETPOS", "count" => "COUNT" }.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def to_rrule(rule)
|
|
12
|
+
rule = rule.to_h if rule.is_a?(Model)
|
|
13
|
+
errors = Validator.new.validate(rule, type: "RecurrenceRule")
|
|
14
|
+
raise ValidationError, errors unless errors.empty?
|
|
15
|
+
raise UnsupportedError, "only Gregorian recurrence expansion is supported" unless rule.fetch("rscale", "gregorian") == "gregorian"
|
|
16
|
+
raise UnsupportedError, "rrule supports only skip=omit" unless rule.fetch("skip", "omit") == "omit"
|
|
17
|
+
raise UnsupportedError, "rrule does not support leap seconds" if rule.fetch("bySecond", []).include?(60)
|
|
18
|
+
|
|
19
|
+
parts = ["FREQ=#{rule.fetch("frequency").upcase}"]
|
|
20
|
+
PARTS.each { |name, keyword| parts << "#{keyword}=#{Array(rule[name]).join(",").upcase}" if rule.key?(name) }
|
|
21
|
+
parts << "BYDAY=#{rule["byDay"].map { |day| "#{day["nthOfPeriod"]}#{day.fetch("day").upcase}" }.join(",")}" if rule["byDay"]
|
|
22
|
+
# The engine runs in UTC as a wall-clock calendar; zone conversion occurs afterwards.
|
|
23
|
+
parts << "UNTIL=#{LocalDateTime.new(rule["until"]).to_time.strftime("%Y%m%dT%H%M%SZ")}" if rule["until"]
|
|
24
|
+
parts.join(";")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rule_times(rule, initial, from, to, limit:, include_initial: true)
|
|
28
|
+
return [] if rule.fetch("count", 1).zero?
|
|
29
|
+
|
|
30
|
+
rule = Jscalendar.deep_copy(rule)
|
|
31
|
+
count = rule.delete("count")
|
|
32
|
+
if rule["frequency"] == "yearly" && rule.key?("byWeekNo") && !(rule.key?("byYearDay") || rule.key?("byMonthDay") || rule.key?("byDay"))
|
|
33
|
+
# RFC 8984 section 4.3.3 supplies this default; rrule 0.8 does not.
|
|
34
|
+
rule["byDay"] = [{ "@type" => "NDay", "day" => %w[su mo tu we th fr sa][initial.wday] }]
|
|
35
|
+
end
|
|
36
|
+
string = to_rrule(rule)
|
|
37
|
+
engine = RRule::Rule.new(string, dtstart: initial, tzid: "UTC", max_year: to.year + 1)
|
|
38
|
+
result = []
|
|
39
|
+
generated = 0
|
|
40
|
+
if include_initial
|
|
41
|
+
generated = 1
|
|
42
|
+
result << initial if initial >= from && initial < to
|
|
43
|
+
return result if count == 1 || result.size >= limit
|
|
44
|
+
end
|
|
45
|
+
# The engine strips fractions. Shift its lower bound before restoring them.
|
|
46
|
+
engine.each(floor_date: (count ? initial : from) - initial.subsec) do |date|
|
|
47
|
+
date = date.to_time.getutc + initial.subsec
|
|
48
|
+
next if include_initial && date == initial
|
|
49
|
+
break if date >= to
|
|
50
|
+
break if rule["until"] && date > LocalDateTime.new(rule["until"]).to_time
|
|
51
|
+
break if count && generated >= count
|
|
52
|
+
next unless clock_matches?(rule, date)
|
|
53
|
+
|
|
54
|
+
generated += 1
|
|
55
|
+
result << date if date >= from
|
|
56
|
+
break if result.size >= limit
|
|
57
|
+
end
|
|
58
|
+
result
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# rrule 0.8 overwrites clock filters for hourly and faster frequencies.
|
|
62
|
+
def clock_matches?(rule, date)
|
|
63
|
+
checks = case rule["frequency"]
|
|
64
|
+
when "hourly" then { "byHour" => date.hour }
|
|
65
|
+
when "minutely" then { "byHour" => date.hour, "byMinute" => date.min }
|
|
66
|
+
when "secondly" then { "byHour" => date.hour, "byMinute" => date.min, "bySecond" => date.sec }
|
|
67
|
+
else {}
|
|
68
|
+
end
|
|
69
|
+
checks.all? { |name, number| !rule.key?(name) || rule[name].include?(number) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class RecurrenceRule
|
|
74
|
+
def to_rrule
|
|
75
|
+
Recurrence.to_rrule(to_h)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class Occurrence
|
|
80
|
+
attr_reader :recurrence_id, :event
|
|
81
|
+
alias calendar event
|
|
82
|
+
|
|
83
|
+
def initialize(id, event, time_zone)
|
|
84
|
+
@recurrence_id = LocalDateTime.new(id)
|
|
85
|
+
@event = event
|
|
86
|
+
@time_zone = time_zone
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def start_time(time_zone: @time_zone)
|
|
90
|
+
@event.start_time(time_zone: time_zone)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Occurrences
|
|
95
|
+
include Enumerable
|
|
96
|
+
|
|
97
|
+
def initialize(calendar, from:, to:, limit:, time_zone:, timeout:)
|
|
98
|
+
raise ArgumentError, "from and to must be Times with from < to" unless from.is_a?(Time) && to.is_a?(Time) && from < to
|
|
99
|
+
unless limit.is_a?(Integer) && limit.positive? && limit <= calendar.limits[:max_occurrences]
|
|
100
|
+
raise ArgumentError, "limit must be positive and <= max_occurrences"
|
|
101
|
+
end
|
|
102
|
+
raise ArgumentError, "timeout must be positive and finite" unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
|
|
103
|
+
|
|
104
|
+
@truncated = false
|
|
105
|
+
@items = Timeout.timeout(timeout, LimitError, "recurrence expansion exceeded time limit") do
|
|
106
|
+
expand(calendar, from, to, limit, time_zone)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def each(&block)
|
|
111
|
+
return enum_for(:each) unless block
|
|
112
|
+
|
|
113
|
+
@items.each(&block)
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def truncated?
|
|
118
|
+
@truncated
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def expand(calendar, from, to, limit, time_zone)
|
|
124
|
+
data = calendar.to_h
|
|
125
|
+
initial_text = data["start"] || data["due"]
|
|
126
|
+
raise ValidationError, "calendar has no start or due" unless initial_text
|
|
127
|
+
|
|
128
|
+
initial = LocalDateTime.new(initial_text).to_time
|
|
129
|
+
zone = data["timeZone"] || time_zone
|
|
130
|
+
resolver = zone && TimeZoneResolver.new(zone, calendar.effective_time_zones)
|
|
131
|
+
lower, upper = resolver ? resolver.wall_bounds(from, to) : [from.getutc, to.getutc]
|
|
132
|
+
maximum = calendar.limits[:max_occurrences]
|
|
133
|
+
rules = data.fetch("recurrenceRules", [])
|
|
134
|
+
exclusions = data.fetch("excludedRecurrenceRules", [])
|
|
135
|
+
overrides = data.fetch("recurrenceOverrides", {})
|
|
136
|
+
raise LimitError, "too many recurrence rules" if rules.size + exclusions.size > calendar.limits[:max_rules]
|
|
137
|
+
|
|
138
|
+
# Without removals, moved instances, or offset changes, each rule's first
|
|
139
|
+
# limit+1 dates suffice to determine the first limit results and truncation.
|
|
140
|
+
simple = exclusions.empty? && overrides.empty? && [nil, "UTC"].include?(zone)
|
|
141
|
+
generation_limit = simple ? limit + 1 : maximum + 1
|
|
142
|
+
candidates = [initial]
|
|
143
|
+
rules.each do |rule|
|
|
144
|
+
dates = Recurrence.rule_times(rule, initial, lower, upper, limit: generation_limit)
|
|
145
|
+
@truncated ||= dates.size > maximum
|
|
146
|
+
candidates.concat(dates.first(maximum))
|
|
147
|
+
end
|
|
148
|
+
exclusions.each do |rule|
|
|
149
|
+
dates = Recurrence.rule_times(rule, initial, lower, upper, limit: maximum + 1, include_initial: false)
|
|
150
|
+
raise LimitError, "excluded recurrence set exceeds max_occurrences" if dates.size > maximum
|
|
151
|
+
|
|
152
|
+
candidates -= dates
|
|
153
|
+
end
|
|
154
|
+
candidates.concat(overrides.keys.map { |date| LocalDateTime.new(date).to_time })
|
|
155
|
+
property = data.key?("start") ? "start" : "due"
|
|
156
|
+
resolvers = {}
|
|
157
|
+
items = candidates.uniq.filter_map do |date|
|
|
158
|
+
id = LocalDateTime.from_time(date).to_s
|
|
159
|
+
patch = overrides.fetch(id, {})
|
|
160
|
+
next if patch.fetch("excluded", data.fetch("excluded", false)) == true
|
|
161
|
+
|
|
162
|
+
fields = { "start" => data["start"], "due" => data["due"], "timeZone" => data["timeZone"] }
|
|
163
|
+
fields[property] = id
|
|
164
|
+
fields.merge!(patch.slice("start", "due", "timeZone"))
|
|
165
|
+
local = LocalDateTime.new(fields["start"] || fields["due"])
|
|
166
|
+
actual_zone = fields["timeZone"] || time_zone
|
|
167
|
+
instant = if actual_zone
|
|
168
|
+
resolvers[actual_zone] ||= TimeZoneResolver.new(actual_zone, calendar.effective_time_zones)
|
|
169
|
+
resolvers[actual_zone].to_utc(local)
|
|
170
|
+
else
|
|
171
|
+
local.to_time
|
|
172
|
+
end
|
|
173
|
+
next unless instant >= from && instant < to
|
|
174
|
+
|
|
175
|
+
[instant, id, patch]
|
|
176
|
+
end
|
|
177
|
+
items.sort_by!(&:first)
|
|
178
|
+
@truncated ||= items.size > limit
|
|
179
|
+
bytes = 0
|
|
180
|
+
items.first(limit).map do |_instant, id, patch|
|
|
181
|
+
occurrence_data = data.except("recurrenceRules", "excludedRecurrenceRules", "recurrenceOverrides")
|
|
182
|
+
occurrence_data[property] = id
|
|
183
|
+
occurrence_data["recurrenceId"] = id
|
|
184
|
+
occurrence_data["recurrenceIdTimeZone"] = data["timeZone"]
|
|
185
|
+
occurrence_data = PatchObject.new(PatchObject.recurrence(patch), **calendar.limits).apply(occurrence_data, validate: false) unless patch.empty?
|
|
186
|
+
bytes += Jscalendar.generate_json(occurrence_data).bytesize
|
|
187
|
+
raise LimitError, "expanded data exceeds max_occurrence_bytes" if bytes > calendar.limits[:max_occurrence_bytes]
|
|
188
|
+
|
|
189
|
+
occurrence = calendar.class.new(occurrence_data, time_zones: calendar.effective_time_zones, **calendar.limits)
|
|
190
|
+
Occurrence.new(id, occurrence, time_zone)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|