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 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
@@ -0,0 +1,5 @@
1
+ module Vcardfull
2
+ autoload :Parser, "vcardfull/parser"
3
+ autoload :Serializer, "vcardfull/serializer"
4
+ autoload :VCard, "vcardfull/vcard"
5
+ end
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: []