valerie 0.0.8 → 1.0.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.
@@ -2,7 +2,22 @@ require_relative '../../valerie'
2
2
 
3
3
  module Valerie
4
4
  module Core
5
+ # Parser module for VCard data
6
+ # @api private
5
7
  module Parser
8
+ # Parse VCard data from string or array format
9
+ #
10
+ # @param data [String, Array<String>] VCard data to parse
11
+ # @return [Array<Card>, Card] Parsed card(s)
12
+ # - Returns Array<Card> when parsing from string
13
+ # - Returns single Card when parsing from array
14
+ # @raise [ArgumentError] if data is neither String nor Array
15
+ # @note The return type inconsistency is a known issue
16
+ # @example Parse from string
17
+ # cards = Valerie::Card.parse("BEGIN:VCARD\r\n...")
18
+ # card = cards.first
19
+ # @example Parse from array
20
+ # card = Valerie::Card.parse(['N:Doe;John', 'TEL;:555-1234'])
6
21
  def parse(data)
7
22
  if data.instance_of?(String)
8
23
  from_s(data)
@@ -12,40 +27,48 @@ module Valerie
12
27
  raise ArgumentError, "Expected String or Array, got #{data.class}"
13
28
  end
14
29
  end
15
-
30
+
16
31
  private
17
32
  def from_a(data)
18
33
  Card.new.tap do |vcard|
19
34
  if (name = data.find { _1.start_with?("N:") })
20
35
  vcard.name = Name.from_s name.split(":").last
21
36
  end
22
-
37
+
38
+ if (formatted_name = data.find { _1.start_with?("FN:") })
39
+ vcard.instance_variable_set(:@formatted_name, formatted_name.split(":", 2).last)
40
+ end
41
+
23
42
  if (organization = data.find { _1.start_with?("ORG:") })
24
43
  vcard.organization = Organization.from_s(organization)
25
44
  end
26
-
45
+
27
46
  if (birthday = data.find { _1.start_with?("BDAY:") })
28
47
  vcard.birthday = Birthday.from_s(birthday)
29
48
  end
30
-
49
+
31
50
  if (gender = data.find { _1.start_with?("GENDER:") })
32
51
  vcard.gender = gender.split(":").last
33
52
  end
34
-
53
+
35
54
  data.select { _1.include?("TEL;") }.each do |phone|
36
55
  vcard.phones.add(Phone.from_s(phone))
37
56
  end
38
-
57
+
39
58
  data.select { _1.start_with?("EMAIL;") }.each do |email|
40
59
  vcard.emails.add(Email.from_s(email))
41
60
  end
61
+
62
+ data.select { _1.start_with?("ADR;") }.each do |address|
63
+ vcard.addresses.add(Address.from_s(address))
64
+ end
42
65
  end
43
66
  end
44
-
67
+
45
68
  def from_s(str)
46
69
  vcards = []
47
70
  vcard = []
48
-
71
+
49
72
  unfold(str).each do |entry|
50
73
  if entry.include?("VERSION:")
51
74
  vcards << from_a(vcard) unless vcard.empty?
@@ -54,17 +77,17 @@ module Valerie
54
77
  vcard << entry
55
78
  end
56
79
  end
57
-
80
+
58
81
  vcards << from_a(vcard)
59
-
82
+
60
83
  vcards
61
84
  end
62
-
85
+
63
86
  UNTERMINATED_QUOTED_PRINTABLE = /ENCODING=QUOTED-PRINTABLE:.*=$/
64
-
87
+
65
88
  def unfold(vcard)
66
89
  unfolded = []
67
-
90
+
68
91
  prior_line = nil
69
92
  vcard.lines do |line|
70
93
  line.chomp!
@@ -82,7 +105,7 @@ module Valerie
82
105
  end
83
106
  prior_line = unfolded[-1]
84
107
  end
85
-
108
+
86
109
  unfolded
87
110
  end
88
111
  end
data/lib/valerie/email.rb CHANGED
@@ -1,42 +1,69 @@
1
1
  require_relative 'ordered'
