jscontact 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 +212 -0
- data/SECURITY.md +6 -0
- data/data/enums.yml +356 -0
- data/data/types.yml +927 -0
- data/lib/jscontact/errors.rb +27 -0
- data/lib/jscontact/localization.rb +55 -0
- data/lib/jscontact/model.rb +236 -0
- data/lib/jscontact/patch_object.rb +89 -0
- data/lib/jscontact/types.rb +261 -0
- data/lib/jscontact/validator.rb +379 -0
- data/lib/jscontact/vcard/from_jscontact.rb +367 -0
- data/lib/jscontact/vcard/structured.rb +135 -0
- data/lib/jscontact/vcard/to_jscontact.rb +510 -0
- data/lib/jscontact/vcard.rb +78 -0
- data/lib/jscontact/version.rb +5 -0
- data/lib/jscontact.rb +45 -0
- data/sig/jscontact.rbs +495 -0
- metadata +104 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
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_localizations: 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" unless @limits[:max_depth].positive?
|
|
17
|
+
|
|
18
|
+
@validate_localizations = validate_localizations
|
|
19
|
+
@errors = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate(value, type: "Card", root: true)
|
|
23
|
+
@errors = []
|
|
24
|
+
inspect_json(value, "", 0, [])
|
|
25
|
+
object(value, type, "", root: root)
|
|
26
|
+
@errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_json(value, pointer: "")
|
|
30
|
+
@errors = []
|
|
31
|
+
inspect_json(value, pointer, 0, [])
|
|
32
|
+
@errors
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def add(pointer, message)
|
|
38
|
+
@errors << Violation.new(pointer: pointer, message: message)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def inspect_json(value, path, depth, ancestors)
|
|
42
|
+
case value
|
|
43
|
+
when Hash, Array
|
|
44
|
+
raise LimitError.new("JSON nesting exceeds max_depth", pointer: path) if depth >= @limits[:max_depth]
|
|
45
|
+
|
|
46
|
+
if ancestors.include?(value.object_id)
|
|
47
|
+
add(path, "cyclic JSON data")
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
next_ancestors = ancestors + [value.object_id]
|
|
51
|
+
if value.is_a?(Hash)
|
|
52
|
+
value.each do |key, item|
|
|
53
|
+
unless key.is_a?(String) && unicode?(key)
|
|
54
|
+
add(path, "JSON member names must be valid Unicode strings")
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
inspect_json(item, Jscontact.pointer(path, key), depth + 1, next_ancestors)
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
value.each_with_index { |item, index| inspect_json(item, Jscontact.pointer(path, index), depth + 1, next_ancestors) }
|
|
61
|
+
end
|
|
62
|
+
when String
|
|
63
|
+
add(path, "string is not valid I-JSON Unicode") unless unicode?(value)
|
|
64
|
+
check_data_uri_limit(value, path)
|
|
65
|
+
when Integer
|
|
66
|
+
add(path, "integer is outside the exact I-JSON range") unless Types.integer?(value)
|
|
67
|
+
when Float
|
|
68
|
+
add(path, "number must be finite") unless value.finite?
|
|
69
|
+
when TrueClass, FalseClass, NilClass
|
|
70
|
+
nil
|
|
71
|
+
else
|
|
72
|
+
add(path, "value is not JSON-compatible")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def unicode?(value)
|
|
77
|
+
return false unless value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
78
|
+
|
|
79
|
+
value.each_codepoint.none? { |point| point.between?(0xD800, 0xDFFF) || point.between?(0xFDD0, 0xFDEF) || (point & 0xFFFF) >= 0xFFFE }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def check_data_uri_limit(value, path)
|
|
83
|
+
return unless value.b.byteslice(0, 5)&.downcase == "data:"
|
|
84
|
+
|
|
85
|
+
header, payload = value.b.split(",", 2)
|
|
86
|
+
return unless payload
|
|
87
|
+
|
|
88
|
+
payload = URI::DEFAULT_PARSER.unescape(payload)
|
|
89
|
+
size = if header.downcase.end_with?(";base64")
|
|
90
|
+
(payload.bytesize * 3 / 4) - [payload[/=+\z/].to_s.length, 2].min
|
|
91
|
+
else
|
|
92
|
+
payload.bytesize
|
|
93
|
+
end
|
|
94
|
+
raise LimitError.new("data URI exceeds max_data_uri_size", pointer: path) if size > @limits[:max_data_uri_size]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def object(value, type, path, root: false)
|
|
98
|
+
unless value.is_a?(Hash)
|
|
99
|
+
add(path, "expected #{type} object")
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
fields = SCHEMA.fetch(type).fetch("properties")
|
|
103
|
+
reserved = SCHEMA.fetch(type).fetch("reserved", [])
|
|
104
|
+
add(Jscontact.pointer(path, "@type"), "expected @type #{type}") if (root || value.key?("@type")) && value["@type"] != type
|
|
105
|
+
fields.each do |name, rule|
|
|
106
|
+
required = rule["required"] || (type == "Card" && rule.fetch("required_versions", []).include?(value["version"]))
|
|
107
|
+
add(Jscontact.pointer(path, name), "required property is missing") if required && !value.key?(name)
|
|
108
|
+
end
|
|
109
|
+
value.each do |name, item|
|
|
110
|
+
next unless name.is_a?(String) && unicode?(name)
|
|
111
|
+
|
|
112
|
+
location = Jscontact.pointer(path, name)
|
|
113
|
+
if name.downcase == "extra" || reserved.any? { |entry| entry.casecmp?(name) }
|
|
114
|
+
add(location, "#{name} is reserved")
|
|
115
|
+
elsif fields.key?(name)
|
|
116
|
+
check(item, fields[name].fetch("type"), location) unless name == "@type"
|
|
117
|
+
elsif KNOWN_NAMES.any? { |known| known.casecmp?(name) }
|
|
118
|
+
add(location, "property name has incorrect case or is not allowed in #{type}")
|
|
119
|
+
elsif !Types.vendor?(name) && !name.match?(/\A(?:[a-z][A-Za-z0-9]*|@[A-Za-z0-9]+)\z/)
|
|
120
|
+
add(location, "invalid property name")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
relationships(value, type, path)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def check(value, signature, path)
|
|
127
|
+
return if value.is_a?(String) && !unicode?(value)
|
|
128
|
+
|
|
129
|
+
if SCHEMA.key?(signature)
|
|
130
|
+
object(value, signature, path)
|
|
131
|
+
elsif signature.end_with?("[]")
|
|
132
|
+
check_array(value, signature.delete_suffix("[]"), path)
|
|
133
|
+
elsif (match = /\A(\w+)\[(.+)\]\z/.match(signature))
|
|
134
|
+
check_map(value, match[1], match[2], path)
|
|
135
|
+
elsif signature.start_with?("Enum:")
|
|
136
|
+
check_enum(value, signature.delete_prefix("Enum:"), path)
|
|
137
|
+
elsif signature.start_with?("EnumMap:")
|
|
138
|
+
check_enum_map(value, signature.delete_prefix("EnumMap:"), path, true_only: true)
|
|
139
|
+
elsif signature == "Contexts"
|
|
140
|
+
check_enum_map(value, "context", path, true_only: false)
|
|
141
|
+
elsif signature == "PartialDate|Timestamp"
|
|
142
|
+
type = value.is_a?(Hash) ? value.fetch("@type", "PartialDate") : "PartialDate"
|
|
143
|
+
if %w[PartialDate Timestamp].include?(type)
|
|
144
|
+
object(value, type, path)
|
|
145
|
+
else
|
|
146
|
+
add(Jscontact.pointer(path, "@type"), "expected PartialDate or Timestamp")
|
|
147
|
+
end
|
|
148
|
+
elsif signature == "VCardParams"
|
|
149
|
+
check_parameters(value, path)
|
|
150
|
+
elsif signature == "JCardProp"
|
|
151
|
+
check_jcard_property(value, path)
|
|
152
|
+
elsif signature == "PatchObject"
|
|
153
|
+
add(path, "expected PatchObject") unless value.is_a?(Hash)
|
|
154
|
+
raise LimitError.new("patch count exceeds max_patches", pointer: path) if value.is_a?(Hash) && value.size > @limits[:max_patches]
|
|
155
|
+
else
|
|
156
|
+
add(path, "invalid #{signature} value") unless scalar?(value, signature)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def check_array(value, signature, path)
|
|
161
|
+
unless value.is_a?(Array)
|
|
162
|
+
add(path, "expected an array")
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
raise LimitError.new("array exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
|
|
166
|
+
|
|
167
|
+
value.each_with_index { |item, index| check(item, signature, Jscontact.pointer(path, index)) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def check_map(value, key_type, item_type, path)
|
|
171
|
+
unless value.is_a?(Hash)
|
|
172
|
+
add(path, "expected a map")
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
raise LimitError.new("map exceeds max_map_size", pointer: path) if value.size > @limits[:max_map_size]
|
|
176
|
+
|
|
177
|
+
value.each do |key, item|
|
|
178
|
+
next unless key.is_a?(String) && unicode?(key)
|
|
179
|
+
|
|
180
|
+
location = Jscontact.pointer(path, key)
|
|
181
|
+
add(location, "invalid #{key_type} map key") unless scalar?(key, key_type)
|
|
182
|
+
check(item, item_type, location)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def check_enum(value, enum, path)
|
|
187
|
+
return if value.is_a?(String) && (ENUMS.fetch(enum).include?(value) || Types.vendor?(value))
|
|
188
|
+
|
|
189
|
+
add(path, "expected a registered or vendor-specific #{enum} value")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def check_enum_map(value, enum, path, true_only:)
|
|
193
|
+
check_map(value, "String", true_only ? "True" : "Boolean", path)
|
|
194
|
+
return unless value.is_a?(Hash)
|
|
195
|
+
|
|
196
|
+
value.each_key { |key| check_enum(key, enum, Jscontact.pointer(path, key)) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def check_parameters(value, path)
|
|
200
|
+
unless value.is_a?(Hash)
|
|
201
|
+
add(path, "expected vCard parameter map")
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
value.each do |key, item|
|
|
205
|
+
location = Jscontact.pointer(path, key)
|
|
206
|
+
add(location, "invalid lowercase parameter name") unless key.is_a?(String) && key.match?(/\A[a-z0-9-]+\z/)
|
|
207
|
+
unless item.is_a?(String) || (item.is_a?(Array) && !item.empty? && item.all?(String))
|
|
208
|
+
add(location, "parameter must be a string or nonempty string array")
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def check_jcard_property(value, path)
|
|
214
|
+
unless value.is_a?(Array) && value.size >= 4
|
|
215
|
+
add(path, "expected jCard property array")
|
|
216
|
+
return
|
|
217
|
+
end
|
|
218
|
+
unless value[0].is_a?(String) && value[0].ascii_only? && value[0].match?(/\A[a-z0-9-]+\z/)
|
|
219
|
+
add(Jscontact.pointer(path, 0), "invalid lowercase vCard property name")
|
|
220
|
+
end
|
|
221
|
+
check_parameters(value[1], Jscontact.pointer(path, 1))
|
|
222
|
+
unless value[2].is_a?(String) && value[2].ascii_only? && value[2].match?(/\A[a-z0-9-]+\z/)
|
|
223
|
+
add(Jscontact.pointer(path, 2), "invalid lowercase vCard value type")
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
value.drop(3).each_with_index { |item, index| check_jcard_value(item, value[2], Jscontact.pointer(path, index + 3)) }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def check_jcard_value(value, type, path)
|
|
231
|
+
return if value.is_a?(String) && !unicode?(value)
|
|
232
|
+
|
|
233
|
+
if value.is_a?(Array) && %w[text unknown].include?(type)
|
|
234
|
+
add(path, "structured jCard values must not be empty") if value.empty?
|
|
235
|
+
value.each_with_index { |item, index| check_jcard_value(item, type, Jscontact.pointer(path, index)) }
|
|
236
|
+
return
|
|
237
|
+
end
|
|
238
|
+
valid = case type
|
|
239
|
+
when "boolean" then [true, false].include?(value)
|
|
240
|
+
when "integer" then Types.integer?(value)
|
|
241
|
+
when "float" then (value.is_a?(Integer) || value.is_a?(Float)) && value.finite?
|
|
242
|
+
when "uri" then Types.uri?(value)
|
|
243
|
+
when "language-tag" then Types.language_tag?(value)
|
|
244
|
+
when "text", "unknown" then value.is_a?(String) && !value.match?(/[\x00-\x08\x0B-\x1F\x7F]/)
|
|
245
|
+
when "date", "time", "date-time", "date-and-or-time", "timestamp", "utc-offset"
|
|
246
|
+
Types.jcard_temporal?(value, type)
|
|
247
|
+
else true # Unknown registered value types must survive round trips.
|
|
248
|
+
end
|
|
249
|
+
add(path, "invalid jCard #{type} value") unless valid
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def scalar?(value, signature)
|
|
253
|
+
case signature
|
|
254
|
+
when "String" then value.is_a?(String)
|
|
255
|
+
when "NonEmptyString" then value.is_a?(String) && !value.empty?
|
|
256
|
+
when "Boolean" then [true, false].include?(value)
|
|
257
|
+
when "True" then value == true
|
|
258
|
+
when "Int" then Types.integer?(value)
|
|
259
|
+
when "UnsignedInt" then Types.integer?(value, minimum: 0)
|
|
260
|
+
when "PositiveInt" then Types.integer?(value, minimum: 1)
|
|
261
|
+
when "Preference" then Types.integer?(value, minimum: 1, maximum: 100)
|
|
262
|
+
when "Month" then Types.integer?(value, minimum: 1, maximum: 12)
|
|
263
|
+
when "Day" then Types.integer?(value, minimum: 1, maximum: 31)
|
|
264
|
+
when "Id" then Types.id?(value)
|
|
265
|
+
when "UTCDateTime" then Types.utc_date_time?(value)
|
|
266
|
+
when "URI" then Types.uri?(value)
|
|
267
|
+
when "GeoURI" then geo_uri?(value)
|
|
268
|
+
when "Email" then Types.email?(value)
|
|
269
|
+
when "LanguageTag" then Types.language_tag?(value)
|
|
270
|
+
when "Script" then Types.script?(value)
|
|
271
|
+
when "Version" then %w[1.0 2.0].include?(value)
|
|
272
|
+
when "VCardName" then value.is_a?(String) && value.match?(/\A[A-Za-z0-9-]+\z/)
|
|
273
|
+
when "CalendarScale" then value.is_a?(String) && (ENUMS["calendarScale"].include?(value) || Types.vendor?(value))
|
|
274
|
+
when "CountryCode" then value.is_a?(String) && !TZInfo::Country.get(value.upcase).nil?
|
|
275
|
+
when "TimeZone" then value.is_a?(String) && !TZInfo::Timezone.get(value).nil?
|
|
276
|
+
when "MediaType" then value.is_a?(String) && value.match?(%r{\A[A-Za-z0-9!\#$&^_.+-]+/[A-Za-z0-9!\#$&^_.+-]+(?:\s*;[^\r\n]+)?\z})
|
|
277
|
+
else false
|
|
278
|
+
end
|
|
279
|
+
rescue TZInfo::InvalidCountryCode, TZInfo::InvalidTimezoneIdentifier
|
|
280
|
+
false
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def geo_uri?(value)
|
|
284
|
+
return false unless value.is_a?(String) && value.ascii_only?
|
|
285
|
+
|
|
286
|
+
coordinates, *parameters = value.split(";", -1)
|
|
287
|
+
match = /\Ageo:(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)(?:,-?\d+(?:\.\d+)?)?\z/i.match(coordinates)
|
|
288
|
+
return false unless match
|
|
289
|
+
|
|
290
|
+
crs = "wgs84"
|
|
291
|
+
parameters.each_with_index do |parameter, index|
|
|
292
|
+
name, content = parameter.split("=", 2)
|
|
293
|
+
return false unless name&.match?(/\A[A-Za-z0-9-]+\z/)
|
|
294
|
+
|
|
295
|
+
case name.downcase
|
|
296
|
+
when "crs"
|
|
297
|
+
return false unless index.zero? && content&.match?(/\A[A-Za-z0-9-]+\z/)
|
|
298
|
+
|
|
299
|
+
crs = content.downcase
|
|
300
|
+
when "u"
|
|
301
|
+
return false unless index == (parameters.first.downcase.start_with?("crs=") ? 1 : 0)
|
|
302
|
+
return false unless content&.match?(/\A\d+(?:\.\d+)?\z/)
|
|
303
|
+
else
|
|
304
|
+
return false if content && !content.match?(/\A(?:[A-Za-z0-9_.!~*'()\[\]:&+$-]|%[0-9A-Fa-f]{2})+\z/)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
return true unless crs == "wgs84"
|
|
308
|
+
|
|
309
|
+
match[1].match?(/\A-?\d{1,2}(?:\.\d+)?\z/) && match[2].match?(/\A-?\d{1,3}(?:\.\d+)?\z/) &&
|
|
310
|
+
match[1].to_f.between?(-90, 90) && match[2].to_f.between?(-180, 180)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def relationships(value, type, path)
|
|
314
|
+
case type
|
|
315
|
+
when "Card"
|
|
316
|
+
add(Jscontact.pointer(path, "members"), "members requires kind group") if value.key?("members") && value["kind"] != "group"
|
|
317
|
+
@errors.concat(Localization.validate(value, **@limits)) if @validate_localizations && value["localizations"].is_a?(Hash) && defined?(Localization)
|
|
318
|
+
when "Name", "Address" then components(value, type, path)
|
|
319
|
+
when "Organization"
|
|
320
|
+
alternatives(value, %w[name units], path)
|
|
321
|
+
add(Jscontact.pointer(path, "units"), "units must not be empty") if value["units"].is_a?(Array) && value["units"].empty?
|
|
322
|
+
when "SpeakToAs" then alternatives(value, %w[grammaticalGender pronouns], path)
|
|
323
|
+
when "OnlineService" then alternatives(value, %w[uri user], path)
|
|
324
|
+
when "Author"
|
|
325
|
+
add(path, "author needs a property other than @type") if (value.keys - ["@type"]).empty?
|
|
326
|
+
when "PartialDate" then partial_date(value, path)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def alternatives(value, names, path)
|
|
331
|
+
add(path, "at least one of #{names.join(", ")} is required") unless names.any? { |name| value.key?(name) }
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def components(value, type, path)
|
|
335
|
+
alternatives(value, type == "Name" ? %w[components full] : %w[components coordinates countryCode full timeZone], path)
|
|
336
|
+
list = value["components"]
|
|
337
|
+
if value.key?("defaultSeparator") && (value["isOrdered"] != true || !value.key?("components"))
|
|
338
|
+
add(Jscontact.pointer(path, "defaultSeparator"), "requires ordered components")
|
|
339
|
+
end
|
|
340
|
+
if value["sortAs"].is_a?(Hash)
|
|
341
|
+
value["sortAs"].each_key do |kind|
|
|
342
|
+
next if list.is_a?(Array) && list.any? { |component| component.is_a?(Hash) && component["kind"] == kind }
|
|
343
|
+
|
|
344
|
+
add(Jscontact.pointer(Jscontact.pointer(path, "sortAs"), kind), "sort key must name an existing component kind")
|
|
345
|
+
end
|
|
346
|
+
add(Jscontact.pointer(path, "sortAs"), "requires components") unless value.key?("components")
|
|
347
|
+
end
|
|
348
|
+
return unless list.is_a?(Array)
|
|
349
|
+
|
|
350
|
+
component_path = Jscontact.pointer(path, "components")
|
|
351
|
+
unless list.any? { |component| component.is_a?(Hash) && component["kind"] != "separator" }
|
|
352
|
+
add(component_path, "at least one non-separator component is required")
|
|
353
|
+
end
|
|
354
|
+
list.each_with_index do |component, index|
|
|
355
|
+
next unless component.is_a?(Hash)
|
|
356
|
+
|
|
357
|
+
location = Jscontact.pointer(component_path, index)
|
|
358
|
+
if component["kind"] == "separator"
|
|
359
|
+
add(location, "separator requires ordered components") unless value["isOrdered"] == true
|
|
360
|
+
add(location, "consecutive separators are forbidden") if index.positive? && list[index - 1].is_a?(Hash) && list[index - 1]["kind"] == "separator"
|
|
361
|
+
end
|
|
362
|
+
if component.key?("phonetic") && !value.key?("phoneticScript") && !value.key?("phoneticSystem")
|
|
363
|
+
add(Jscontact.pointer(location, "phonetic"), "requires phoneticScript or phoneticSystem on its parent")
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def partial_date(value, path)
|
|
369
|
+
alternatives(value, %w[year month day], path)
|
|
370
|
+
add(Jscontact.pointer(path, "month"), "month requires year or day") if value.key?("month") && !value.key?("year") && !value.key?("day")
|
|
371
|
+
add(Jscontact.pointer(path, "day"), "day requires month") if value.key?("day") && !value.key?("month")
|
|
372
|
+
year, month, day = value.values_at("year", "month", "day")
|
|
373
|
+
return unless Types.integer?(month, minimum: 1, maximum: 12) && Types.integer?(day, minimum: 1, maximum: 31)
|
|
374
|
+
return if year && !Types.integer?(year, minimum: 0)
|
|
375
|
+
|
|
376
|
+
add(path, "invalid Gregorian calendar date") unless Date.valid_date?(year || 2000, month, day, Date::GREGORIAN)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|