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,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscalendar
|
|
4
|
+
# Collects violations while treating configured resource limits as hard failures.
|
|
5
|
+
class Validator
|
|
6
|
+
KNOWN_NAMES = SCHEMA.values.flat_map { |definition| definition.fetch("properties").keys }.uniq.freeze
|
|
7
|
+
|
|
8
|
+
def initialize(validate_patches: true, **limits)
|
|
9
|
+
unknown = limits.keys - DEFAULT_LIMITS.keys
|
|
10
|
+
raise ArgumentError, "unknown limits: #{unknown.join(", ")}" unless unknown.empty?
|
|
11
|
+
|
|
12
|
+
@limits = DEFAULT_LIMITS.merge(limits)
|
|
13
|
+
@limits.each do |name, value|
|
|
14
|
+
raise ArgumentError, "#{name} must be a nonnegative Integer" unless value.is_a?(Integer) && value >= 0
|
|
15
|
+
end
|
|
16
|
+
raise ArgumentError, "max_depth must be positive" if @limits[:max_depth].zero?
|
|
17
|
+
|
|
18
|
+
@validate_patches = validate_patches
|
|
19
|
+
@errors = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate(value, type: value.is_a?(Hash) && value["@type"], time_zones: {})
|
|
23
|
+
@errors = []
|
|
24
|
+
inspect_json(value, "", 0, [])
|
|
25
|
+
return @errors unless @errors.empty?
|
|
26
|
+
|
|
27
|
+
@zones = time_zones
|
|
28
|
+
object(value, type, "")
|
|
29
|
+
@errors
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_json(value, pointer: "")
|
|
33
|
+
@errors = []
|
|
34
|
+
inspect_json(value, pointer, 0, [])
|
|
35
|
+
@errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def add(pointer, message)
|
|
41
|
+
@errors << Violation.new(pointer: pointer, message: message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect_json(value, path, depth, ancestors)
|
|
45
|
+
case value
|
|
46
|
+
when Hash, Array
|
|
47
|
+
raise LimitError.new("JSON nesting exceeds max_depth", pointer: path) if depth >= @limits[:max_depth]
|
|
48
|
+
|
|
49
|
+
if ancestors.include?(value.object_id)
|
|
50
|
+
add(path, "cyclic JSON data")
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
raise LimitError.new("collection exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
|
|
54
|
+
|
|
55
|
+
next_ancestors = ancestors + [value.object_id]
|
|
56
|
+
if value.is_a?(Hash)
|
|
57
|
+
value.each do |key, item|
|
|
58
|
+
unless key.is_a?(String) && unicode?(key)
|
|
59
|
+
add(path, "JSON member names must be valid Unicode strings")
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
inspect_json(item, Jscalendar.pointer(path, key), depth + 1, next_ancestors)
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
value.each_with_index { |item, index| inspect_json(item, Jscalendar.pointer(path, index), depth + 1, next_ancestors) }
|
|
66
|
+
end
|
|
67
|
+
when String
|
|
68
|
+
add(path, "string is not valid I-JSON Unicode") unless unicode?(value)
|
|
69
|
+
check_data_uri_limit(value, path)
|
|
70
|
+
when Integer
|
|
71
|
+
add(path, "integer is outside the exact I-JSON range") unless Types.integer?(value)
|
|
72
|
+
when Float
|
|
73
|
+
add(path, "number must be finite") unless value.finite?
|
|
74
|
+
when TrueClass, FalseClass, NilClass
|
|
75
|
+
nil
|
|
76
|
+
else
|
|
77
|
+
add(path, "value is not JSON-compatible")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unicode?(value)
|
|
82
|
+
return false unless value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
83
|
+
|
|
84
|
+
value.each_codepoint.none? { |point| point.between?(0xD800, 0xDFFF) || point.between?(0xFDD0, 0xFDEF) || (point & 0xFFFF) >= 0xFFFE }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_data_uri_limit(value, path)
|
|
88
|
+
return unless value.b.byteslice(0, 5)&.downcase == "data:"
|
|
89
|
+
|
|
90
|
+
header, payload = value.b.split(",", 2)
|
|
91
|
+
return unless payload
|
|
92
|
+
|
|
93
|
+
payload = URI::DEFAULT_PARSER.unescape(payload)
|
|
94
|
+
size = if header.downcase.end_with?(";base64")
|
|
95
|
+
(payload.bytesize * 3 / 4) - [payload[/=+\z/].to_s.length, 2].min
|
|
96
|
+
else
|
|
97
|
+
payload.bytesize
|
|
98
|
+
end
|
|
99
|
+
raise LimitError.new("data URI exceeds max_data_uri_size", pointer: path) if size > @limits[:max_data_uri_size]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def object(value, type, path)
|
|
103
|
+
unless value.is_a?(Hash) && SCHEMA.key?(type)
|
|
104
|
+
add(path, "expected a known JSCalendar object")
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
previous_zones = @zones
|
|
108
|
+
@zones = @zones.merge(value["timeZones"].is_a?(Hash) ? value["timeZones"] : {})
|
|
109
|
+
fields = SCHEMA.fetch(type).fetch("properties")
|
|
110
|
+
add(Jscalendar.pointer(path, "@type"), "expected @type #{type}") unless value["@type"] == type
|
|
111
|
+
fields.each do |name, rule|
|
|
112
|
+
add(Jscalendar.pointer(path, name), "required property is missing") if rule["required"] && !value.key?(name)
|
|
113
|
+
end
|
|
114
|
+
value.each do |name, item|
|
|
115
|
+
next unless name.is_a?(String) && unicode?(name)
|
|
116
|
+
|
|
117
|
+
location = Jscalendar.pointer(path, name)
|
|
118
|
+
if fields.key?(name)
|
|
119
|
+
check(item, fields[name].fetch("type"), location) unless name == "@type"
|
|
120
|
+
elsif KNOWN_NAMES.any? { |known| known.casecmp?(name) }
|
|
121
|
+
add(location, "property is not allowed in #{type} or has incorrect case")
|
|
122
|
+
elsif !Types.vendor?(name) && !name.match?(/\A[a-z][A-Za-z0-9]*\z/)
|
|
123
|
+
add(location, "invalid property name")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
relationships(value, type, path)
|
|
127
|
+
ensure
|
|
128
|
+
@zones = previous_zones if defined?(previous_zones) && previous_zones
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check(value, signature, path)
|
|
132
|
+
if signature.end_with?("|null")
|
|
133
|
+
check(value, signature.delete_suffix("|null"), path) unless value.nil?
|
|
134
|
+
elsif SCHEMA.key?(signature)
|
|
135
|
+
object(value, signature, path)
|
|
136
|
+
elsif signature.end_with?("[]")
|
|
137
|
+
if value.is_a?(Array)
|
|
138
|
+
value.each_with_index { |item, i| check(item, signature.delete_suffix("[]"), Jscalendar.pointer(path, i)) }
|
|
139
|
+
else
|
|
140
|
+
add(path, "expected an array")
|
|
141
|
+
end
|
|
142
|
+
elsif (match = /\A(\w+)\[(.+)\]\z/.match(signature))
|
|
143
|
+
check_map(value, match[1], match[2], path)
|
|
144
|
+
elsif signature.start_with?("Enum:", "Closed:")
|
|
145
|
+
enum(value, signature.split(":", 2).last, path, extensible: signature.start_with?("Enum:"))
|
|
146
|
+
elsif signature.start_with?("EnumMap:", "ClosedEnumMap:")
|
|
147
|
+
check_map(value, "String", "True", path)
|
|
148
|
+
if value.is_a?(Hash)
|
|
149
|
+
value.each_key { |key| enum(key, signature.split(":", 2).last, Jscalendar.pointer(path, key), extensible: signature.start_with?("EnumMap:")) }
|
|
150
|
+
end
|
|
151
|
+
elsif signature == "PatchObject"
|
|
152
|
+
if value.is_a?(Hash)
|
|
153
|
+
raise LimitError.new("patch count exceeds max_patches", pointer: path) if value.size > @limits[:max_patches]
|
|
154
|
+
else
|
|
155
|
+
add(path, "expected PatchObject")
|
|
156
|
+
end
|
|
157
|
+
elsif %w[Entry Trigger].include?(signature)
|
|
158
|
+
if !value.is_a?(Hash) || !value["@type"].is_a?(String)
|
|
159
|
+
add(path, "expected typed #{signature}")
|
|
160
|
+
elsif (signature == "Entry" ? %w[Event Task] : %w[OffsetTrigger AbsoluteTrigger]).include?(value["@type"])
|
|
161
|
+
object(value, value["@type"], path)
|
|
162
|
+
end
|
|
163
|
+
else
|
|
164
|
+
add(path, "invalid #{signature} value") unless scalar?(value, signature)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def check_map(value, key_type, item_type, path)
|
|
169
|
+
unless value.is_a?(Hash)
|
|
170
|
+
add(path, "expected a map")
|
|
171
|
+
return
|
|
172
|
+
end
|
|
173
|
+
value.each do |key, item|
|
|
174
|
+
location = Jscalendar.pointer(path, key)
|
|
175
|
+
add(location, "invalid #{key_type} map key") unless scalar?(key, key_type)
|
|
176
|
+
check(item, item_type, location)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def enum(value, name, path, extensible: true)
|
|
181
|
+
valid = value.is_a?(String) && (ENUMS.fetch(name).include?(value) || (extensible && Types.vendor?(value)))
|
|
182
|
+
add(path, "invalid #{name} value") unless valid
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def scalar?(value, signature)
|
|
186
|
+
case signature
|
|
187
|
+
when "String" then value.is_a?(String)
|
|
188
|
+
when "Boolean" then [true, false].include?(value)
|
|
189
|
+
when "True" then value == true
|
|
190
|
+
when "Int" then Types.integer?(value)
|
|
191
|
+
when "UnsignedInt" then Types.integer?(value, minimum: 0)
|
|
192
|
+
when "Id" then Types.id?(value)
|
|
193
|
+
when "UTCDateTime" then Types.utc_date_time?(value)
|
|
194
|
+
when "LocalDateTime" then value.is_a?(String) && Types.utc_date_time?("#{value}Z")
|
|
195
|
+
when "Duration", "SignedDuration"
|
|
196
|
+
value.is_a?(String) && Jscalendar.const_get(signature).new(value)
|
|
197
|
+
when "TimeZoneId"
|
|
198
|
+
value.is_a?(String) && (@zones.key?(value) || TZInfo::Timezone.get(value))
|
|
199
|
+
when "LanguageTag" then Types.language_tag?(value)
|
|
200
|
+
when "URI" then Types.uri?(value)
|
|
201
|
+
when "Email" then Types.email?(value)
|
|
202
|
+
when "UTCOffset"
|
|
203
|
+
value.is_a?(String) && value.match?(/\A[+-](?:[01]\d|2[0-3])[0-5]\d(?:[0-5]\d)?\z/) && !%w[-0000 -000000].include?(value)
|
|
204
|
+
else false
|
|
205
|
+
end
|
|
206
|
+
rescue ValidationError, TZInfo::InvalidTimezoneIdentifier
|
|
207
|
+
false
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def relationships(value, type, path)
|
|
211
|
+
if value["color"].is_a?(String)
|
|
212
|
+
valid = ENUMS.fetch("color").include?(value["color"].downcase) || value["color"].match?(/\A#(?:[0-9a-f]{3}|[0-9a-f]{6})\z/i)
|
|
213
|
+
add("#{path}/color", "expected CSS Level 3 color name or hexadecimal RGB") unless valid
|
|
214
|
+
end
|
|
215
|
+
if value["method"].is_a?(String) && !%w[publish request reply add cancel refresh counter declinecounter].include?(value["method"])
|
|
216
|
+
add("#{path}/method", "expected lowercase iTIP method")
|
|
217
|
+
end
|
|
218
|
+
%w[links delegatedTo delegatedFrom memberOf replyTo sendTo roles locationTypes].each do |name|
|
|
219
|
+
add(Jscalendar.pointer(path, name), "must not be empty") if value[name].is_a?(Hash) && value[name].empty?
|
|
220
|
+
end
|
|
221
|
+
%w[percentComplete priority].each do |name|
|
|
222
|
+
next unless value[name].is_a?(Integer)
|
|
223
|
+
|
|
224
|
+
max = name == "priority" ? 9 : 100
|
|
225
|
+
add(Jscalendar.pointer(path, name), "must be between 0 and #{max}") unless value[name].between?(0, max)
|
|
226
|
+
end
|
|
227
|
+
if value["descriptionContentType"].is_a?(String)
|
|
228
|
+
media = value["descriptionContentType"]
|
|
229
|
+
valid = media.match?(%r{\Atext/[A-Za-z0-9!\#$&^_.+-]+(?:\s*;[^\r\n]*)?\z}) &&
|
|
230
|
+
(!media.match?(/charset\s*=/i) || media.match?(/charset\s*=\s*"?utf-8"?(?:\s*;|\z)/i))
|
|
231
|
+
add(Jscalendar.pointer(path, "descriptionContentType"), "expected text media type with UTF-8 charset") unless valid
|
|
232
|
+
end
|
|
233
|
+
%w[replyTo sendTo].each do |name|
|
|
234
|
+
next unless value[name].is_a?(Hash)
|
|
235
|
+
|
|
236
|
+
value[name].each do |method, uri|
|
|
237
|
+
location = Jscalendar.pointer(Jscalendar.pointer(path, name), method)
|
|
238
|
+
add(location, "method must be ASCII alphanumeric") unless method.match?(/\A[A-Za-z0-9]+\z/)
|
|
239
|
+
scheme = if method == "imip"
|
|
240
|
+
"mailto:"
|
|
241
|
+
else
|
|
242
|
+
(method == "web" && name == "replyTo" ? "https:" : nil)
|
|
243
|
+
end
|
|
244
|
+
add(location, "expected #{scheme} URI") if scheme && uri.is_a?(String) && !uri.downcase.start_with?(scheme)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
case type
|
|
248
|
+
when "Event", "Task", "Group" then calendar(value, type, path)
|
|
249
|
+
when "RecurrenceRule" then recurrence(value, path)
|
|
250
|
+
when "NDay"
|
|
251
|
+
add(Jscalendar.pointer(path, "nthOfPeriod"), "must be nonzero") if value["nthOfPeriod"].is_a?(Integer) && value["nthOfPeriod"].zero?
|
|
252
|
+
when "Link"
|
|
253
|
+
add(Jscalendar.pointer(path, "rel"), "must be icon when display is set") if value.key?("display") && value["rel"] != "icon"
|
|
254
|
+
if value["cid"].is_a?(String)
|
|
255
|
+
cid = value["cid"]
|
|
256
|
+
valid = !cid.match?(/%(?![0-9a-fA-F]{2})/) && Types.email?(URI::DEFAULT_PARSER.unescape(cid))
|
|
257
|
+
add("#{path}/cid", "expected URL-encoded content-id") unless valid
|
|
258
|
+
end
|
|
259
|
+
when "Location"
|
|
260
|
+
add(path, "location needs a property other than relativeTo") if (value.keys - %w[@type relativeTo]).empty?
|
|
261
|
+
add(Jscalendar.pointer(path, "coordinates"), "expected geo URI") if value["coordinates"].is_a?(String) && !value["coordinates"].start_with?("geo:")
|
|
262
|
+
when "Participant"
|
|
263
|
+
if value.key?("progress") && value.fetch("participationStatus", "needs-action") != "accepted"
|
|
264
|
+
add(Jscalendar.pointer(path, "progress"), "requires accepted participationStatus")
|
|
265
|
+
end
|
|
266
|
+
if value["scheduleStatus"].is_a?(Array)
|
|
267
|
+
value["scheduleStatus"].each_with_index do |status, i|
|
|
268
|
+
add("#{path}/scheduleStatus/#{i}", "invalid scheduling status") unless status.is_a?(String) && status.match?(/\A[1-5]\.\d+(?:\.\d+)?\z/)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
when "TimeZone" then zone(value, path)
|
|
272
|
+
when "TimeZoneRule"
|
|
273
|
+
add("#{path}/recurrenceRules", "at most one rule is allowed") if value["recurrenceRules"].is_a?(Array) && value["recurrenceRules"].size > 1
|
|
274
|
+
if value["recurrenceOverrides"].is_a?(Hash)
|
|
275
|
+
value.fetch("recurrenceOverrides", {}).each do |key, patch|
|
|
276
|
+
add(Jscalendar.pointer("#{path}/recurrenceOverrides", key), "time zone override must be empty") unless patch == {}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def calendar(value, type, path)
|
|
283
|
+
if value.key?("recurrenceId")
|
|
284
|
+
%w[recurrenceRules recurrenceOverrides].each { |name| add("#{path}/#{name}", "not allowed on recurrence instance") if value.key?(name) }
|
|
285
|
+
elsif value.key?("recurrenceIdTimeZone")
|
|
286
|
+
add("#{path}/recurrenceIdTimeZone", "requires recurrenceId")
|
|
287
|
+
end
|
|
288
|
+
if type == "Task" && value.key?("recurrenceRules") && !value.key?("start") && !value.key?("due")
|
|
289
|
+
add("#{path}/recurrenceRules", "task recurrence needs start or due")
|
|
290
|
+
end
|
|
291
|
+
if value["participants"].is_a?(Hash)
|
|
292
|
+
value["participants"].each do |id, person|
|
|
293
|
+
next unless person.is_a?(Hash)
|
|
294
|
+
|
|
295
|
+
if person.key?("sendTo") && (!value["replyTo"].is_a?(Hash) || value["replyTo"].empty?)
|
|
296
|
+
add("#{path}/replyTo", "participants with sendTo require a reply method")
|
|
297
|
+
end
|
|
298
|
+
%w[progress progressUpdated percentComplete].each do |name|
|
|
299
|
+
add(Jscalendar.pointer("#{path}/participants", id) + "/#{name}", "only allowed in a Task") if type != "Task" && person.key?(name)
|
|
300
|
+
end
|
|
301
|
+
%w[delegatedTo delegatedFrom memberOf].each do |name|
|
|
302
|
+
next unless person[name].is_a?(Hash)
|
|
303
|
+
|
|
304
|
+
person[name].each_key do |reference|
|
|
305
|
+
add(Jscalendar.pointer("#{path}/participants/#{id}/#{name}", reference), "unknown participant") unless value["participants"].key?(reference)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
if value["timeZones"].is_a?(Hash)
|
|
311
|
+
references = zone_references(value.except("timeZones"))
|
|
312
|
+
value["timeZones"].each_key do |id|
|
|
313
|
+
location = Jscalendar.pointer("#{path}/timeZones", id)
|
|
314
|
+
add(location, "custom identifier must start with / and be paramtext") unless id.start_with?("/") && paramtext?(id)
|
|
315
|
+
add(location, "unreferenced custom time zone") unless references.include?(id)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
validate_patches(value, path) if @validate_patches
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def validate_patches(value, path)
|
|
322
|
+
%w[recurrenceOverrides localizations].each do |name|
|
|
323
|
+
next unless value[name].is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
value[name].each do |key, patch|
|
|
326
|
+
next unless patch.is_a?(Hash)
|
|
327
|
+
|
|
328
|
+
if name == "recurrenceOverrides" && patch["excluded"] == true
|
|
329
|
+
add(Jscalendar.pointer("#{path}/#{name}", key), "excluded override cannot patch other properties") if patch.size != 1
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
filtered = name == "recurrenceOverrides" ? PatchObject.recurrence(patch) : PatchObject.localization(patch)
|
|
333
|
+
base = value.except("recurrenceOverrides", "localizations")
|
|
334
|
+
begin
|
|
335
|
+
result = PatchObject.new(filtered, **@limits).apply(base, validate: false)
|
|
336
|
+
violations = Validator.new(validate_patches: false, **@limits).validate(result, time_zones: @zones)
|
|
337
|
+
violations.each { |error| add(Jscalendar.pointer("#{path}/#{name}", key) + error.pointer, error.message) }
|
|
338
|
+
rescue ValidationError => e
|
|
339
|
+
add(Jscalendar.pointer("#{path}/#{name}", key), e.message)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def recurrence(value, path)
|
|
346
|
+
%w[byDay byMonthDay byMonth byYearDay byWeekNo byHour byMinute bySecond bySetPosition].each do |name|
|
|
347
|
+
add("#{path}/#{name}", "must not be empty") if value[name].is_a?(Array) && value[name].empty?
|
|
348
|
+
end
|
|
349
|
+
add("#{path}/interval", "must be positive") if value["interval"].is_a?(Integer) && value["interval"].zero?
|
|
350
|
+
add("#{path}/until", "count and until are mutually exclusive") if value.key?("count") && value.key?("until")
|
|
351
|
+
ranges = { "byHour" => 0..23, "byMinute" => 0..59, "bySecond" => 0..60 }
|
|
352
|
+
ranges.merge!("byMonthDay" => -31..31, "byYearDay" => -366..366, "byWeekNo" => -53..53) if value.fetch("rscale", "gregorian") == "gregorian"
|
|
353
|
+
ranges.each do |name, range|
|
|
354
|
+
next unless value[name].is_a?(Array)
|
|
355
|
+
|
|
356
|
+
value[name].each_with_index do |number, i|
|
|
357
|
+
valid = number.is_a?(Integer) && range.cover?(number) && !(range.begin.negative? && number.zero?)
|
|
358
|
+
add("#{path}/#{name}/#{i}", "out of range") unless valid
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
if value["bySetPosition"].is_a?(Array)
|
|
362
|
+
value["bySetPosition"].each_with_index do |number, i|
|
|
363
|
+
add("#{path}/bySetPosition/#{i}", "must be nonzero") if number.is_a?(Integer) && number.zero?
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
return unless value["byMonth"].is_a?(Array)
|
|
367
|
+
|
|
368
|
+
value["byMonth"].each_with_index do |month, i|
|
|
369
|
+
valid = month.is_a?(String) && month.match?(/\A[1-9]\d*L?\z/)
|
|
370
|
+
valid &&= month.match?(/\A(?:[1-9]|1[0-2])\z/) if value.fetch("rscale", "gregorian") == "gregorian"
|
|
371
|
+
add("#{path}/byMonth/#{i}", "invalid calendar month") unless valid
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def zone(value, path)
|
|
376
|
+
rules = %w[standard daylight].flat_map { |name| value[name].is_a?(Array) ? value[name] : [] }
|
|
377
|
+
add(path, "time zone needs a transition rule") if rules.empty?
|
|
378
|
+
add("#{path}/tzId", "invalid paramtext") if value["tzId"].is_a?(String) && !paramtext?(value["tzId"])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def paramtext?(value)
|
|
382
|
+
!value.empty? && !value.match?(/[\x00-\x08\x0A-\x1F\x7F";:,]/)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def zone_references(value)
|
|
386
|
+
case value
|
|
387
|
+
when Hash
|
|
388
|
+
value.flat_map do |key, item|
|
|
389
|
+
if key == "timeZones"
|
|
390
|
+
[]
|
|
391
|
+
else
|
|
392
|
+
(%w[timeZone recurrenceIdTimeZone].include?(key) ? [item] : zone_references(item))
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
when Array then value.flat_map { |item| zone_references(item) }
|
|
396
|
+
else []
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
data/lib/jscalendar.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "bigdecimal"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require "timeout"
|
|
11
|
+
require "tzinfo"
|
|
12
|
+
require "rrule"
|
|
13
|
+
require_relative "jscalendar/version"
|
|
14
|
+
require_relative "jscalendar/errors"
|
|
15
|
+
require_relative "jscalendar/types"
|
|
16
|
+
require_relative "jscalendar/temporal"
|
|
17
|
+
require_relative "jscalendar/validator"
|
|
18
|
+
require_relative "jscalendar/model"
|
|
19
|
+
require_relative "jscalendar/patch_object"
|
|
20
|
+
require_relative "jscalendar/time_zone"
|
|
21
|
+
require_relative "jscalendar/recurrence"
|
|
22
|
+
require_relative "jscalendar/calendar"
|
|
23
|
+
|
|
24
|
+
JSCalendar = Jscalendar unless defined?(JSCalendar)
|
|
25
|
+
|
|
26
|
+
module Jscalendar
|
|
27
|
+
class << self
|
|
28
|
+
def parse(source, strict: true, **limits)
|
|
29
|
+
raise ParseError, "JSON input must be a String" unless source.is_a?(String)
|
|
30
|
+
|
|
31
|
+
Validator.new(**limits)
|
|
32
|
+
options = DEFAULT_LIMITS.merge(limits)
|
|
33
|
+
raise LimitError, "input exceeds max_input_size" if source.bytesize > options[:max_input_size]
|
|
34
|
+
|
|
35
|
+
data = JSON.parse(source, allow_duplicate_key: false, max_nesting: options[:max_depth])
|
|
36
|
+
raise ParseError, "expected Event, Task, or Group" unless data.is_a?(Hash) && %w[Event Task Group].include?(data["@type"])
|
|
37
|
+
|
|
38
|
+
const_get(data["@type"]).new(data, strict: strict, **limits)
|
|
39
|
+
rescue JSON::NestingError => e
|
|
40
|
+
raise LimitError, e.message
|
|
41
|
+
rescue JSON::ParserError, EncodingError => e
|
|
42
|
+
raise ParseError, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate(source, **limits)
|
|
46
|
+
validator = Validator.new(**limits)
|
|
47
|
+
data = if source.is_a?(String)
|
|
48
|
+
parse(source, strict: false, **limits).to_h
|
|
49
|
+
else
|
|
50
|
+
deep_copy(source, max_depth: limits.fetch(:max_depth, DEFAULT_LIMITS[:max_depth]))
|
|
51
|
+
end
|
|
52
|
+
validator.validate(data)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|