2
2
 
3
3
  module Valerie
4
+ # Represents an email address in a VCard
5
+ #
6
+ # @example Simple email
7
+ # email = Valerie::Email.new(address: 'user@example.com')
8
+ #
9
+ # @example Email with type
10
+ # email = Valerie::Email.new(address: 'work@example.com', type: :work)
11
+ #
12
+ # @example Email with multiple types
13
+ # email = Valerie::Email.new(address: 'user@example.com', type: [:work, :internet])
4
14
  class Email
5
15
  include Ordered
6
-
16
+
17
+ # Parse an email from VCard EMAIL field string
18
+ #
19
+ # @param data [String] EMAIL field string (e.g., "EMAIL;TYPE=work:user@example.com")
20
+ # @return [Email] Parsed email object
21
+ # @api private
7
22
  def self.from_s(data)
8
23
  data = data[data.index("EMAIL;")..] unless data.start_with?("EMAIL;")
9
24
  identifier = data.split(":").last.split(";")
10
25
  options = data.gsub("EMAIL", "").split(":").first.split(";").compact.filter { _1.to_s.include?('=')}.map { _1.downcase.split("=") }.to_h
11
-
26
+
12
27
  new(
13
28
  address: identifier[0],
14
29
  **options
15
30
  )
16
31
  end
17
-
32
+
33
+ # @return [String] The email address
34
+ # @return [Hash] Additional options (type, position, etc.)
18
35
  attr_reader :address, :options
19
-
36
+
37
+ # Create a new email address
38
+ #
39
+ # @param address [String] The email address
40
+ # @param options [Hash] Additional options
41
+ # @option options [String, Symbol, Array] :type Type(s) like :work, :home, :internet
42
+ # @option options [Integer] :position Position in collection (1-based, used internally)
43
+ # @raise [ArgumentError] if position is invalid (< 1)
44
+ # @return [Email]
45
+ # @example
46
+ # email = Valerie::Email.new(address: 'user@example.com', type: :work)
20
47
  def initialize(address:, **options)
21
48
  @address = address
22
49
  @options = options
23
-
50
+
24
51
  raise ArgumentError, 'Invalid Position' if invalid_position?
25
52
  end
26
-
53
+
27
54
  def [](key)
28
55
  @options[key]
29
56
  end
30
-
57
+
31
58
  def to_s
32
59
  parts = ['EMAIL']
33
-
34
- parts << "PERF=#{@options[:position]}" if position?
60
+
61
+ parts << "PREF=#{@options[:position]}" if position?
35
62
  parts << type_to_s if type?
36
-
63
+
37
64
  parts.join(';') + ":#{@address}"
38
65
  end
39
-
66
+
40
67
  def to_h
41
68
  {
42
69
  address: @address,
@@ -44,12 +71,12 @@ module Valerie
44
71
  type: @options[:type],
45
72
  }
46
73
  end
47
-
74
+
48
75
  private
49
76
  def type?
50
77
  @options[:type]
51
78
  end
52
-
79
+
53
80
  def type_to_s
54
81
  if @options[:type].is_a?(Array)
55
82
  "TYPE=\"#{@options[:type].join(',')}\""
data/lib/valerie/name.rb CHANGED
@@ -1,24 +1,58 @@
1
1
  module Valerie
2
+ # Represents a person's name in a VCard
3
+ #
4
+ # @example With hash
5
+ # name = Valerie::Name.new(first_name: 'John', last_name: 'Doe')
6
+ #
7
+ # @example With full details
8
+ # name = Valerie::Name.new(
9
+ # first_name: 'John',
10
+ # last_name: 'Doe',
11
+ # middle_name: 'Michael',
12
+ # prefix: 'Dr.',
13
+ # suffix: 'Jr.'
14
+ # )
15
+ #
16
+ # @example With string (splits on first space)
17
+ # name = Valerie::Name.new('John Doe')
2
18
  class Name
3
19
  class << self
