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,367 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ module VCard
5
+ # RFC 9555 §3: native properties first, then JSPROP for unrepresentable data.
6
+ class FromJSContact
7
+ def initialize(card)
8
+ @card = card
9
+ @properties = []
10
+ @raw = card.fetch("vCardProps", [])
11
+ @raw.each_with_index do |property, index|
12
+ JCard::Property.new(property, registry: REGISTRY, pointer: "/vCardProps/#{index}")
13
+ end
14
+ end
15
+
16
+ def convert
17
+ build_properties
18
+ build_localizations
19
+ represented = ToJSContact.new(@properties, version: @card.fetch("version")).convert
20
+ # Arrays must be replaced as a whole: JSPTR may not reference array elements.
21
+ difference(represented, @card).each do |pointer, value|
22
+ @properties << ["jsprop", { "jsptr" => pointer }, "text", Jscontact.generate_json(value)]
23
+ end
24
+ properties = @properties.map { |property| JCard::Property.new(property, registry: REGISTRY) }
25
+ quote_pointers(JCard::Card.new("vcard", properties, registry: REGISTRY).to_vcard)
26
+ end
27
+
28
+ def build_properties
29
+ add("version", {}, "text", "4.0")
30
+ SCALARS.each do |vcard_name, json_name|
31
+ next unless @card.key?(json_name)
32
+
33
+ type = REGISTRY[vcard_name]["type"]
34
+ type = "text" if vcard_name == "uid" && !uri?(@card[json_name])
35
+ value = @card[json_name]
36
+ value = value.sub(/\.\d+Z\z/, "Z") if %w[created rev].include?(vcard_name)
37
+ add(vcard_name, {}, type, value)
38
+ end
39
+ build_name(@card.fetch("name", {}))
40
+ build_maps
41
+ build_organizations
42
+ build_addresses
43
+ build_anniversaries
44
+ build_speak_to_as
45
+ @card.fetch("members", {}).each_key { |uid| add("member", {}, "uri", uid) if uri?(uid) }
46
+ keywords = @card.fetch("keywords", {}).keys
47
+ add("categories", {}, "text", *keywords) unless keywords.empty?
48
+ @card.fetch("relatedTo", {}).each do |uid, relation|
49
+ params = relation.fetch("vCardParams", {}).dup
50
+ types = Array(params.delete("type")) + relation.fetch("relation", {}).keys.select { |kind| ENUMS["relation"].include?(kind) }
51
+ params["type"] = types.uniq unless types.empty?
52
+ add("related", params, uri?(uid) ? "uri" : "text", uid)
53
+ end
54
+ @properties.concat(@raw.reject { |prop| prop[0] == "version" })
55
+ @properties
56
+ end
57
+
58
+ private
59
+
60
+ def build_name(name)
61
+ if name.key?("full")
62
+ add("fn", {}, "text", name["full"])
63
+ else
64
+ add("fn", { "derived" => "TRUE" }, "text", Structured.full_name(name))
65
+ end
66
+ add("fn", { "derived" => "TRUE" }, "text", "") unless @properties.any? { |prop| prop[0] == "fn" } || @raw.any? { |prop| prop[0] == "fn" }
67
+ return unless name["components"] || name["sortAs"]
68
+
69
+ values, params = Structured.write(name, name: true)
70
+ params = name.fetch("vCardParams", {}).merge(params)
71
+ build_phonetic("n", name, params) if add("n", params, "text", values)
72
+ end
73
+
74
+ def build_maps
75
+ PROPERTIES.values.map(&:first).uniq.each do |map|
76
+ next if map == "speakToAs/pronouns"
77
+
78
+ @card.fetch(map, {}).each do |id, object|
79
+ name = property_name(map, object)
80
+ next unless name
81
+
82
+ _, key = PROPERTIES.fetch(name)
83
+ key = "user" if name == "socialprofile" && !object["uri"]
84
+ next unless object.key?(key)
85
+
86
+ type = REGISTRY[name]["type"]
87
+ type = "text" if name == "socialprofile" && key == "user"
88
+ type = uri?(object[key]) ? "uri" : "text" if name == "tel"
89
+ params = common_params(object, id)
90
+ case name
91
+ when "impp", "socialprofile"
92
+ params["service-type"] = object["service"] if object["service"]
93
+ params["username"] = object["user"] if object["user"] && key != "user"
94
+ when "note"
95
+ params["author-name"] = object.dig("author", "name") if object.dig("author", "name")
96
+ params["author"] = object.dig("author", "uri") if object.dig("author", "uri")
97
+ params["created"] = compact_timestamp(object["created"]) if object["created"]
98
+ when "expertise", "hobby", "interest"
99
+ if object["level"]
100
+ params["level"] = if name == "expertise"
101
+ { "low" => "beginner", "medium" => "average", "high" => "expert" }.fetch(object["level"], object["level"])
102
+ else
103
+ object["level"]
104
+ end
105
+ end
106
+ params["index"] = object["listAs"].to_s if object["listAs"]
107
+ when "org-directory"
108
+ params["index"] = object["listAs"].to_s if object["listAs"]
109
+ when "title", "role"
110
+ if object["organizationId"] && @card.fetch("organizations", {}).key?(object["organizationId"])
111
+ params["group"] = organization_group(object["organizationId"])
112
+ end
113
+ end
114
+ add(name, params, type, object[key])
115
+ add_label(object, params)
116
+ end
117
+ end
118
+ end
119
+
120
+ def property_name(map, object)
121
+ case map
122
+ when "onlineServices"
123
+ object["vCardName"]&.casecmp?("impp") ? "impp" : "socialprofile"
124
+ when "titles" then { "title" => "title", "role" => "role" }[object.fetch("kind", "title")]
125
+ when "media" then %w[photo logo sound].find { |kind| object["kind"] == kind }
126
+ when "personalInfo" then %w[expertise hobby interest].find { |kind| object["kind"] == kind }
127
+ when "directories" then { "entry" => "source", "directory" => "org-directory" }[object["kind"]]
128
+ when "calendars" then { "calendar" => "caluri", "freeBusy" => "fburl" }[object["kind"]]
129
+ when "links" then object["kind"] == "contact" ? "contact-uri" : "url"
130
+ else PROPERTIES.find { |_, definition| definition.first == map }&.first
131
+ end
132
+ end
133
+
134
+ def build_organizations
135
+ @card.fetch("organizations", {}).each do |id, object|
136
+ units = object.fetch("units", [])
137
+ values = [object.fetch("name", "")] + units.map { |unit| unit["name"] }
138
+ sort = [object.fetch("sortAs", "")] + units.map { |unit| unit.fetch("sortAs", "") }
139
+ params = common_params(object, id)
140
+ params["sort-as"] = sort if sort.any? { |value| !value.empty? }
141
+ params["group"] = organization_group(id) if @card.fetch("titles", {}).values.any? { |title| title["organizationId"] == id }
142
+ add("org", params, "text", values.length == 1 ? values.first : values)
143
+ add_label(object, params)
144
+ end
145
+ end
146
+
147
+ def organization_group(id)
148
+ object = @card["organizations"][id]
149
+ object.dig("vCardParams", "group") || "org#{@card["organizations"].keys.index(id) + 1}"
150
+ end
151
+
152
+ def build_addresses
153
+ @card.fetch("addresses", {}).each do |id, object|
154
+ values, structured_params = Structured.write(object, name: false)
155
+ params = common_params(object, id).merge(structured_params)
156
+ { "full" => "label", "coordinates" => "geo", "timeZone" => "tz", "countryCode" => "cc" }.each do |key, parameter|
157
+ params[parameter] = object[key] if object[key]
158
+ end
159
+ if add("adr", params, "text", values)
160
+ add_label(object, params)
161
+ build_phonetic("adr", object, params)
162
+ end
163
+ end
164
+ end
165
+
166
+ def build_phonetic(name, object, params)
167
+ return unless object.fetch("components", []).any? { |component| component["phonetic"] }
168
+
169
+ params["altid"] ||= params.fetch("prop-id", "name")
170
+ # add copies its arguments so the base property needs the same ALTID explicitly.
171
+ base = @properties.reverse.find { |prop| prop[0] == name && prop[1]["prop-id"] == params["prop-id"] }
172
+ base[1]["altid"] = params["altid"] if base
173
+ values, structured_params = Structured.write(object, name: name == "n", phonetic: true)
174
+ phonetic_params = params.merge(structured_params).merge("phonetic" => object.fetch("phoneticSystem", "script"))
175
+ phonetic_params["script"] = object["phoneticScript"] if object["phoneticScript"]
176
+ add(name, phonetic_params, "text", values)
177
+ end
178
+
179
+ def build_anniversaries
180
+ @card.fetch("anniversaries", {}).each do |id, object|
181
+ name = { "birth" => "bday", "death" => "deathdate", "wedding" => "anniversary" }[object["kind"]]
182
+ next unless name
183
+
184
+ params = object.fetch("vCardParams", {}).merge("prop-id" => id)
185
+ # JSContact's date numbers are Gregorian, even with another calendarScale.
186
+ # Preserve other calendar metadata through the JSPROP residual instead.
187
+ params.delete("calscale") if params["calscale"] && !params["calscale"].to_s.casecmp?("gregorian")
188
+ if object["date"]
189
+ date = object["date"]
190
+ if date["@type"] == "Timestamp"
191
+ add(name, params, "date-and-or-time", date["utc"].sub(/\.\d+Z\z/, "Z"))
192
+ elsif (value = partial_date(date))
193
+ date_params = params.dup
194
+ date_params["calscale"] = "gregorian" if date["calendarScale"] == "gregorian"
195
+ add(name, date_params, "date", value)
196
+ end
197
+ end
198
+ next unless object["place"] && %w[birth death].include?(object["kind"])
199
+
200
+ place = object["place"]
201
+ place_params = { "prop-id" => id }.merge(place.fetch("vCardParams", {}))
202
+ if place["coordinates"]
203
+ add("#{object["kind"]}place", place_params, "uri", place["coordinates"])
204
+ elsif place["full"]
205
+ add("#{object["kind"]}place", place_params, "text", place["full"])
206
+ end
207
+ end
208
+ end
209
+
210
+ def partial_date(date)
211
+ if date["year"]
212
+ value = format("%04d", date["year"])
213
+ value += format("-%02d", date["month"]) if date["month"]
214
+ value += format("-%02d", date["day"]) if date["month"] && date["day"]
215
+ elsif date["month"] && date["day"]
216
+ value = format("--%<month>02d-%<day>02d", month: date["month"], day: date["day"])
217
+ end
218
+ return unless value
219
+
220
+ JCardJCal::ValueTypes.from_json(value, "date", format: :vcard)
221
+ rescue JCardJCal::ValueError
222
+ nil
223
+ end
224
+
225
+ def build_speak_to_as
226
+ object = @card.fetch("speakToAs", {})
227
+ add("gramgender", object.fetch("vCardParams", {}), "text", object["grammaticalGender"]) if object["grammaticalGender"]
228
+ object.fetch("pronouns", {}).each do |id, pronouns|
229
+ params = common_params(pronouns, id)
230
+ add("pronouns", params, "text", pronouns["pronouns"])
231
+ add_label(pronouns, params)
232
+ end
233
+ end
234
+
235
+ # RFC 9555 §2.3.11 reversed: emit the translated properties under LANGUAGE/ALTID.
236
+ def build_localizations
237
+ @card.fetch("localizations", {}).each do |language, patch|
238
+ localized = PatchObject.new(patch).apply(@card)
239
+ localized.delete("localizations")
240
+ alternative = self.class.new(localized).build_properties
241
+ alternative.each do |property|
242
+ next if %w[version language jsprop].include?(property[0])
243
+ next if @properties.include?(property)
244
+
245
+ candidate = copy(property)
246
+ identity = property_identity(candidate)
247
+ base = @properties.find { |item| property_identity(item) == identity && !item[1]["language"] }
248
+ if !base && candidate[1]["phonetic"]
249
+ base = @properties.find do |item|
250
+ item[0] == candidate[0] && item[1]["prop-id"] == candidate[1]["prop-id"] &&
251
+ !item[1]["phonetic"] && !item[1]["language"]
252
+ end
253
+ end
254
+ next unless base
255
+ next if base[3..] == candidate[3..] && base[1].except("altid") == candidate[1].except("altid")
256
+
257
+ alt_id = base[1]["altid"] ||= base[1].fetch("prop-id", candidate[0])
258
+ candidate[1]["altid"] = alt_id
259
+ candidate[1]["language"] = language
260
+ @properties << candidate
261
+ end
262
+ end
263
+ end
264
+
265
+ # jCard's generic writer quotes only when necessary; JSPTR always requires quotes.
266
+ def quote_pointers(text)
267
+ text.gsub(/^(?:[A-Za-z0-9-]+\.)?JSPROP[^\r\n]*(?:\r\n[ \t][^\r\n]*)*\r\n/) do |folded|
268
+ line = JCardJCal::ContentLine::Reader.new(folded).first
269
+ content = line.group ? "#{line.group}.JSPROP" : +"JSPROP"
270
+ line.params.each do |key, value|
271
+ values = Array(value).map do |item|
272
+ encoded = JCardJCal::ContentLine::ParamCodec.encode(item)
273
+ key == "jsptr" && !encoded.start_with?('"') ? "\"#{encoded}\"" : encoded
274
+ end
275
+ content << ";#{key.upcase}=#{values.join(",")}"
276
+ end
277
+ content << ":#{line.value}"
278
+ result = +""
279
+ width = 0
280
+ content.each_char do |character|
281
+ if width + character.bytesize > 75
282
+ result << "\r\n "
283
+ width = 1
284
+ end
285
+ result << character
286
+ width += character.bytesize
287
+ end
288
+ result << "\r\n"
289
+ end
290
+ end
291
+
292
+ def property_identity(prop)
293
+ [prop[0], prop[1]["prop-id"], prop[1]["phonetic"], prop[1]["group"]]
294
+ end
295
+
296
+ def common_params(object, id)
297
+ params = object.fetch("vCardParams", {}).dup
298
+ params["prop-id"] = id
299
+ params["pref"] = object["pref"].to_s if object["pref"]
300
+ params["mediatype"] = object["mediaType"] if object["mediaType"]
301
+ types = Array(params.delete("type")).flat_map { |value| value.split(",") }
302
+ object.fetch("contexts", {}).each { |context, enabled| types << CONTEXTS.key(context) if enabled && CONTEXTS.value?(context) }
303
+ object.fetch("features", {}).each_key { |feature| types << FEATURES.key(feature) if FEATURES.value?(feature) }
304
+ params["type"] = types.uniq unless types.empty?
305
+ params["group"] ||= "label#{@properties.length}" if object["label"]
306
+ params
307
+ end
308
+
309
+ def add_label(object, params)
310
+ add("x-ablabel", { "group" => params["group"] }, "text", object["label"]) if object["label"]
311
+ end
312
+
313
+ def compact_timestamp(value)
314
+ JCardJCal::ValueTypes.to_text(value.sub(/\.\d+Z\z/, "Z"), "timestamp", format: :vcard)
315
+ end
316
+
317
+ def add(name, params, type, *values)
318
+ # Preserved source properties already carry parameters that have no scalar counterpart.
319
+ return if name != "version" && @raw.any? { |prop| prop[0] == name && prop[2..] == [type, *values] }
320
+
321
+ property = [name, copy(params), type, *copy(values)]
322
+ JCard::Property.new(property, registry: REGISTRY)
323
+ @properties << property
324
+ true
325
+ rescue JCardJCal::ValidationError => e
326
+ raise if e.is_a?(JCardJCal::LimitError)
327
+
328
+ # Valid JSON strings and extension values may have no native vCard encoding.
329
+ false
330
+ end
331
+
332
+ def uri?(value)
333
+ JCardJCal::ValueTypes.from_json(value, "uri", format: :vcard)
334
+ true
335
+ rescue JCardJCal::ValueError
336
+ false
337
+ end
338
+
339
+ def copy(value) = Jscontact.deep_copy(value)
340
+
341
+ def difference(before, after, path = "")
342
+ if before.is_a?(Hash) && after.is_a?(Hash)
343
+ null_key = after.keys.find { |key| after[key].nil? && (!before.key?(key) || !before[key].nil?) }
344
+ if null_key
345
+ return { path => after } unless path.empty?
346
+
347
+ pointer = "/#{null_key.gsub("~", "~0").gsub("/", "~1")}"
348
+ raise ConversionError.new("JSPROP cannot represent a top-level null value: null deletes a property", pointer: pointer)
349
+ end
350
+ (before.keys | after.keys).each_with_object({}) do |key, result|
351
+ escaped = key.gsub("~", "~0").gsub("/", "~1")
352
+ child_path = path.empty? ? escaped : "#{path}/#{escaped}"
353
+ if before.key?(key) == after.key?(key)
354
+ result.merge!(difference(before[key], after[key], child_path))
355
+ else
356
+ result[child_path] = after[key]
357
+ end
358
+ end
359
+ elsif before != after
360
+ { path => after }
361
+ else
362
+ {}
363
+ end
364
+ end
365
+ end
366
+ end
367
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscontact
4
+ module VCard
5
+ # RFC 9555 §§2.5.5, 2.6.1 and 3.3.1; positions follow RFC 9554 §2.
6
+ module Structured
7
+ NAME_KINDS = %w[surname given given2 title credential surname2 generation].freeze
8
+ ADDRESS_KINDS = %w[postOfficeBox apartment name locality region postcode country room apartment floor number name
9
+ building block subdistrict district landmark direction].freeze
10
+
11
+ module_function
12
+
13
+ def read(values, params, name:)
14
+ values = Array(values)
15
+ positions = components(values, name: name)
16
+ result = {}
17
+ if (ordered = order(params["jscomps"], positions))
18
+ result["components"] = ordered[0]
19
+ result["isOrdered"] = true
20
+ result["defaultSeparator"] = ordered[1] unless ordered[1].nil?
21
+ elsif !positions.empty?
22
+ result["components"] = positions.values
23
+ end
24
+ if name && params["sort-as"]
25
+ sort = Array(params["sort-as"])
26
+ result["sortAs"] = NAME_KINDS.each_with_index.to_h { |kind, i| [kind, sort[i]] }.reject { |_, v| v.nil? || v.empty? }
27
+ result.delete("sortAs") if result["sortAs"].empty?
28
+ end
29
+ result
30
+ end
31
+
32
+ def components(values, name:)
33
+ kinds = name ? NAME_KINDS : ADDRESS_KINDS
34
+ result = {}
35
+ values.each_with_index do |part, index|
36
+ next if !name && values.length > 7 && [1, 2].include?(index)
37
+
38
+ Array(part).each_with_index do |value, subindex|
39
+ next if value.empty?
40
+ next if name && ((index.zero? && Array(values[5]).include?(value)) || (index == 4 && Array(values[6]).include?(value)))
41
+
42
+ key = subindex.zero? ? index.to_s : "#{index},#{subindex}"
43
+ result[key] = { "kind" => kinds.fetch(index), "value" => value }
44
+ end
45
+ end
46
+ result
47
+ end
48
+
49
+ def order(value, positions)
50
+ return unless value.is_a?(String)
51
+
52
+ entries = JCardJCal::ContentLine::TextCodec.split(value, ";")
53
+ first = entries.shift
54
+ return unless first == "" || first&.start_with?("s,")
55
+
56
+ entries = entries.map do |entry|
57
+ next entry if entry.start_with?("s,")
58
+ next unless /\A\d+(?:,\d+)?\z/.match?(entry)
59
+
60
+ indices = entry.split(",").map { |index| index.sub(/\A0+(?=\d)/, "") }
61
+ next if indices[1] == "0"
62
+
63
+ indices.join(",")
64
+ end
65
+ return if entries.any?(&:nil?)
66
+
67
+ positional = entries.reject { |entry| entry.start_with?("s,") }
68
+ return unless positional.length == positions.length && positional.uniq.length == positional.length && positional.all? { |entry| positions.key?(entry) }
69
+
70
+ components = entries.map do |entry|
71
+ entry.start_with?("s,") ? { "kind" => "separator", "value" => unescape_separator(entry[2..]) } : positions.fetch(entry).dup
72
+ end
73
+ [components, first.empty? ? nil : unescape_separator(first[2..])]
74
+ end
75
+
76
+ def write(object, name:, phonetic: false)
77
+ kinds = name ? NAME_KINDS : ADDRESS_KINDS
78
+ parts = Array.new(kinds.length) { [] }
79
+ entries = []
80
+ Array(object["components"]).each do |component|
81
+ kind = component["kind"]
82
+ if kind == "separator"
83
+ entries << "s,#{escape_separator(component["value"])}"
84
+ next
85
+ end
86
+ index = name ? kinds.index(kind) : kinds.rindex(kind)
87
+ next unless index
88
+
89
+ value = phonetic ? component.fetch("phonetic", component["value"]) : component["value"]
90
+ subindex = parts[index].length
91
+ entries << (subindex.zero? ? index.to_s : "#{index},#{subindex}")
92
+ parts[index] << value
93
+ end
94
+ if name
95
+ parts[0].concat(parts[5])
96
+ parts[4].concat(parts[6])
97
+ else
98
+ parts[1] = [parts.values_at(7, 9, 8, 12).flatten.join(" ")]
99
+ parts[2] = [parts.values_at(10, 11, 13, 17, 16, 14, 15).flatten.join(" ")]
100
+ end
101
+ params = {}
102
+ if object["isOrdered"]
103
+ separator = object.key?("defaultSeparator") ? "s,#{escape_separator(object["defaultSeparator"])}" : ""
104
+ params["jscomps"] = ([separator] + entries).join(";") unless entries.empty?
105
+ end
106
+ params["sort-as"] = kinds.map { |kind| object["sortAs"].fetch(kind, "") } if name && object["sortAs"]
107
+ [parts.map { |part| part.length > 1 ? part : part.fetch(0, "") }, params]
108
+ end
109
+
110
+ def full_name(object)
111
+ components = Array(object["components"])
112
+ return "" if components.empty?
113
+ return components.map { |component| component["value"] }.join(" ") unless object["isOrdered"]
114
+
115
+ result = +""
116
+ previous = nil
117
+ components.each do |component|
118
+ kind = component["kind"]
119
+ result << object.fetch("defaultSeparator", " ") if previous && previous != "separator" && kind != "separator"
120
+ result << component["value"]
121
+ previous = kind
122
+ end
123
+ result
124
+ end
125
+
126
+ def escape_separator(value)
127
+ value.gsub(/[\\,;]/) { |char| "\\#{char}" }
128
+ end
129
+
130
+ def unescape_separator(value)
131
+ value.gsub(/\\([\\,;])/, '\\1')
132
+ end
133
+ end
134
+ end
135
+ end