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.
@@ -0,0 +1,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ module VCard
5
+ # Each conversion works on private state; registry and mapping tables are immutable.
6
+ class ToJSContact
7
+ def initialize(properties, version: "2.0")
8
+ @properties = properties
9
+ @card = { "@type" => "Card", "version" => version }
10
+ @ids = {}
11
+ @counters = Hash.new(0)
12
+ @reserved = properties.filter_map { |prop| prop[1]["prop-id"] }
13
+ @paths = {}.compare_by_identity
14
+ @groups = {}
15
+ @anniversaries = {}
16
+ @scalar_seen = {}
17
+ @labels = properties.select { |prop| prop[0] == "x-ablabel" }.to_h { |prop| [prop[1]["group"]&.downcase, prop[3]] }
18
+ end
19
+
20
+ def convert
21
+ @properties.each_with_index do |prop, index|
22
+ prop[1].each do |key, value|
23
+ next unless %w[altid language phonetic script prop-id pref mediatype index level service-type username
24
+ author author-name created calscale label geo tz cc derived jscomps jsptr].include?(key)
25
+ next if value.is_a?(String)
26
+
27
+ raise ConversionError.new("#{key.upcase} must have a single value", pointer: "/vCardProps/#{index}/1/#{key}")
28
+ end
29
+ end
30
+ language = @properties.find { |prop| prop[0] == "language" }&.[](3)
31
+ unless language || @properties.any? { |prop| prop[0] == "fn" && !prop[1]["language"] }
32
+ candidates = @properties.reject { |prop| prop[1]["phonetic"] }.filter_map { |prop| prop[1]["language"] }
33
+ language = candidates.tally.max_by { |_, count| count }&.first
34
+ end
35
+ @card["language"] = normalize_language(language) if language
36
+ groups = @properties.reject { |prop| %w[jsprop x-ablabel].include?(prop[0]) || prop[1]["phonetic"] }
37
+ .group_by { |prop| alternative_key(prop) }
38
+ priority = { "org" => 0, "adr" => 0, "bday" => 1, "deathdate" => 1, "anniversary" => 1 }
39
+ groups = groups.values.sort_by { |props| priority.fetch(props.first[0], 2) }
40
+ alternatives = []
41
+ groups.each do |props|
42
+ primary = props.min_by { |prop| [language_rank(prop), prop[1].length] }
43
+ path = convert_property(primary)
44
+ @paths[primary] = path
45
+ props.reject { |prop| prop.equal?(primary) }.each { |prop| alternatives << [prop, path] }
46
+ end
47
+ alternatives.each { |prop, path| convert_alternative(prop, path) }
48
+ @properties.select { |prop| prop[1]["phonetic"] }.each { |prop| convert_phonetic(prop) }
49
+ @properties.select { |prop| prop[0] == "x-ablabel" }.each do |prop|
50
+ preserve(prop) unless prop[1]["group"] && @properties.any? do |other|
51
+ other[0] != "x-ablabel" && other[1]["group"]&.downcase == prop[1]["group"]&.downcase &&
52
+ @paths[other] && value_at(@card, @paths[other]).is_a?(Hash) && value_at(@card, @paths[other])["label"] == prop[3]
53
+ end
54
+ end
55
+ # Anniversary requires a date; a place alone stays losslessly in vCardProps.
56
+ @card.fetch("anniversaries", {}).delete_if do |id, anniversary|
57
+ next false if anniversary["date"]
58
+
59
+ @properties.each { |prop| preserve(prop) if @paths[prop] == ["anniversaries", id] }
60
+ true
61
+ end
62
+ @card.delete("anniversaries") if @card["anniversaries"] && @card["anniversaries"].empty?
63
+ apply_jsprops
64
+ @card
65
+ end
66
+
67
+ private
68
+
69
+ def alternative_key(prop)
70
+ return [prop[0], "singleton"] if %w[fn n gramgender].include?(prop[0])
71
+ return [prop[0], prop[1]["altid"]] if prop[1]["altid"]
72
+
73
+ [prop.object_id]
74
+ end
75
+
76
+ def language_rank(prop)
77
+ language = prop[1]["language"]
78
+ return 0 unless language
79
+ return 1 if normalize_language(language) == @card["language"]
80
+
81
+ 2
82
+ end
83
+
84
+ def normalize_language(language)
85
+ raise ConversionError, "LANGUAGE must be a single value" unless language.is_a?(String)
86
+
87
+ language.split("-").map.with_index do |part, index|
88
+ if !index.zero? && part.length == 4
89
+ part.capitalize
90
+ elsif !index.zero? && part.length == 2
91
+ part.upcase
92
+ else
93
+ part.downcase
94
+ end
95
+ end.join("-")
96
+ end
97
+
98
+ # RFC 9555 §2.3.11: localizations are atomic patches relative to the base Card.
99
+ def convert_alternative(prop, path)
100
+ return preserve(prop) unless path && prop[1]["language"]
101
+
102
+ language = normalize_language(prop[1]["language"])
103
+ return preserve(prop) if language == @card["language"]
104
+
105
+ original = @card
106
+ @card = copy(original)
107
+ convert_property(prop, forced_path: path)
108
+ translated = value_at(@card, path)
109
+ @card = original
110
+ changes = difference(value_at(original, path), translated, path.map { |token| token.gsub("~", "~0").gsub("/", "~1") }.join("/"))
111
+ (@card["localizations"] ||= {})[language] ||= {}
112
+ @card["localizations"][language].merge!(changes)
113
+ @paths[prop] = path
114
+ ensure
115
+ @card = original if original
116
+ end
117
+
118
+ def convert_property(prop, forced_path: nil)
119
+ name, params, type, value = prop
120
+ return if params["derived"]&.casecmp?("true")
121
+ return preserve(prop) if type == "unknown" && !REGISTRY[name]["type"].eql?("unknown")
122
+
123
+ path = if PROPERTIES.key?(name)
124
+ convert_mapped(prop, forced_path)
125
+ elsif SCALARS.key?(name)
126
+ return preserve(prop) if @scalar_seen[name] && !forced_path
127
+
128
+ @scalar_seen[name] = true
129
+ return preserve(prop) if name == "kind" && !ENUMS["cardKind"].include?(value.downcase)
130
+
131
+ key = SCALARS.fetch(name)
132
+ converted = %w[created rev].include?(name) ? timestamp(value) : value
133
+ converted = converted.downcase if name == "kind"
134
+ converted = normalize_language(converted) if name == "language"
135
+ @card[key] = converted
136
+ preserve(prop) unless (params.keys - %w[language altid derived]).empty?
137
+ [key]
138
+ else
139
+ convert_special(prop, forced_path)
140
+ end
141
+ @paths[prop] = path if path.is_a?(Array)
142
+ path
143
+ end
144
+
145
+ def convert_special(prop, forced_path)
146
+ name, params, _type, value = prop
147
+ case name
148
+ when "fn"
149
+ (@card["name"] ||= {})["full"] = value
150
+ preserve(prop) unless (params.keys - %w[language altid derived]).empty?
151
+ ["name"]
152
+ when "n"
153
+ object = Structured.read(value, params, name: true)
154
+ object = parameters(object, prop, object["isOrdered"] ? %w[jscomps sort-as] : %w[sort-as])
155
+ (@card["name"] ||= {}).merge!(object)
156
+ ["name"]
157
+ when "gramgender"
158
+ return preserve(prop) unless ENUMS["gender"].include?(value.downcase)
159
+
160
+ object = parameters({ "grammaticalGender" => value.downcase }, prop, common: false)
161
+ (@card["speakToAs"] ||= {}).merge!(object)
162
+ ["speakToAs"]
163
+ when "org" then convert_organization(prop, forced_path)
164
+ when "adr", "geo", "tz" then convert_address(prop, forced_path)
165
+ when "bday", "birthplace", "deathdate", "deathplace", "anniversary" then convert_anniversary(prop, forced_path)
166
+ when "member", "categories"
167
+ return preserve(prop) if name == "member" && !@properties.find { |item| item[0] == "kind" }&.[](3)&.casecmp?("group")
168
+
169
+ key = name == "member" ? "members" : "keywords"
170
+ prop[3..].each { |item| (@card[key] ||= {})[item] = true }
171
+ preserve(prop) unless (params.keys - %w[pref]).empty?
172
+ [key]
173
+ when "related"
174
+ convert_relation(prop)
175
+ else preserve(prop)
176
+ end
177
+ end
178
+
179
+ def convert_relation(prop)
180
+ _, params, _, value = prop
181
+ known, unknown = parameter_values(params["type"]).partition { |entry| ENUMS["relation"].include?(entry.downcase) }
182
+ object = parameters({ "relation" => known.to_h { |entry| [entry.downcase, true] } }, prop, %w[type], common: false)
183
+ (object["vCardParams"] ||= {})["type"] = unknown.length == 1 ? unknown.first : unknown unless unknown.empty?
184
+ map = @card["relatedTo"] ||= {}
185
+ if (previous = map[value])
186
+ previous["relation"].merge!(object["relation"])
187
+ old_params = previous.fetch("vCardParams", {})
188
+ new_params = object.fetch("vCardParams", {})
189
+ if new_params.any? { |key, item| old_params.key?(key) && old_params[key] != item }
190
+ preserve(prop)
191
+ elsif !new_params.empty?
192
+ previous["vCardParams"] = old_params.merge(new_params)
193
+ end
194
+ else
195
+ map[value] = object
196
+ end
197
+ ["relatedTo", value]
198
+ end
199
+
200
+ def convert_mapped(prop, forced_path)
201
+ name, params, type, value = prop
202
+ map, key, kind = PROPERTIES.fetch(name)
203
+ return preserve(prop) if key == "uri" && type != "uri" && name != "socialprofile"
204
+
205
+ key = "user" if name == "socialprofile" && type != "uri"
206
+ path = forced_path || (map.split("/") + [identifier(map, prop)])
207
+ object = { key => value }
208
+ object["kind"] = kind if kind
209
+ object["vCardName"] = "impp" if name == "impp"
210
+ consumed = []
211
+ case name
212
+ when "impp", "socialprofile"
213
+ { "service-type" => "service", "username" => "user" }.each do |parameter, property|
214
+ object[property] = params[parameter] if params[parameter]
215
+ consumed << parameter
216
+ end
217
+ when "note"
218
+ object["created"] = timestamp(params["created"], compact: true) if params["created"]
219
+ author = { "name" => params["author-name"], "uri" => params["author"] }.compact
220
+ object["author"] = author unless author.empty?
221
+ consumed += %w[created author author-name]
222
+ when "expertise", "hobby", "interest"
223
+ if params["level"]
224
+ level = params["level"].downcase
225
+ object["level"] = name == "expertise" ? { "beginner" => "low", "average" => "medium", "expert" => "high" }.fetch(level, level) : level
226
+ end
227
+ consumed << "level"
228
+ when "title", "role"
229
+ organizations = @groups[params["group"]&.downcase]
230
+ object["organizationId"] = organizations.first if params["group"] && organizations&.length == 1
231
+ end
232
+ if %w[expertise hobby interest org-directory].include?(name) && params["index"]
233
+ object["listAs"] = integer(params["index"], "INDEX")
234
+ consumed << "index"
235
+ end
236
+ object = parameters(object, prop, consumed)
237
+ set_at(path, object)
238
+ # A comma-separated NICKNAME contains independent nicknames (RFC 6350 §6.2.3).
239
+ if name == "nickname"
240
+ prop[4..].each do |nickname|
241
+ set_at(map.split("/") + [identifier(map, [name, {}, type, nickname])], object.merge("name" => nickname))
242
+ end
243
+ end
244
+ path
245
+ end
246
+
247
+ def convert_organization(prop, forced_path)
248
+ _, params, _, values = prop
249
+ values = Array(values)
250
+ object = {}
251
+ object["name"] = values.first unless values.first.to_s.empty?
252
+ object["units"] = values.drop(1).map { |value| { "name" => value } } if values.length > 1
253
+ sort = Array(params["sort-as"])
254
+ object["sortAs"] = sort.first if sort.first && !sort.first.empty?
255
+ object.fetch("units", []).each_with_index { |unit, i| unit["sortAs"] = sort[i + 1] if sort[i + 1] && !sort[i + 1].empty? }
256
+ path = forced_path || ["organizations", identifier("organizations", prop)]
257
+ object = parameters(object, prop, %w[sort-as])
258
+ set_at(path, object)
259
+ (@groups[params["group"]&.downcase] ||= []) << path.last if params["group"] && !forced_path
260
+ path
261
+ end
262
+
263
+ # RFC 9555 §2.8.3: grouped and ungrouped geographic properties share an address.
264
+ def convert_address(prop, forced_path)
265
+ name, params, type, value = prop
266
+ group = params["group"]&.downcase
267
+ grouped = @properties.any? { |item| %w[adr geo tz].include?(item[0]) && item[1]["group"] }
268
+ related = (@properties.find { |item| %w[adr geo tz].include?(item[0]) && item[1]["group"]&.downcase == group && @paths[item] } if group || grouped)
269
+ path = forced_path || (related && @paths[related]) || ["addresses", identifier("addresses", prop)]
270
+ object = case name
271
+ when "adr"
272
+ Structured.read(value, params, name: false).tap do |address|
273
+ { "label" => "full", "geo" => "coordinates", "cc" => "countryCode" }.each do |parameter, property|
274
+ address[property] = params[parameter] if params[parameter]
275
+ end
276
+ address["timeZone"] = timezone(params["tz"], "text") if params["tz"]
277
+ end
278
+ when "geo" then { "coordinates" => value }
279
+ when "tz"
280
+ zone = timezone(value, type)
281
+ return preserve(prop) unless zone
282
+
283
+ { "timeZone" => zone }
284
+ end
285
+ object = parameters(object, prop, %w[label geo tz cc] + (object["isOrdered"] ? %w[jscomps] : []))
286
+ set_at(path, (value_at(@card, path) || {}).merge(object))
287
+ path
288
+ end
289
+
290
+ def timezone(value, type)
291
+ return value if type == "text" && !value.match?(/\A[+-]\d{2}:?\d{2}\z/)
292
+ return unless type == "utc-offset" || value.match?(/\A[+-]\d{2}:?\d{2}\z/)
293
+
294
+ match = /\A([+-])(\d{2}):?00\z/.match(value)
295
+ return unless match
296
+
297
+ hours = match[2].to_i * (match[1] == "-" ? -1 : 1)
298
+ return unless (-12..14).cover?(hours)
299
+ return "Etc/UTC" if hours.zero?
300
+
301
+ "Etc/GMT#{hours.positive? ? "-" : "+"}#{hours.abs}"
302
+ end
303
+
304
+ # RFC 9555 §§2.2.2 and 2.5.1: dates and places merge by anniversary kind.
305
+ def convert_anniversary(prop, forced_path)
306
+ name, params, type, value = prop
307
+ kind = { "bday" => "birth", "birthplace" => "birth", "deathdate" => "death", "deathplace" => "death", "anniversary" => "wedding" }.fetch(name)
308
+ converted = if name.end_with?("place")
309
+ return preserve(prop) unless type == "text" || (type == "uri" && value.start_with?("geo:"))
310
+
311
+ { "place" => { type == "uri" ? "coordinates" : "full" => value } }
312
+ else
313
+ # vCard dates use CALSCALE; JSContact date numbers always use Gregorian.
314
+ # RFC 6350 §5.8 requires ignoring calendars we cannot interpret.
315
+ return preserve(prop) if params["calscale"] && !params["calscale"].casecmp?("gregorian")
316
+
317
+ date = anniversary_date(value, type)
318
+ return preserve(prop) unless date
319
+
320
+ date["calendarScale"] = params["calscale"].downcase if params["calscale"] && date["@type"] != "Timestamp"
321
+ { "date" => date }
322
+ end
323
+ path = forced_path || @anniversaries[kind] || ["anniversaries", identifier("anniversaries", prop)]
324
+ object = value_at(@card, path) || { "kind" => kind }
325
+ return preserve(prop) if converted.keys.any? { |key| object.key?(key) } && !forced_path
326
+
327
+ if name.end_with?("place")
328
+ converted["place"] = parameters(converted["place"], prop, common: false)
329
+ (converted["place"]["vCardParams"] ||= {})["prop-id"] = params["prop-id"] if params["prop-id"] && params["prop-id"] != path.last
330
+ object = object.merge(converted)
331
+ else
332
+ consumed = converted["date"]["@type"] == "Timestamp" ? [] : %w[calscale]
333
+ object = parameters(object.merge(converted), prop, consumed, common: false)
334
+ end
335
+ @anniversaries[kind] = path
336
+ set_at(path, object)
337
+ path
338
+ end
339
+
340
+ def anniversary_date(value, type)
341
+ return unless %w[date date-and-or-time date-time timestamp].include?(type)
342
+ return { "@type" => "Timestamp", "utc" => timestamp(value) } if /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}(?::\d{2})?)\z/.match?(value)
343
+
344
+ match = /\A(?:(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?|--(\d{2})-(\d{2}))\z/.match(value)
345
+ return unless match
346
+
347
+ { "year" => match[1]&.to_i, "month" => (match[2] || match[4])&.to_i, "day" => (match[3] || match[5])&.to_i }.compact
348
+ end
349
+
350
+ # RFC 9555 §2.3: only parameters with a counterpart leave vCardParams.
351
+ def parameters(object, prop, consumed = [], common: true)
352
+ name, params = prop
353
+ remaining = params.except(*(%w[prop-id altid language derived] + consumed))
354
+ fields = target_fields(name)
355
+ if common
356
+ object["pref"] = integer(remaining.delete("pref"), "PREF") if remaining["pref"] && fields.key?("pref")
357
+ object["mediaType"] = remaining.delete("mediatype") if remaining["mediatype"] && fields.key?("mediaType")
358
+ types = parameter_values(remaining.delete("type"))
359
+ leftovers = []
360
+ types.each do |type|
361
+ lower = type.downcase
362
+ if fields.key?("contexts") && CONTEXTS.key?(lower) && (%w[home work].include?(lower) || name == "adr")
363
+ (object["contexts"] ||= {})[CONTEXTS.fetch(lower)] = true
364
+ elsif name == "tel" && FEATURES.key?(lower)
365
+ (object["features"] ||= {})[FEATURES.fetch(lower)] = true
366
+ else
367
+ leftovers << type
368
+ end
369
+ end
370
+ remaining["type"] = leftovers.length == 1 ? leftovers.first : leftovers unless leftovers.empty?
371
+ end
372
+ object["label"] = @labels[params["group"]&.downcase] if fields.key?("label") && params["group"] && @labels[params["group"]&.downcase]
373
+ object["vCardParams"] = remaining unless remaining.empty?
374
+ object
375
+ end
376
+
377
+ def target_fields(name)
378
+ map = PROPERTIES[name]&.first
379
+ type = if map == "speakToAs/pronouns"
380
+ "Pronouns"
381
+ elsif map
382
+ SCHEMA["Card"]["properties"][map]["type"][/\[(\w+)\]/, 1]
383
+ else
384
+ { "n" => "Name", "org" => "Organization", "adr" => "Address", "geo" => "Address", "tz" => "Address",
385
+ "related" => "Relation", "gramgender" => "SpeakToAs" }.fetch(name, "Anniversary")
386
+ end
387
+ SCHEMA.fetch(type).fetch("properties")
388
+ end
389
+
390
+ def integer(value, parameter)
391
+ raise ConversionError, "#{parameter} must be an integer" unless value.is_a?(String) && /\A\d+\z/.match?(value)
392
+
393
+ value.to_i
394
+ end
395
+
396
+ def timestamp(value, compact: false)
397
+ value = JCardJCal::ValueTypes.from_text(value, "timestamp", format: :vcard) if compact
398
+ Time.iso8601(value).utc.iso8601
399
+ rescue ArgumentError
400
+ raise ConversionError, "invalid vCard timestamp"
401
+ end
402
+
403
+ def parameter_values(value)
404
+ Array(value).flat_map { |item| item.split(",") }
405
+ end
406
+
407
+ def identifier(map, prop)
408
+ name, params = prop
409
+ key = [map, name, params["altid"] || prop.object_id]
410
+ return @ids[key] if @ids[key]
411
+
412
+ explicit = params["prop-id"]
413
+ if explicit
414
+ raise ConversionError, "PROP-ID is not a JSContact identifier" unless explicit.is_a?(String) && /\A[A-Za-z0-9_-]{1,255}\z/.match?(explicit)
415
+ raise ConversionError, "duplicate PROP-ID in #{map}" if value_at(@card, map.split("/") + [explicit])
416
+
417
+ return @ids[key] = explicit
418
+ end
419
+ loop do
420
+ @counters[map] += 1
421
+ candidate = "#{map.delete("/")}#{@counters[map]}"
422
+ return @ids[key] = candidate unless @reserved.include?(candidate)
423
+ end
424
+ end
425
+
426
+ def convert_phonetic(prop)
427
+ name, params, _, values = prop
428
+ return preserve(prop) unless %w[n adr].include?(name)
429
+
430
+ base = @properties.find { |item| item[0] == name && !item[1]["phonetic"] && item[1]["altid"] == params["altid"] }
431
+ path = base && @paths[base]
432
+ return preserve(prop) unless path
433
+
434
+ object = copy(value_at(@card, path))
435
+ object["phoneticSystem"] = params["phonetic"] unless params["phonetic"] == "script"
436
+ object["phoneticScript"] = params["script"] if params["script"]
437
+ phonetic = Structured.read(values, params, name: name == "n").fetch("components", []).group_by { |item| item["kind"] }
438
+ object.fetch("components", []).each do |component|
439
+ match = phonetic[component["kind"]]&.shift
440
+ component["phonetic"] = match["value"] if match
441
+ end
442
+ unmapped_params = params.any? do |key, value|
443
+ !%w[altid language phonetic script].include?(key) && base[1][key] != value
444
+ end
445
+ preserve(prop) if unmapped_params || phonetic.values.any? { |components| !components.empty? }
446
+ language = params["language"] && normalize_language(params["language"])
447
+ if language && language != @card["language"]
448
+ changes = difference(value_at(@card, path), object, path.map { |token| token.gsub("~", "~0").gsub("/", "~1") }.join("/"), arrays: true)
449
+ ((@card["localizations"] ||= {})[language] ||= {}).merge!(changes)
450
+ else
451
+ set_at(path, object)
452
+ end
453
+ end
454
+
455
+ # RFC 9555 §3.2.1: apply all JSPROP values together, after regular properties.
456
+ def apply_jsprops
457
+ patches = {}
458
+ @properties.select { |prop| prop[0] == "jsprop" }.each do |prop|
459
+ pointer = prop[1]["jsptr"]
460
+ raise ConversionError, "JSPROP requires a single JSPTR parameter" unless pointer.is_a?(String)
461
+
462
+ pointer = pointer.delete_prefix("/")
463
+ raise ConversionError, "duplicate JSPTR" if patches.key?(pointer)
464
+
465
+ cursor = @card
466
+ pointer.split("/", -1).each do |token|
467
+ raise ConversionError, "JSPTR must not reference an array" if cursor.is_a?(Array)
468
+
469
+ cursor = cursor.is_a?(Hash) ? cursor[token.gsub("~1", "/").gsub("~0", "~")] : nil
470
+ end
471
+ patches[pointer] = JSON.parse(prop[3], allow_duplicate_key: false)
472
+ raise ConversionError, "JSPROP JSON must not contain insignificant whitespace" if prop[3].gsub(/"(?:\\.|[^"\\])*"/m, "").match?(/[ \t\r\n]/)
473
+ rescue JSON::ParserError => e
474
+ raise ConversionError, "invalid JSPROP JSON: #{e.message}"
475
+ end
476
+ @card = PatchObject.new(patches).apply(@card) unless patches.empty?
477
+ end
478
+
479
+ def preserve(prop)
480
+ (@card["vCardProps"] ||= []) << copy(prop)
481
+ nil
482
+ end
483
+
484
+ def value_at(object, path)
485
+ path.reduce(object) { |cursor, token| cursor.is_a?(Hash) ? cursor[token] : nil }
486
+ end
487
+
488
+ def set_at(path, value)
489
+ object = path[0...-1].reduce(@card) { |cursor, token| cursor[token] ||= {} }
490
+ object[path.last] = value
491
+ end
492
+
493
+ def copy(value) = Jscontact.deep_copy(value)
494
+
495
+ def difference(before, after, path, arrays: false)
496
+ if before.is_a?(Hash) && after.is_a?(Hash)
497
+ (before.keys | after.keys).each_with_object({}) do |key, result|
498
+ result.merge!(difference(before[key], after[key], "#{path}/#{key.gsub("~", "~0").gsub("/", "~1")}", arrays: arrays))
499
+ end
500
+ elsif arrays && before.is_a?(Array) && after.is_a?(Array) && before.length == after.length
501
+ before.each_index.with_object({}) { |index, result| result.merge!(difference(before[index], after[index], "#{path}/#{index}", arrays: arrays)) }
502
+ elsif before != after
503
+ { path => after }
504
+ else
505
+ {}
506
+ end
507
+ end
508
+ end
509
+ end
510
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../jscontact"
4
+ require "jcard_jcal"
5
+ require "digest/sha1"
6
+ require "time"
7
+ require_relative "vcard/structured"
8
+ require_relative "vcard/to_jscontact"
9
+ require_relative "vcard/from_jscontact"
10
+
11
+ module Jscontact
12
+ # Optional RFC 9555 conversion, updated for RFC 9982's optional UID.
13
+ module VCard
14
+ # RFC 9555 §§2.4–2.13: scalar values in identifier maps.
15
+ PROPERTIES = {
16
+ "source" => %w[directories uri entry], "nickname" => %w[nicknames name],
17
+ "photo" => %w[media uri photo], "email" => %w[emails address],
18
+ "impp" => %w[onlineServices uri], "socialprofile" => %w[onlineServices uri],
19
+ "lang" => %w[preferredLanguages language], "tel" => %w[phones number],
20
+ "contact-uri" => %w[links uri contact], "logo" => %w[media uri logo],
21
+ "title" => %w[titles name title], "role" => %w[titles name role],
22
+ "expertise" => %w[personalInfo value expertise], "hobby" => %w[personalInfo value hobby],
23
+ "interest" => %w[personalInfo value interest], "org-directory" => %w[directories uri directory],
24
+ "note" => %w[notes note], "sound" => %w[media uri sound], "url" => %w[links uri],
25
+ "key" => %w[cryptoKeys uri], "caladruri" => %w[schedulingAddresses uri],
26
+ "caluri" => %w[calendars uri calendar], "fburl" => %w[calendars uri freeBusy],
27
+ "pronouns" => ["speakToAs/pronouns", "pronouns"]
28
+ }.each_value(&:freeze).freeze
29
+ SCALARS = { "kind" => "kind", "uid" => "uid", "prodid" => "prodId", "language" => "language",
30
+ "created" => "created", "rev" => "updated" }.freeze
31
+ FEATURES = { "cell" => "mobile", "fax" => "fax", "main-number" => "main-number", "pager" => "pager",
32
+ "text" => "text", "textphone" => "textphone", "video" => "video", "voice" => "voice" }.freeze
33
+ CONTEXTS = { "home" => "private", "work" => "work", "billing" => "billing", "delivery" => "delivery" }.freeze
34
+ REGISTRY = JCard::Registry.default.dup.tap do |registry|
35
+ %w[birthplace deathplace].each { |name| registry.register(name, type: "text", types: %w[text uri]) }
36
+ registry.register("deathdate", type: "date-and-or-time", types: %w[date-and-or-time date date-time time text timestamp])
37
+ %w[expertise hobby interest jsprop x-ablabel].each { |name| registry.register(name, type: "text") }
38
+ %w[contact-uri org-directory].each { |name| registry.register(name, type: "uri") }
39
+ end.freeze
40
+
41
+ def self.to_jscontact(vcard_text, version: "2.0")
42
+ raise ConversionError.new("unsupported JSContact version", pointer: "/version") unless %w[1.0 2.0].include?(version)
43
+
44
+ cards = JCard.from_vcard(vcard_text, registry: REGISTRY)
45
+ raise ConversionError, "expected exactly one vCard" unless cards.length == 1
46
+
47
+ hash = ToJSContact.new(cards.first.to_a[1], version: version).convert
48
+ if version == "1.0"
49
+ warn "JSContact 1.0 conversion is deprecated; prefer version 2.0"
50
+ hash["uid"] ||= stable_uid(vcard_text)
51
+ end
52
+ card = Card.new(hash)
53
+ card.validate!
54
+ card
55
+ rescue JCardJCal::Error => e
56
+ raise ConversionError.new(e.message, pointer: e.pointer || "")
57
+ end
58
+
59
+ def self.from_jscontact(card)
60
+ card = Card.new(card) if card.is_a?(Hash)
61
+ raise ConversionError, "expected a JSContact Card or object" unless card.is_a?(Card)
62
+
63
+ card.validate!
64
+ FromJSContact.new(card.to_h).convert
65
+ rescue JCardJCal::Error => e
66
+ raise ConversionError.new(e.message, pointer: e.pointer || "")
67
+ end
68
+
69
+ def self.stable_uid(source)
70
+ bytes = Digest::SHA1.digest("jscontact-vcard\0#{source}").bytes.first(16)
71
+ bytes[6] = (bytes[6] & 15) | 80
72
+ bytes[8] = (bytes[8] & 63) | 128
73
+ hex = bytes.pack("C*").unpack1("H*")
74
+ "urn:uuid:#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
75
+ end
76
+ private_class_method :stable_uid
77
+ end
78
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jscontact.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "date"
6
+ require "uri"
7
+ require "tzinfo"
8
+ require_relative "jscontact/version"
9
+ require_relative "jscontact/errors"
10
+ require_relative "jscontact/types"
11
+ require_relative "jscontact/validator"
12
+ require_relative "jscontact/model"
13
+ require_relative "jscontact/patch_object"
14
+ require_relative "jscontact/localization"
15
+
16
+ JSContact = Jscontact unless defined?(JSContact)
17
+
18
+ module Jscontact
19
+ class << self
20
+ # Parses one Card, preserving unknown fields and its original JSContact version.
21
+ def parse(source, strict: true, **limits)
22
+ raise ParseError, "JSON input must be a String" unless source.is_a?(String)
23
+
24
+ Validator.new(**limits)
25
+ options = DEFAULT_LIMITS.merge(limits)
26
+ raise LimitError, "input exceeds max_input_size" if source.bytesize > options[:max_input_size]
27
+
28
+ data = JSON.parse(source, allow_duplicate_key: false, max_nesting: options[:max_depth])
29
+ Card.new(data, strict: strict, **limits)
30
+ rescue JSON::NestingError => e
31
+ raise LimitError, e.message
32
+ rescue JSON::ParserError, EncodingError => e
33
+ raise ParseError, e.message
34
+ end
35
+
36
+ # Upgrades a copy; a parsed 1.0 Card otherwise remains 1.0 on serialization.
37
+ def upgrade(card)
38
+ card = Card.new(card) unless card.is_a?(Card)
39
+ card.validate!
40
+ data = card.to_h
41
+ data["version"] = "2.0"
42
+ Card.new(data, **card.limits).validate!
43
+ end
44
+ end
45
+ end