vcardfull 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/lib/vcardfull/parser/line_reader.rb +192 -0
- data/lib/vcardfull/parser/v2_1.rb +46 -0
- data/lib/vcardfull/parser/v3_0.rb +40 -0
- data/lib/vcardfull/parser/v4_0.rb +12 -0
- data/lib/vcardfull/parser/vcard_handler.rb +209 -0
- data/lib/vcardfull/parser.rb +176 -0
- data/lib/vcardfull/serializer/v2_1.rb +71 -0
- data/lib/vcardfull/serializer.rb +166 -0
- data/lib/vcardfull/vcard/address.rb +20 -0
- data/lib/vcardfull/vcard/custom_property.rb +20 -0
- data/lib/vcardfull/vcard/email.rb +20 -0
- data/lib/vcardfull/vcard/instant_message.rb +20 -0
- data/lib/vcardfull/vcard/phone.rb +20 -0
- data/lib/vcardfull/vcard/url.rb +20 -0
- data/lib/vcardfull/vcard.rb +43 -0
- data/lib/vcardfull.rb +5 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a8054173747d0c98ff48f3f935ca7da31fd141f2a3b8e8cd4f74acd494e7dad0
|
|
4
|
+
data.tar.gz: 9943f1fc22ef9800893016fe4bedfe82b410ae3a9478c10eaa12cac6f175a4dc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7f5b73162dd4c737cb957c69cff03692ed8b95e90678c086a7420003612243ef8191929ca86a2ca513e94491bcc11155cfe9c30aef1b96626d41580281e013d9
|
|
7
|
+
data.tar.gz: 947e2824b87addb38686cd675f959b3a069e902e271c588f86e7317ca0d7f0057bcf9f177bfd07a24b753d045792ad7039fa50005e73bc2e2df6d72c8dce4743
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Vcardfull
|
|
7
|
+
class Parser
|
|
8
|
+
# Streaming line reader that unfolds logical vCard lines from chunked IO input.
|
|
9
|
+
#
|
|
10
|
+
# Handles RFC 6350 line folding (continuation lines starting with a space or tab)
|
|
11
|
+
# and vCard 2.1 quoted-printable soft line breaks. Values that exceed the
|
|
12
|
+
# configured threshold are transparently promoted from in-memory StringIO
|
|
13
|
+
# buffers to on-disk Tempfiles.
|
|
14
|
+
class LineReader
|
|
15
|
+
# Creates a new LineReader.
|
|
16
|
+
#
|
|
17
|
+
# @param io [IO] the input stream to read from.
|
|
18
|
+
# @param large_value_threshold [Integer] byte size above which the internal
|
|
19
|
+
# buffer is promoted from a StringIO to a Tempfile.
|
|
20
|
+
# @param quoted_printable_aware [Boolean] when +true+, treats trailing +=+
|
|
21
|
+
# as a quoted-printable soft line break (used for vCard 2.1).
|
|
22
|
+
def initialize(io, large_value_threshold:, quoted_printable_aware: false)
|
|
23
|
+
@io = io
|
|
24
|
+
@quoted_printable_aware = quoted_printable_aware
|
|
25
|
+
@large_value_threshold = large_value_threshold
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Yields each unfolded logical line as an IO object (StringIO or Tempfile).
|
|
29
|
+
#
|
|
30
|
+
# When called without a block, returns an Enumerator.
|
|
31
|
+
#
|
|
32
|
+
# @yield [IO] each unfolded logical line.
|
|
33
|
+
# @return [Enumerator] if no block is given.
|
|
34
|
+
def each_line
|
|
35
|
+
return enum_for(:each_line) unless block_given?
|
|
36
|
+
|
|
37
|
+
reset_state
|
|
38
|
+
|
|
39
|
+
while (chunk = @io.read(@large_value_threshold))
|
|
40
|
+
process_chunk(chunk) { |buffer| yield buffer }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if @has_content
|
|
44
|
+
yield_buffered_line { |buffer| yield buffer }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
def reset_state
|
|
50
|
+
reset_buffer
|
|
51
|
+
@state = :reading_content
|
|
52
|
+
@has_content = false
|
|
53
|
+
@last_character = nil
|
|
54
|
+
@first_line = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def process_chunk(chunk, &block)
|
|
58
|
+
@pos = 0
|
|
59
|
+
|
|
60
|
+
while @pos < chunk.bytesize
|
|
61
|
+
resolve_pending_state(chunk, &block)
|
|
62
|
+
scan_content_run(chunk)
|
|
63
|
+
detect_line_ending(chunk)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_pending_state(chunk, &block)
|
|
68
|
+
case @state
|
|
69
|
+
when :after_carriage_return
|
|
70
|
+
consume_optional_line_feed(chunk)
|
|
71
|
+
when :after_line_break
|
|
72
|
+
start_logical_line(chunk, &block)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def consume_optional_line_feed(chunk)
|
|
77
|
+
@state = :after_line_break
|
|
78
|
+
@pos += 1 if chunk.getbyte(@pos) == 0x0A
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def start_logical_line(chunk, &block)
|
|
82
|
+
@state = :reading_content
|
|
83
|
+
byte = chunk.getbyte(@pos)
|
|
84
|
+
|
|
85
|
+
if byte == 0x20 || byte == 0x09
|
|
86
|
+
@pos += 1
|
|
87
|
+
elsif quoted_printable_soft_break?
|
|
88
|
+
remove_trailing_soft_break_marker
|
|
89
|
+
else
|
|
90
|
+
yield_buffered_line(&block) if @has_content
|
|
91
|
+
reset_buffer
|
|
92
|
+
@first_line = nil
|
|
93
|
+
@has_content = false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def scan_content_run(chunk)
|
|
98
|
+
run_start = @pos
|
|
99
|
+
|
|
100
|
+
while @pos < chunk.bytesize
|
|
101
|
+
byte = chunk.getbyte(@pos)
|
|
102
|
+
break if byte == 0x0D || byte == 0x0A
|
|
103
|
+
@pos += 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if @pos > run_start
|
|
107
|
+
content = chunk.byteslice(run_start, @pos - run_start)
|
|
108
|
+
write_to_buffer(content)
|
|
109
|
+
@last_character = content[-1]
|
|
110
|
+
@has_content = true
|
|
111
|
+
@first_line ||= capture_first_line
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def detect_line_ending(chunk)
|
|
116
|
+
return if @pos >= chunk.bytesize
|
|
117
|
+
|
|
118
|
+
byte = chunk.getbyte(@pos)
|
|
119
|
+
|
|
120
|
+
if byte == 0x0D
|
|
121
|
+
detect_carriage_return_ending(chunk)
|
|
122
|
+
elsif byte == 0x0A
|
|
123
|
+
detect_line_feed_ending
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def detect_carriage_return_ending(chunk)
|
|
128
|
+
@pos += 1
|
|
129
|
+
|
|
130
|
+
if @pos < chunk.bytesize
|
|
131
|
+
@state = :after_line_break
|
|
132
|
+
@pos += 1 if chunk.getbyte(@pos) == 0x0A
|
|
133
|
+
else
|
|
134
|
+
@state = :after_carriage_return
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def detect_line_feed_ending
|
|
139
|
+
@state = :after_line_break
|
|
140
|
+
@pos += 1
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def quoted_printable_soft_break?
|
|
144
|
+
@quoted_printable_aware && @last_character == "=" && quoted_printable_encoded?(@first_line)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def quoted_printable_encoded?(line)
|
|
148
|
+
line&.match?(/;ENCODING=QUOTED-PRINTABLE/i)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def remove_trailing_soft_break_marker
|
|
152
|
+
@buffer.truncate(@buffer.size - 1)
|
|
153
|
+
@buffer.seek(0, IO::SEEK_END)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def yield_buffered_line
|
|
157
|
+
@buffer.rewind
|
|
158
|
+
yield @buffer
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def capture_first_line
|
|
162
|
+
@buffer.rewind
|
|
163
|
+
line = @buffer.read
|
|
164
|
+
@buffer.seek(0, IO::SEEK_END)
|
|
165
|
+
line
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write_to_buffer(content)
|
|
169
|
+
@buffer_size += content.bytesize
|
|
170
|
+
promote_to_tempfile if !@promoted && @buffer_size > @large_value_threshold
|
|
171
|
+
@buffer.write(content)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def promote_to_tempfile
|
|
175
|
+
tempfile = Tempfile.new("vcard_line_reader")
|
|
176
|
+
tempfile.binmode
|
|
177
|
+
|
|
178
|
+
@buffer.rewind
|
|
179
|
+
IO.copy_stream(@buffer, tempfile)
|
|
180
|
+
|
|
181
|
+
@buffer = tempfile
|
|
182
|
+
@promoted = true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def reset_buffer
|
|
186
|
+
@promoted = false
|
|
187
|
+
@buffer = StringIO.new
|
|
188
|
+
@buffer_size = 0
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
# Documentation: https://web.archive.org/web/20120104222727/http://www.imc.org/pdi/vcard-21.txt
|
|
6
|
+
module Vcardfull
|
|
7
|
+
class Parser
|
|
8
|
+
# vCard 2.1 parser.
|
|
9
|
+
#
|
|
10
|
+
# Extends V30 with quoted-printable awareness and decoding support for
|
|
11
|
+
# QUOTED-PRINTABLE and BASE64 encoded values. Removes encoding parameters
|
|
12
|
+
# after decoding.
|
|
13
|
+
class V21 < V30
|
|
14
|
+
private
|
|
15
|
+
def quoted_printable_aware?
|
|
16
|
+
true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode(value_io, params)
|
|
20
|
+
read_value(value_io) do |value|
|
|
21
|
+
decoded = decode_value(value, params)
|
|
22
|
+
params.delete("ENCODING")
|
|
23
|
+
params.delete("CHARSET")
|
|
24
|
+
decoded
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def decode_value(value, params)
|
|
29
|
+
encoding = params["ENCODING"]&.upcase
|
|
30
|
+
|
|
31
|
+
case encoding
|
|
32
|
+
when "QUOTED-PRINTABLE"
|
|
33
|
+
value.unpack1("M").force_encoding("UTF-8")
|
|
34
|
+
when "BASE64", "B"
|
|
35
|
+
Base64.decode64(value)
|
|
36
|
+
else
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unescape(value)
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Documentation: https://datatracker.ietf.org/doc/html/rfc2426
|
|
4
|
+
module Vcardfull
|
|
5
|
+
class Parser
|
|
6
|
+
# vCard 3.0 (RFC 2426) parser.
|
|
7
|
+
#
|
|
8
|
+
# Overrides parameter parsing to handle the PREF keyword as a TYPE component
|
|
9
|
+
# and to extract preference values from TYPE parameters.
|
|
10
|
+
class V30 < Parser
|
|
11
|
+
private
|
|
12
|
+
def parse_params(parts)
|
|
13
|
+
parts.each_with_object({}) do |part, params|
|
|
14
|
+
if part.include?("=")
|
|
15
|
+
key, val = part.split("=", 2)
|
|
16
|
+
params[key.upcase] = val
|
|
17
|
+
elsif part.upcase == "PREF"
|
|
18
|
+
params["PREF"] = "1"
|
|
19
|
+
else
|
|
20
|
+
params["TYPE"] = [ params["TYPE"], part ].compact.join(",")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def extract_pref(params)
|
|
26
|
+
types = params["TYPE"]&.split(",")&.map(&:downcase)
|
|
27
|
+
|
|
28
|
+
if types&.include?("pref")
|
|
29
|
+
1
|
|
30
|
+
else
|
|
31
|
+
params["PREF"]&.to_i
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def extract_type(params)
|
|
36
|
+
params["TYPE"]&.split(",")&.map(&:downcase)&.reject { |t| t == "pref" }&.first
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Documentation: https://datatracker.ietf.org/doc/html/rfc6350
|
|
4
|
+
module Vcardfull
|
|
5
|
+
class Parser
|
|
6
|
+
# vCard 4.0 (RFC 6350) parser.
|
|
7
|
+
#
|
|
8
|
+
# Uses the base Parser behavior without any version-specific overrides.
|
|
9
|
+
class V40 < Parser
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class Parser
|
|
5
|
+
# SAX-style event handler that accumulates parsed vCard properties and builds
|
|
6
|
+
# a VCard object. Used internally by the Parser to map raw property events
|
|
7
|
+
# to structured VCard attributes.
|
|
8
|
+
class VCardHandler
|
|
9
|
+
STRUCTURED_NAME_PARTS = %i[family_name given_name additional_names honorific_prefix honorific_suffix].freeze
|
|
10
|
+
|
|
11
|
+
ADDRESS_PARTS = %i[po_box extended street locality region postal_code country].freeze
|
|
12
|
+
|
|
13
|
+
# Creates a new VCardHandler.
|
|
14
|
+
#
|
|
15
|
+
# @param unescape [#call] a callable that unescapes vCard backslash sequences
|
|
16
|
+
# in property values (e.g. +\\n+ to newline).
|
|
17
|
+
def initialize(unescape:)
|
|
18
|
+
@unescape = unescape
|
|
19
|
+
@attributes = {
|
|
20
|
+
emails: [],
|
|
21
|
+
phones: [],
|
|
22
|
+
addresses: [],
|
|
23
|
+
urls: [],
|
|
24
|
+
instant_messages: [],
|
|
25
|
+
custom_properties: []
|
|
26
|
+
}
|
|
27
|
+
@position_counters = Hash.new(0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Dispatches a parsed vCard property to the appropriate handler method.
|
|
31
|
+
#
|
|
32
|
+
# @param name [String] the property name (e.g. "EMAIL", "TEL", "FN").
|
|
33
|
+
# @param params [Hash] the property parameters (e.g. {"TYPE" => "work"}).
|
|
34
|
+
# @param value [String, IO] the property value, either a String or an IO for large values.
|
|
35
|
+
# @param type [String, nil] the extracted TYPE parameter value (e.g. "work", "home").
|
|
36
|
+
# @param pref [Integer, nil] the preference order, if specified.
|
|
37
|
+
def on_property(name, params, value, type:, pref:)
|
|
38
|
+
case name.upcase
|
|
39
|
+
when "BEGIN", "END"
|
|
40
|
+
# skip
|
|
41
|
+
when "VERSION"
|
|
42
|
+
on_version(value)
|
|
43
|
+
when "UID"
|
|
44
|
+
on_uid(value)
|
|
45
|
+
when "FN"
|
|
46
|
+
on_formatted_name(value)
|
|
47
|
+
when "N"
|
|
48
|
+
on_structured_name(value)
|
|
49
|
+
when "KIND"
|
|
50
|
+
on_kind(value)
|
|
51
|
+
when "NICKNAME"
|
|
52
|
+
on_nickname(value)
|
|
53
|
+
when "BDAY"
|
|
54
|
+
on_birthday(value)
|
|
55
|
+
when "ANNIVERSARY"
|
|
56
|
+
on_anniversary(value)
|
|
57
|
+
when "GENDER"
|
|
58
|
+
on_gender(value)
|
|
59
|
+
when "NOTE"
|
|
60
|
+
on_note(value)
|
|
61
|
+
when "PRODID"
|
|
62
|
+
on_product_id(value)
|
|
63
|
+
when "EMAIL"
|
|
64
|
+
on_email(value, type: type, pref: pref)
|
|
65
|
+
when "TEL"
|
|
66
|
+
on_phone(value, type: type, pref: pref)
|
|
67
|
+
when "ADR"
|
|
68
|
+
on_address(value, type: type, pref: pref)
|
|
69
|
+
when "URL"
|
|
70
|
+
on_url(value, type: type, pref: pref)
|
|
71
|
+
when "IMPP"
|
|
72
|
+
on_instant_message(value, type: type, pref: pref)
|
|
73
|
+
else
|
|
74
|
+
on_custom_property(name, value, params)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the constructed VCard from all accumulated properties.
|
|
79
|
+
#
|
|
80
|
+
# @return [VCard] the built vCard object.
|
|
81
|
+
def result
|
|
82
|
+
VCard.new(**@attributes)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
def on_version(value)
|
|
87
|
+
@attributes[:version] = value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def on_uid(value)
|
|
91
|
+
@attributes[:uid] = value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_formatted_name(value)
|
|
95
|
+
@attributes[:formatted_name] = @unescape.call(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def on_structured_name(value)
|
|
99
|
+
parts = split_structured(value)
|
|
100
|
+
STRUCTURED_NAME_PARTS.each_with_index do |key, i|
|
|
101
|
+
val = @unescape.call(parts[i].to_s)
|
|
102
|
+
@attributes[key] = val.empty? ? nil : val
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def on_kind(value)
|
|
107
|
+
@attributes[:kind] = value.downcase
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def on_nickname(value)
|
|
111
|
+
@attributes[:nickname] = @unescape.call(value)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def on_birthday(value)
|
|
115
|
+
@attributes[:birthday] = value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def on_anniversary(value)
|
|
119
|
+
@attributes[:anniversary] = value
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def on_gender(value)
|
|
123
|
+
@attributes[:gender] = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def on_note(value)
|
|
127
|
+
@attributes[:note] = @unescape.call(value)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def on_product_id(value)
|
|
131
|
+
@attributes[:product_id] = value
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def on_email(value, type:, pref:)
|
|
135
|
+
@attributes[:emails] << {
|
|
136
|
+
address: value,
|
|
137
|
+
label: type,
|
|
138
|
+
pref: pref,
|
|
139
|
+
position: next_position(:email)
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def on_phone(value, type:, pref:)
|
|
144
|
+
@attributes[:phones] << {
|
|
145
|
+
number: value,
|
|
146
|
+
label: type,
|
|
147
|
+
pref: pref,
|
|
148
|
+
position: next_position(:phone)
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def on_address(value, type:, pref:)
|
|
153
|
+
parts = split_structured(value)
|
|
154
|
+
addr = {
|
|
155
|
+
label: type,
|
|
156
|
+
pref: pref,
|
|
157
|
+
position: next_position(:address)
|
|
158
|
+
}
|
|
159
|
+
ADDRESS_PARTS.each_with_index do |key, i|
|
|
160
|
+
val = @unescape.call(parts[i].to_s)
|
|
161
|
+
addr[key] = val.empty? ? nil : val
|
|
162
|
+
end
|
|
163
|
+
@attributes[:addresses] << addr
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def on_url(value, type:, pref:)
|
|
167
|
+
@attributes[:urls] << {
|
|
168
|
+
url: value,
|
|
169
|
+
label: type,
|
|
170
|
+
pref: pref,
|
|
171
|
+
position: next_position(:url)
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def on_instant_message(value, type:, pref:)
|
|
176
|
+
@attributes[:instant_messages] << {
|
|
177
|
+
uri: value,
|
|
178
|
+
label: type,
|
|
179
|
+
pref: pref,
|
|
180
|
+
position: next_position(:im)
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def on_custom_property(name, value, params)
|
|
185
|
+
@attributes[:custom_properties] << {
|
|
186
|
+
name: name.upcase,
|
|
187
|
+
value: value,
|
|
188
|
+
params: params_string(params),
|
|
189
|
+
position: next_position(:custom)
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def next_position(counter)
|
|
194
|
+
position = @position_counters[counter]
|
|
195
|
+
@position_counters[counter] += 1
|
|
196
|
+
position
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def split_structured(value)
|
|
200
|
+
value.split(/(?<!\\);/, -1)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def params_string(params)
|
|
204
|
+
return nil if params.empty?
|
|
205
|
+
params.map { |k, v| "#{k}=#{v}" }.join(";")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Vcardfull
|
|
6
|
+
# Streaming vCard parser that supports versions 2.1, 3.0, and 4.0.
|
|
7
|
+
#
|
|
8
|
+
# Automatically detects the vCard version from the input and delegates to
|
|
9
|
+
# the appropriate version-specific parser. Values smaller than a configurable
|
|
10
|
+
# threshold are buffered in memory; larger values are written to temporary files.
|
|
11
|
+
class Parser
|
|
12
|
+
autoload :LineReader, "vcardfull/parser/line_reader"
|
|
13
|
+
autoload :VCardHandler, "vcardfull/parser/vcard_handler"
|
|
14
|
+
autoload :V21, "vcardfull/parser/v2_1"
|
|
15
|
+
autoload :V30, "vcardfull/parser/v3_0"
|
|
16
|
+
autoload :V40, "vcardfull/parser/v4_0"
|
|
17
|
+
|
|
18
|
+
DEFAULT_LARGE_VALUE_THRESHOLD = 1 * 1024 * 1024 # 1 MB
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Parses vCard data and returns a VCard object.
|
|
22
|
+
#
|
|
23
|
+
# Detects the vCard version from the input and delegates to the
|
|
24
|
+
# appropriate version-specific parser (V21, V30, or V40).
|
|
25
|
+
#
|
|
26
|
+
# @param input [String, IO] vCard data as a String or an IO-like object.
|
|
27
|
+
# @param args [Hash] additional keyword arguments forwarded to the parser constructor.
|
|
28
|
+
# @return [VCard] the parsed vCard.
|
|
29
|
+
def parse(input, **args)
|
|
30
|
+
io = input.is_a?(String) ? StringIO.new(input) : input
|
|
31
|
+
version = detect_version(io)
|
|
32
|
+
|
|
33
|
+
parser_class = case version
|
|
34
|
+
when "2.1" then V21
|
|
35
|
+
when "3.0" then V30
|
|
36
|
+
else V40
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
parser_class.new(io, **args).parse
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
def detect_version(io)
|
|
44
|
+
version = nil
|
|
45
|
+
|
|
46
|
+
io.each_line do |raw_line|
|
|
47
|
+
line = raw_line.chomp("\r\n").chomp("\n").chomp("\r")
|
|
48
|
+
|
|
49
|
+
if line =~ /\AVERSION:(.*)\z/i
|
|
50
|
+
version = $1.strip
|
|
51
|
+
break
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
io.rewind
|
|
55
|
+
|
|
56
|
+
version
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Creates a new parser instance.
|
|
61
|
+
#
|
|
62
|
+
# @param input [String, IO] vCard data as a String or an IO-like object.
|
|
63
|
+
# @param handler [VCardHandler, nil] a custom handler for property events. Defaults to a new VCardHandler.
|
|
64
|
+
# @param large_value_threshold [Integer] byte size above which values are written to disk
|
|
65
|
+
# instead of being buffered in memory. Defaults to 1 MB.
|
|
66
|
+
def initialize(input, handler: nil, large_value_threshold: DEFAULT_LARGE_VALUE_THRESHOLD)
|
|
67
|
+
@io = input.is_a?(String) ? StringIO.new(input) : input
|
|
68
|
+
@large_value_threshold = large_value_threshold
|
|
69
|
+
@handler = handler || VCardHandler.new(unescape: method(:unescape))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Runs the parser over the input and returns the constructed VCard.
|
|
73
|
+
#
|
|
74
|
+
# Iterates over each vCard property, dispatching events to the handler,
|
|
75
|
+
# then returns the handler's result.
|
|
76
|
+
#
|
|
77
|
+
# @return [VCard] the parsed vCard.
|
|
78
|
+
def parse
|
|
79
|
+
each_property do |name, params, value, type:, pref:|
|
|
80
|
+
@handler.on_property(name, params, value, type: type, pref: pref)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@handler.result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
def each_property
|
|
88
|
+
line_reader = LineReader.new(
|
|
89
|
+
@io,
|
|
90
|
+
quoted_printable_aware: quoted_printable_aware?,
|
|
91
|
+
large_value_threshold: @large_value_threshold
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
line_reader.each_line do |line_io|
|
|
95
|
+
name, params, value_io = parse_line(line_io)
|
|
96
|
+
|
|
97
|
+
unless name.nil?
|
|
98
|
+
value = decode(value_io, params)
|
|
99
|
+
type = extract_type(params)
|
|
100
|
+
pref = extract_pref(params)
|
|
101
|
+
|
|
102
|
+
yield name, params, value, type: type, pref: pref
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def quoted_printable_aware?
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def decode(value_io, params)
|
|
112
|
+
read_value(value_io) do |value|
|
|
113
|
+
unescape(value)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_value(value_io)
|
|
118
|
+
if value_io.respond_to?(:read) && large_value?(value_io)
|
|
119
|
+
value_io
|
|
120
|
+
elsif value_io.respond_to?(:read)
|
|
121
|
+
yield value_io.read
|
|
122
|
+
else
|
|
123
|
+
yield value_io
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def large_value?(value_io)
|
|
128
|
+
(value_io.size - value_io.pos) > @large_value_threshold
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_line(line_io)
|
|
132
|
+
property_with_params = +""
|
|
133
|
+
|
|
134
|
+
while (char = line_io.getc)
|
|
135
|
+
if char == ":"
|
|
136
|
+
break
|
|
137
|
+
else
|
|
138
|
+
property_with_params << char
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if property_with_params.empty?
|
|
143
|
+
nil
|
|
144
|
+
else
|
|
145
|
+
parts = property_with_params.split(";")
|
|
146
|
+
name = parts.shift
|
|
147
|
+
params = parse_params(parts)
|
|
148
|
+
|
|
149
|
+
[ name, params, line_io ]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def parse_params(parts)
|
|
154
|
+
parts.each_with_object({}) do |part, params|
|
|
155
|
+
if part.include?("=")
|
|
156
|
+
key, val = part.split("=", 2)
|
|
157
|
+
params[key.upcase] = val
|
|
158
|
+
else
|
|
159
|
+
params["TYPE"] = [ params["TYPE"], part ].compact.join(",")
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def extract_type(params)
|
|
165
|
+
params["TYPE"]&.split(",")&.first&.downcase
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def extract_pref(params)
|
|
169
|
+
params["PREF"]&.to_i
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def unescape(value)
|
|
173
|
+
value.gsub("\\n", "\n").gsub("\\N", "\n").gsub("\\,", ",").gsub("\\;", ";").gsub("\\\\", "\\")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class Serializer
|
|
5
|
+
# vCard 2.1 serializer.
|
|
6
|
+
#
|
|
7
|
+
# Overrides parameter formatting to use bare type values (e.g. +;WORK+
|
|
8
|
+
# instead of +;TYPE=work+), disables backslash escaping, and applies
|
|
9
|
+
# quoted-printable encoding for non-ASCII values.
|
|
10
|
+
class V21 < Serializer
|
|
11
|
+
private
|
|
12
|
+
def build_params(label: nil, pref: nil)
|
|
13
|
+
parts = []
|
|
14
|
+
parts << label.upcase if label && !label.to_s.empty?
|
|
15
|
+
parts << "PREF" if pref && !pref.to_s.empty?
|
|
16
|
+
|
|
17
|
+
if parts.any?
|
|
18
|
+
";#{parts.join(";")}"
|
|
19
|
+
else
|
|
20
|
+
""
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def escape(value)
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fn_line
|
|
29
|
+
encode("FN", @attributes.formatted_name.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def n_line
|
|
33
|
+
parts = [
|
|
34
|
+
@attributes.family_name,
|
|
35
|
+
@attributes.given_name,
|
|
36
|
+
@attributes.additional_names,
|
|
37
|
+
@attributes.honorific_prefix,
|
|
38
|
+
@attributes.honorific_suffix
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
encode("N", parts.map(&:to_s).join(";"))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def encode(name, value)
|
|
45
|
+
if needs_quoted_printable_encoding?(value)
|
|
46
|
+
"#{name};ENCODING=QUOTED-PRINTABLE;CHARSET=UTF-8:#{quoted_printable_encode(value)}"
|
|
47
|
+
else
|
|
48
|
+
"#{name}:#{value}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def simple_properties(lines)
|
|
53
|
+
%i[nickname birthday anniversary gender note product_id].each do |key|
|
|
54
|
+
value = @attributes[key]
|
|
55
|
+
lines << encode(PROPERTY_NAMES[key], value.to_s) if value && !value.to_s.empty?
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def needs_quoted_printable_encoding?(value)
|
|
60
|
+
!value.ascii_only?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def quoted_printable_encode(value)
|
|
64
|
+
encoded = [ value ].pack("M")
|
|
65
|
+
encoded.gsub!(/=\n\z/, "")
|
|
66
|
+
encoded.gsub!(/\n/, "\r\n")
|
|
67
|
+
encoded
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
# Serializes a VCard object into vCard format (RFC 6350).
|
|
5
|
+
#
|
|
6
|
+
# Produces a complete vCard string with BEGIN/END delimiters, CRLF line
|
|
7
|
+
# endings, and properly escaped property values.
|
|
8
|
+
class Serializer
|
|
9
|
+
autoload :V21, "vcardfull/serializer/v2_1"
|
|
10
|
+
|
|
11
|
+
PROPERTY_NAMES = {
|
|
12
|
+
version: "VERSION",
|
|
13
|
+
kind: "KIND",
|
|
14
|
+
formatted_name: "FN",
|
|
15
|
+
nickname: "NICKNAME",
|
|
16
|
+
birthday: "BDAY",
|
|
17
|
+
anniversary: "ANNIVERSARY",
|
|
18
|
+
gender: "GENDER",
|
|
19
|
+
note: "NOTE",
|
|
20
|
+
product_id: "PRODID"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Creates a new Serializer.
|
|
24
|
+
#
|
|
25
|
+
# @param attributes [VCard] the vCard object to serialize.
|
|
26
|
+
def initialize(attributes)
|
|
27
|
+
@attributes = attributes
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Serializes the vCard to a vCard format string.
|
|
31
|
+
#
|
|
32
|
+
# @return [String] the vCard data with CRLF line endings.
|
|
33
|
+
def to_vcf
|
|
34
|
+
lines = []
|
|
35
|
+
lines << "BEGIN:VCARD"
|
|
36
|
+
lines << "VERSION:#{@attributes.version || "4.0"}"
|
|
37
|
+
lines << uid_line
|
|
38
|
+
lines << n_line if has_structured_name?
|
|
39
|
+
lines << fn_line
|
|
40
|
+
lines << kind_line if @attributes.kind
|
|
41
|
+
|
|
42
|
+
simple_properties(lines)
|
|
43
|
+
emails(lines)
|
|
44
|
+
phones(lines)
|
|
45
|
+
addresses(lines)
|
|
46
|
+
urls(lines)
|
|
47
|
+
instant_messages(lines)
|
|
48
|
+
custom_properties(lines)
|
|
49
|
+
|
|
50
|
+
lines << "END:VCARD"
|
|
51
|
+
lines.compact.join("\r\n") + "\r\n"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
def uid_line
|
|
56
|
+
"UID:#{@attributes.uid}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def n_line
|
|
60
|
+
parts = [
|
|
61
|
+
@attributes.family_name,
|
|
62
|
+
@attributes.given_name,
|
|
63
|
+
@attributes.additional_names,
|
|
64
|
+
@attributes.honorific_prefix,
|
|
65
|
+
@attributes.honorific_suffix
|
|
66
|
+
]
|
|
67
|
+
"N:#{parts.map { |p| escape(p.to_s) }.join(";")}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def has_structured_name?
|
|
71
|
+
%i[family_name given_name additional_names honorific_prefix honorific_suffix].any? do |key|
|
|
72
|
+
value = @attributes[key]
|
|
73
|
+
value.is_a?(String) ? !value.empty? : !value.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fn_line
|
|
78
|
+
"FN:#{escape(@attributes.formatted_name.to_s)}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def kind_line
|
|
82
|
+
"KIND:#{@attributes.kind}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def simple_properties(lines)
|
|
86
|
+
%i[nickname birthday anniversary gender note product_id].each do |key|
|
|
87
|
+
value = @attributes[key]
|
|
88
|
+
if value && !value.to_s.empty?
|
|
89
|
+
lines << "#{PROPERTY_NAMES[key]}:#{escape(value.to_s)}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def emails(lines)
|
|
95
|
+
Array(@attributes.emails).each do |email|
|
|
96
|
+
params = build_params(label: email.label, pref: email.pref)
|
|
97
|
+
lines << "EMAIL#{params}:#{email.address}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def phones(lines)
|
|
102
|
+
Array(@attributes.phones).each do |phone|
|
|
103
|
+
params = build_params(label: phone.label, pref: phone.pref)
|
|
104
|
+
lines << "TEL#{params}:#{phone.number}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def addresses(lines)
|
|
109
|
+
Array(@attributes.addresses).each do |addr|
|
|
110
|
+
params = build_params(label: addr.label, pref: addr.pref)
|
|
111
|
+
parts = [
|
|
112
|
+
addr.po_box,
|
|
113
|
+
addr.extended,
|
|
114
|
+
addr.street,
|
|
115
|
+
addr.locality,
|
|
116
|
+
addr.region,
|
|
117
|
+
addr.postal_code,
|
|
118
|
+
addr.country
|
|
119
|
+
]
|
|
120
|
+
lines << "ADR#{params}:#{parts.map { |p| escape(p.to_s) }.join(";")}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def urls(lines)
|
|
125
|
+
Array(@attributes.urls).each do |url|
|
|
126
|
+
params = build_params(label: url.label, pref: url.pref)
|
|
127
|
+
lines << "URL#{params}:#{url.url}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def instant_messages(lines)
|
|
132
|
+
Array(@attributes.instant_messages).each do |im|
|
|
133
|
+
params = build_params(label: im.label, pref: im.pref)
|
|
134
|
+
lines << "IMPP#{params}:#{im.uri}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def custom_properties(lines)
|
|
139
|
+
Array(@attributes.custom_properties).each do |prop|
|
|
140
|
+
value = prop.value.respond_to?(:read) ? prop.value.tap(&:rewind).read : prop.value
|
|
141
|
+
params_str = prop.params.to_s
|
|
142
|
+
if params_str.empty?
|
|
143
|
+
lines << "#{prop.name}:#{value}"
|
|
144
|
+
else
|
|
145
|
+
lines << "#{prop.name};#{params_str}:#{value}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_params(label: nil, pref: nil)
|
|
151
|
+
parts = []
|
|
152
|
+
parts << "TYPE=#{label}" if label && !label.to_s.empty?
|
|
153
|
+
parts << "PREF=#{pref}" if pref && !pref.to_s.empty?
|
|
154
|
+
|
|
155
|
+
if parts.any?
|
|
156
|
+
";#{parts.join(";")}"
|
|
157
|
+
else
|
|
158
|
+
""
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def escape(value)
|
|
163
|
+
value.gsub("\\", "\\\\\\\\").gsub(",", "\\,").gsub("\n", "\\n")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a vCard ADR property with structured address components.
|
|
6
|
+
class Address < Struct.new(:po_box, :extended, :street, :locality, :region, :postal_code, :country, :label, :pref, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into an Address, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Address, Hash] an Address instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [Address]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a non-standard or extension vCard property (e.g. X-properties).
|
|
6
|
+
class CustomProperty < Struct.new(:name, :value, :params, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into a CustomProperty, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [CustomProperty, Hash] a CustomProperty instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [CustomProperty]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a vCard EMAIL property.
|
|
6
|
+
class Email < Struct.new(:address, :label, :pref, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into an Email, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Email, Hash] an Email instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [Email]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a vCard IMPP (instant messaging) property.
|
|
6
|
+
class InstantMessage < Struct.new(:uri, :label, :pref, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into an InstantMessage, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [InstantMessage, Hash] an InstantMessage instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [InstantMessage]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a vCard TEL property.
|
|
6
|
+
class Phone < Struct.new(:number, :label, :pref, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into a Phone, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Phone, Hash] a Phone instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [Phone]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
class VCard
|
|
5
|
+
# Represents a vCard URL property.
|
|
6
|
+
class Url < Struct.new(:url, :label, :pref, :position, keyword_init: true)
|
|
7
|
+
# Wraps raw data into a Url, returning the object unchanged if it is already one.
|
|
8
|
+
#
|
|
9
|
+
# @param data [Url, Hash] a Url instance or a Hash of keyword arguments.
|
|
10
|
+
# @return [Url]
|
|
11
|
+
def self.wrap(data)
|
|
12
|
+
if data.is_a?(self)
|
|
13
|
+
data
|
|
14
|
+
else
|
|
15
|
+
new(**data)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vcardfull
|
|
4
|
+
# Represents a parsed vCard with all standard properties.
|
|
5
|
+
#
|
|
6
|
+
# Scalar properties (version, uid, formatted_name, etc.) are stored as
|
|
7
|
+
# simple values. Collection properties (emails, phones, addresses, urls,
|
|
8
|
+
# instant_messages, custom_properties) are arrays of typed value objects.
|
|
9
|
+
class VCard < Struct.new(
|
|
10
|
+
:version, :uid, :formatted_name,
|
|
11
|
+
:family_name, :given_name, :additional_names, :honorific_prefix, :honorific_suffix,
|
|
12
|
+
:kind, :nickname, :birthday, :anniversary, :gender, :note, :product_id,
|
|
13
|
+
:emails, :phones, :addresses, :urls, :instant_messages, :custom_properties,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
autoload :Email, "vcardfull/vcard/email"
|
|
17
|
+
autoload :Phone, "vcardfull/vcard/phone"
|
|
18
|
+
autoload :Address, "vcardfull/vcard/address"
|
|
19
|
+
autoload :Url, "vcardfull/vcard/url"
|
|
20
|
+
autoload :InstantMessage, "vcardfull/vcard/instant_message"
|
|
21
|
+
autoload :CustomProperty, "vcardfull/vcard/custom_property"
|
|
22
|
+
|
|
23
|
+
# Creates a new VCard, wrapping collection data in typed value objects.
|
|
24
|
+
#
|
|
25
|
+
# @param kwargs [Hash] keyword arguments matching the Struct members.
|
|
26
|
+
def initialize(**)
|
|
27
|
+
super
|
|
28
|
+
self.emails = Array(self.emails).map { |data| Email.wrap(data) }
|
|
29
|
+
self.phones = Array(self.phones).map { |data| Phone.wrap(data) }
|
|
30
|
+
self.addresses = Array(self.addresses).map { |data| Address.wrap(data) }
|
|
31
|
+
self.urls = Array(self.urls).map { |data| Url.wrap(data) }
|
|
32
|
+
self.instant_messages = Array(self.instant_messages).map { |data| InstantMessage.wrap(data) }
|
|
33
|
+
self.custom_properties = Array(self.custom_properties).map { |data| CustomProperty.wrap(data) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Serializes this vCard to a vCard format string.
|
|
37
|
+
#
|
|
38
|
+
# @return [String] the vCard data with CRLF line endings.
|
|
39
|
+
def to_vcf
|
|
40
|
+
Serializer.new(self).to_vcf
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/vcardfull.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: vcardfull
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Stanko K.R.
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: stringio
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
executables: []
|
|
41
|
+
extensions: []
|
|
42
|
+
extra_rdoc_files: []
|
|
43
|
+
files:
|
|
44
|
+
- lib/vcardfull.rb
|
|
45
|
+
- lib/vcardfull/parser.rb
|
|
46
|
+
- lib/vcardfull/parser/line_reader.rb
|
|
47
|
+
- lib/vcardfull/parser/v2_1.rb
|
|
48
|
+
- lib/vcardfull/parser/v3_0.rb
|
|
49
|
+
- lib/vcardfull/parser/v4_0.rb
|
|
50
|
+
- lib/vcardfull/parser/vcard_handler.rb
|
|
51
|
+
- lib/vcardfull/serializer.rb
|
|
52
|
+
- lib/vcardfull/serializer/v2_1.rb
|
|
53
|
+
- lib/vcardfull/vcard.rb
|
|
54
|
+
- lib/vcardfull/vcard/address.rb
|
|
55
|
+
- lib/vcardfull/vcard/custom_property.rb
|
|
56
|
+
- lib/vcardfull/vcard/email.rb
|
|
57
|
+
- lib/vcardfull/vcard/instant_message.rb
|
|
58
|
+
- lib/vcardfull/vcard/phone.rb
|
|
59
|
+
- lib/vcardfull/vcard/url.rb
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata: {}
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.1'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.6.9
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: A vCard parser and serializer supporting versions 2.1, 3.0, and 4.0
|
|
80
|
+
test_files: []
|