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