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,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
|
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
|