20
+ # Create a new Name from various input formats
21
+ #
22
+ # @param parts [Hash, String] Name data
23
+ # @return [Name]
4
24
  def new(parts)
5
25
  if parts.is_a?(Hash)
6
26
  super **parts
7
27
  else
8
28
  first_name, last_name = parts.split(" ")
9
-
29
+
10
30
  super first_name:, last_name:
11
31
  end
12
32
  end
13
-
33
+
34
+ # Parse a name from VCard N field string
35
+ #
36
+ # @param data [String] N field string
37
+ # @return [Name] Parsed name object
38
+ # @api private
14
39
  def from_s(data)
15
40
  parts = data.gsub("N:", "").split(";")
16
41
  new first_name: parts[1], last_name: parts[0], middle_name: parts[2], prefix: parts[3], suffix: parts[4]
17
42
  end
18
43
  end
19
-
44
+
45
+ # @return [String, nil] Given name (first name)
46
+ # @return [String, nil] Family name (last name)
20
47
  attr_reader :first_name, :last_name
21
-
48
+
49
+ # Create a new name
50
+ #
51
+ # @param first_name [String, nil] Given name
52
+ # @param last_name [String, nil] Family name
53
+ # @param middle_name [String, nil] Middle name
54
+ # @param prefix [String, nil] Name prefix (e.g., "Dr.", "Ms.")
55
+ # @param suffix [String, nil] Name suffix (e.g., "Jr.", "PhD")
22
56
  def initialize(first_name: nil, last_name: nil, middle_name: nil, prefix: nil, suffix: nil)
23
57
  @first_name = first_name
24
58
  @last_name = last_name
@@ -26,11 +60,11 @@ module Valerie
26
60
  @prefix = prefix
27
61
  @suffix = suffix
28
62
  end
29
-
63
+
30
64
  def to_s
31
65
  "N:#{[@last_name, @first_name, @middle_name, @prefix, @suffix].join(";")}"
32
66
  end
33
-
67
+
34
68
  def to_h
35
69
  {
36
70
  first_name: @first_name,
data/lib/valerie/phone.rb CHANGED
@@ -1,11 +1,31 @@
1
1
  require_relative 'ordered'
2
2
 
3
3
  module Valerie
4
+ # Represents a phone number in a VCard
5
+ #
6
+ # @example Simple phone number
7
+ # phone = Valerie::Phone.new('+1-555-1234')
8
+ #
9
+ # @example Phone with type
10
+ # phone = Valerie::Phone.new('+1-555-1234', type: :work)
11
+ #
12
+ # @example Phone with multiple types
13
+ # phone = Valerie::Phone.new('+1-555-1234', type: [:work, :voice])
14
+ #
15
+ # @example Phone with position
16
+ # phone = Valerie::Phone.new('+1-555-1234', type: :cell, position: 1)
4
17
  class Phone
18
+ # Valid phone type values according to VCard spec
19
+ # @note This constant is currently not enforced
5
20
  VALID_TYPES = %w[text voice fax cell video pager textphone].freeze
6
21
 
7
22
  include Ordered
8
23
 
24
+ # Parse a phone number from VCard TEL field string
25
+ #
26
+ # @param data [String] TEL field string (e.g., "TEL;TYPE=work:+1-555-1234")
27
+ # @return [Phone] Parsed phone object
28
+ # @api private
9
29
  def self.from_s(data)
10
30
  data = data[data.index("TEL;")..] unless data.start_with?("TEL;")
11
31
  identifier = data.split(":").last
@@ -14,8 +34,20 @@ module Valerie
14
34
  new(identifier, **options)
15
35
  end
16
36
 
37
+ # @return [String] The phone number
38
+ # @return [Hash] Additional options (type, position, etc.)
17
39
  attr_reader :number, :options
18
40
 
41
+ # Create a new phone number
42
+ #
43
+ # @param number [String] The phone number
44
+ # @param options [Hash] Additional options
45
+ # @option options [String, Symbol, Array] :type Type(s) like :work, :home, :cell, :voice, :fax
46
+ # @option options [Integer] :position Position in collection (1-based, used internally)
47
+ # @raise [ArgumentError] if position is invalid (< 1)
48
+ # @return [Phone]
49
+ # @example
50
+ # phone = Valerie::Phone.new('+1-555-1234', type: :work)
19
51
  def initialize(number, **options)
20
52
  @number = number
21
53
  @options = options.transform_keys!(&:to_sym)
@@ -24,19 +56,31 @@ module Valerie
24
56
  raise ArgumentError, 'Invalid Position' if invalid_position?
25
57
  end
26
58
 
59
+ # Access phone options
60
+ #
61
+ # @param key [Symbol] Option key
62
+ # @return [Object, nil] Option value
27
63
  def [](key)
28
64
  @options[key]
29
65
  end
30
66
 
67
+ # Convert to VCard TEL field string
68
+ #
69
+ # @return [String] TEL field in VCard format
70
+ # @example
71
+ # phone.to_s #=> "TEL;PREF=1;TYPE=work:+1-555-1234"
31
72
  def to_s
32
73
  parts = ['TEL']
33
74
 
34
- parts << "PERF=#{@options[:position]}" if position?
75
+ parts << "PREF=#{@options[:position]}" if position?
35
76
  parts << type_to_s if type?
36
77
 
37
78
  parts.join(';') + ":#{@number}"
38
79
  end
39
80
 
81
+ # Convert to hash representation
82
+ #
83
+ # @return [Hash] Hash with :number, :type, and :position keys
40
84
  def to_h
41
85
  {
42
86
  number: @number,
data/lib/valerie.rb CHANGED
@@ -11,28 +11,64 @@ require 'valerie/collection/email_collection'
11
11
  require 'valerie/collection/address_collection'
12
12
  require 'valerie/collection/phone_collection'
13
13
 
14
+ # Valerie is a VCard 3.0 parser and generator for Ruby.
15
+ # It provides a simple and flexible API for creating, parsing, and managing contact cards.
16
+ #
17
+ # @example Basic usage
18
+ # card = Valerie::Card.new
19
+ # card.name = { first_name: 'John', last_name: 'Doe' }
20
+ # card.emails.add('john@example.com', type: 'work')
21
+ # puts card.to_s
22
+ #
23
+ # @example Parsing a VCard
24
+ # vcard_string = "BEGIN:VCARD\r\nVERSION:3.0\r\n..."
25
+ # cards = Valerie::Card.parse(vcard_string)
26
+ #
27
+ # @see https://github.com/hellotext/valerie
14
28
  module Valerie
15
- VERSION = '0.0.7'.freeze
16
-
29
+ # Current version of the Valerie gem
30
+ VERSION = '1.0.0'.freeze
31
+
32
+ # Get the global configuration object
33
+ #
34
+ # @return [Configuration] The global configuration
17
35
  def self.configuration
18
36
  @configuration ||= Configuration.new
19
37
  end
20
-
38
+
39
+ # Configure Valerie globally
40
+ #
41
+ # @yield [Configuration] The configuration object
42
+ # @example
43
+ # Valerie.configure do |config|
44
+ # config.product = 'My App'
45
+ # config.version = '4.0'
46
+ # end
21
47
  def self.configure
22
48
  yield(configuration)
23
49
  end
24
-
50
+
51
+ # Global configuration for VCard generation
25
52
  class Configuration
53
+ # @return [String] Product identifier for PRODID field
54
+ # @return [String] VCard version number
55
+ # @return [String] Language code for the card
26
56
  attr_accessor :product, :version, :language
27
-
57
+
58
+ # Get the product identifier (defaults to 'Valerie www.hellotext.com')
59
+ # @return [String]
28
60
  def product
29
61
  @product ||= 'Valerie www.hellotext.com'
30
62
  end
31
-
63
+
64
+ # Get the VCard version (defaults to '3.0')
65
+ # @return [String]
32
66
  def version
33
67
  @version ||= '3.0'
34
68
  end
35
-
69
+
70
+ # Get the language code (defaults to 'EN')
71
+ # @return [String]
36
72
  def language
37
73
  @language ||= 'EN'
38
74
  end
@@ -0,0 +1,93 @@
1
+ require_relative 'test_helper'
2
+
3
+ class AddressTest < Minitest::Test
4
+ def test_initialize_with_all_fields
5
+ address = Valerie::Address.new(
6
+ post_office_box: 'PO Box 123',
7
+ extended_address: 'Suite 200',
8
+ street_address: '123 Main St',
9
+ locality: 'New York',
10
+ region: 'NY',
11
+ postal_code: '10001',
12
+ country: 'USA'
13
+ )
14
+
15
+ assert_equal(address.to_h[:post_office_box], 'PO Box 123')
16
+ assert_equal(address.to_h[:extended_address], 'Suite 200')
17
+ assert_equal(address.to_h[:street_address], '123 Main St')
18
+ assert_equal(address.to_h[:locality], 'New York')
19
+ assert_equal(address.to_h[:region], 'NY')
20
+ assert_equal(address.to_h[:postal_code], '10001')
21
+ assert_equal(address.to_h[:country], 'USA')
22
+ end
23
+
24
+ def test_to_s_format
25
+ address = Valerie::Address.new(
26
+ post_office_box: '',
27
+ extended_address: '',
28
+ street_address: '123 Main St',
29
+ locality: 'New York',
30
+ region: 'NY',
31
+ postal_code: '10001',
32
+ country: 'USA'
33
+ )
34
+
35
+ assert_equal(address.to_s.start_with?('ADR:'), true)
36
+ assert_equal(address.identifier, ';;123 Main St;New York;NY;10001;USA')
37
+ end
38
+
39
+ def test_to_s_with_position
40
+ address = Valerie::Address.new(
41
+ post_office_box: '',
42
+ extended_address: '',
43
+ street_address: '123 Main St',
44
+ locality: 'New York',
45
+ region: 'NY',
46
+ postal_code: '10001',
47
+ country: 'USA',
48
+ position: 1
49
+ )
50
+
51
+ assert_equal(address.to_s, 'ADR;PREF=1:;;123 Main St;New York;NY;10001;USA')
52
+ end
53
+
54
+ def test_with_invalid_position
55
+ error = assert_raises(ArgumentError) do
56
+ Valerie::Address.new(
57
+ post_office_box: '',
58
+ extended_address: '',
59
+ street_address: '123 Main St',
60
+ locality: 'New York',
61
+ region: 'NY',
62
+ postal_code: '10001',
63
+ country: 'USA',
64
+ position: -1
65
+ )
66
+ end
67
+
68
+ assert_equal(error.message, 'Invalid Position')
69
+ end
70
+
71
+ def test_from_s_parsing
72
+ address_string = 'ADR;TYPE=work:PO Box 123;Suite 200;123 Main St;New York;NY;10001;USA'
73
+ address = Valerie::Address.from_s(address_string)
74
+
75
+ assert_equal(address.to_h[:post_office_box], 'PO Box 123')
76
+ assert_equal(address.to_h[:extended_address], 'Suite 200')
77
+ assert_equal(address.to_h[:street_address], '123 Main St')
78
+ assert_equal(address.to_h[:locality], 'New York')
79
+ assert_equal(address.to_h[:region], 'NY')
80
+ assert_equal(address.to_h[:postal_code], '10001')
81
+ assert_equal(address.to_h[:country], 'USA')
82
+ end
83
+
84
+ def test_from_s_parsing_with_empty_fields
85
+ address_string = 'ADR;TYPE=home:;;456 Oak Ave;Los Angeles;CA;90001;USA'
86
+ address = Valerie::Address.from_s(address_string)
87
+
88
+ assert_equal(address.to_h[:post_office_box], '')
89
+ assert_equal(address.to_h[:extended_address], '')
90
+ assert_equal(address.to_h[:street_address], '456 Oak Ave')
91
+ assert_equal(address.to_h[:locality], 'Los Angeles')
92
+ end
93
+ end