diff --git a/.gitignore b/.gitignore index ef99224..b6cf356 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,20 @@ -.idea/* -Gemfile.lock +# YARD documentation +/.yardoc +/doc + +# RubyGems +/*.gem + +# Bundler +/.bundle +/vendor/bundle + +# Test coverage +/coverage + +# IDE +/.idea +/.vscode +*.swp +*.swo +*~ diff --git a/.yardopts b/.yardopts new file mode 100644 index 0000000..8116502 --- /dev/null +++ b/.yardopts @@ -0,0 +1,10 @@ +--markup markdown +--markup-provider kramdown +--output-dir doc +--protected +--private +--title "Valerie - VCard Parser and Generator Documentation" +--readme README.md +lib/**/*.rb +- +LICENSE diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..d931358 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,24 @@ +PATH + remote: . + specs: + valerie (1.0.0) + +GEM + remote: https://rubygems.org/ + specs: + minitest (5.26.0) + rake (13.3.1) + yard (0.9.37) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + minitest (~> 5.14) + rake (~> 13.0) + valerie! + yard (~> 0.9) + +BUNDLED WITH + 2.6.7 diff --git a/README.md b/README.md index 8b20c7d..a8d2693 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Add this line to your application's Gemfile: gem 'valerie' ``` -Or install it yourself as: +Or install it yourself as: ```bash gem install valerie @@ -19,7 +19,7 @@ gem install valerie ## Usage -Parsing a VCard is as simple as passing a VCard string, like +Parsing a VCard is as simple as passing a VCard string, like ```ruby data = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Hellotext www.hellotext.com//EN\r\nN:Rosenbaum;Shira;;;\r\nTEL;:+598 00 000 00\r\nEND:VCARD" @@ -42,8 +42,7 @@ According to the VCard 3.0 specification, some types of properties can have mult - `TEL`: for telephone numbers - `ADR`: for addresses -Aside from these, every other support property supports a single value. - +Aside from these, every other support property supports a single value. ### Single value properties @@ -69,9 +68,9 @@ Or an array with the following order `[first_name, last_name, middle_name, prefi card.name = %w[Shira Rosenbaum M Ms. PhD] ``` -#### Gender +#### Gender -To set the Gender of the card you can, set it value `Card#gender=`, this accepts one of the following constants: +To set the Gender of the card you can, set it value `Card#gender=`, this accepts one of the following constants: `male`, `female`, `other`, `none` and `unknown`. Passing another value raises an `ArgumentError`. ```ruby @@ -97,7 +96,7 @@ card.organization = { organization: 'Hellotext', department: 'Engineering' } card.organization = 'Hellotext' ``` -### Collections +### Collections The card exposes methods to adding the respective collectable properties. Email, telephone and address. @@ -129,11 +128,11 @@ card.addresses.add( ) ``` -#### Positions +#### Positions -Emails, phones and addresses are ordered. They can include an optional `position` argument +Emails, phones and addresses are ordered. They can include an optional `position` argument to specify their order when the profile has multiple values. Whenever you add a new value, -a default position argument is picked and the value is appended as the last element. +a default position argument is picked and the value is appended as the last element. To specify the position, you can pass the `position` argument as a keyword argument. Unlike arrays the position is 1-based and not 0-based. @@ -153,9 +152,9 @@ card.emails.add('user@domain.com', type: 'work') card.emails.add('user@domain.com', type: %w[work internet]) ``` -### Configuration +### Configuration -You can configure the following properties of the card. +You can configure the following properties of the card. - `prodid`: The product id of the card. This defaults to 'Valerie www.hellotext.com'. - `version`: The version of the card. This defaults to '3.0'. @@ -169,6 +168,24 @@ Valerie.configure do |config| end ``` +### Documentation + +Valerie uses [YARD](https://yardoc.org/) for API documentation. To generate the documentation locally: + +```bash +# Install dependencies +bundle install + +# Generate documentation +bundle exec rake yard + +# Or generate and view +bundle exec rake doc +open doc/index.html +``` + +The documentation covers all public APIs with examples and usage information. + ### Licence This code is released under the MIT License. See the LICENSE file for more information. @@ -181,7 +198,7 @@ This code is released under the MIT License. See the LICENSE file for more infor Contributions are welcome. Please follow the steps below to contribute. -1. Fork it +1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) diff --git a/Rakefile b/Rakefile index b753476..c3becf2 100644 --- a/Rakefile +++ b/Rakefile @@ -6,3 +6,27 @@ end desc 'Run tests' task default: :test + +# YARD documentation task +begin + require 'yard' + + YARD::Rake::YardocTask.new do |t| + t.files = ['lib/**/*.rb'] + t.options = ['--markup', 'markdown', '--title', 'Valerie Documentation'] + end + + desc 'Generate YARD documentation and open in browser' + task :doc => :yard do + puts "Documentation generated in doc/" + puts "Run 'open doc/index.html' to view" + end +rescue LoadError + # YARD not available + desc 'Generate YARD documentation (YARD not installed)' + task :yard do + puts "YARD is not available. Install it with: gem install yard" + end + + task :doc => :yard +end diff --git a/lib/valerie.rb b/lib/valerie.rb index f222898..c74c5f1 100644 --- a/lib/valerie.rb +++ b/lib/valerie.rb @@ -11,28 +11,64 @@ require 'valerie/collection/address_collection' require 'valerie/collection/phone_collection' +# Valerie is a VCard 3.0 parser and generator for Ruby. +# It provides a simple and flexible API for creating, parsing, and managing contact cards. +# +# @example Basic usage +# card = Valerie::Card.new +# card.name = { first_name: 'John', last_name: 'Doe' } +# card.emails.add('john@example.com', type: 'work') +# puts card.to_s +# +# @example Parsing a VCard +# vcard_string = "BEGIN:VCARD\r\nVERSION:3.0\r\n..." +# cards = Valerie::Card.parse(vcard_string) +# +# @see https://github.com/hellotext/valerie module Valerie - VERSION = '0.0.7'.freeze - + # Current version of the Valerie gem + VERSION = '1.0.0'.freeze + + # Get the global configuration object + # + # @return [Configuration] The global configuration def self.configuration @configuration ||= Configuration.new end - + + # Configure Valerie globally + # + # @yield [Configuration] The configuration object + # @example + # Valerie.configure do |config| + # config.product = 'My App' + # config.version = '4.0' + # end def self.configure yield(configuration) end - + + # Global configuration for VCard generation class Configuration + # @return [String] Product identifier for PRODID field + # @return [String] VCard version number + # @return [String] Language code for the card attr_accessor :product, :version, :language - + + # Get the product identifier (defaults to 'Valerie www.hellotext.com') + # @return [String] def product @product ||= 'Valerie www.hellotext.com' end - + + # Get the VCard version (defaults to '3.0') + # @return [String] def version @version ||= '3.0' end - + + # Get the language code (defaults to 'EN') + # @return [String] def language @language ||= 'EN' end diff --git a/lib/valerie/address.rb b/lib/valerie/address.rb index 20745d7..78deb34 100644 --- a/lib/valerie/address.rb +++ b/lib/valerie/address.rb @@ -1,9 +1,37 @@ require_relative 'ordered' module Valerie + # Represents a physical address in a VCard + # + # @example Full address + # address = Valerie::Address.new( + # post_office_box: 'PO Box 123', + # extended_address: 'Suite 200', + # street_address: '123 Main St', + # locality: 'New York', + # region: 'NY', + # postal_code: '10001', + # country: 'USA' + # ) + # + # @example Simple address + # address = Valerie::Address.new( + # post_office_box: '', + # extended_address: '', + # street_address: '123 Main St', + # locality: 'New York', + # region: 'NY', + # postal_code: '10001', + # country: 'USA' + # ) class Address include Ordered - + + # Parse an address from VCard ADR field string + # + # @param data [String] ADR field string + # @return [Address] Parsed address object + # @api private def self.from_s(data) data = data[data.index("ADR;")..] unless data.start_with?("ADR;") identifier = data.split(":").last.split(";") @@ -21,6 +49,19 @@ def self.from_s(data) ) end + # Create a new address + # + # @param post_office_box [String] Post office box + # @param extended_address [String] Extended address (e.g., apartment, suite) + # @param street_address [String] Street address + # @param locality [String] City or locality + # @param region [String] State, province, or region + # @param postal_code [String] ZIP or postal code + # @param country [String] Country + # @param options [Hash] Additional options + # @option options [Integer] :position Position in collection (1-based, used internally) + # @raise [ArgumentError] if position is invalid (< 1) + # @return [Address] def initialize(post_office_box:, extended_address:, street_address:, locality:, region:, postal_code:, country:, **options) @post_office_box = post_office_box @extended_address = extended_address @@ -30,7 +71,7 @@ def initialize(post_office_box:, extended_address:, street_address:, locality:, @postal_code = postal_code @country = country @options = options - + raise ArgumentError, 'Invalid Position' if invalid_position? end @@ -40,14 +81,14 @@ def [](key) def to_s parts = ['ADR'] - - parts << "PERF=#{position}" if position? - + + parts << "PREF=#{position}" if position? + @options.map do |key, value| next if key == :position parts << "#{key}=#{value}" end - + parts.join(';') + ":#{identifier}" end diff --git a/lib/valerie/card.rb b/lib/valerie/card.rb index 5b664f3..c4d8d96 100644 --- a/lib/valerie/card.rb +++ b/lib/valerie/card.rb @@ -2,25 +2,98 @@ require_relative 'core/parser' module Valerie + # Represents a VCard (Contact Card) that can be generated or parsed. + # + # A Card contains various contact properties like name, email addresses, + # phone numbers, addresses, and more. It can be converted to VCard 3.0 format + # or parsed from VCard strings. + # + # @example Creating a new card + # card = Valerie::Card.new + # card.name = { first_name: 'Jane', last_name: 'Smith' } + # card.organization = { name: 'Acme Corp', department: 'Engineering' } + # card.birthday = Date.new(1990, 5, 15) + # card.gender = 'female' + # + # @example Adding contacts + # card.emails.add('jane@example.com', type: 'work') + # card.phones.add('+1-555-1234', type: 'cell') + # card.addresses.add( + # street_address: '123 Main St', + # locality: 'New York', + # region: 'NY', + # postal_code: '10001', + # country: 'USA' + # ) + # + # @example Generating VCard output + # vcard_string = card.to_s + # + # @example Parsing a VCard + # cards = Valerie::Card.parse("BEGIN:VCARD\r\n...") class Card extend Core::Parser - + + # @return [Name, nil] The name of the contact + # @return [String, nil] The formatted full name (FN field) + # @return [Organization, nil] The organization details + # @return [Gender, nil] The gender + # @return [Birthday, nil] The birthday attr_reader :name, :formatted_name, :organization, :gender, :birthday - + + # Set the formatted name (FN field) for this card + # + # @param value [String] The formatted full name + # @return [String] The formatted name + # @example + # card.formatted_name = 'Dr. Jane Smith Jr.' + def formatted_name=(value) + @formatted_name = value + end + + # Set the name for this card + # + # Automatically sets the formatted_name if not already set. + # + # @param parts [Hash, String, Name] Name data + # @option parts [String] :first_name Given name + # @option parts [String] :last_name Family name + # @option parts [String] :middle_name Middle name (optional) + # @option parts [String] :prefix Prefix like "Dr." or "Ms." (optional) + # @option parts [String] :suffix Suffix like "Jr." or "PhD" (optional) + # @return [Name] The created Name object + # @example With hash + # card.name = { first_name: 'John', last_name: 'Doe' } + # @example With string (splits on space) + # card.name = 'John Doe' + # @example With Name object + # card.name = Valerie::Name.new(first_name: 'John', last_name: 'Doe') def name=(parts) if parts.instance_of?(Name) @name = parts else @name = Name.new parts end - + if (@name.first_name || @name.last_name) && @formatted_name.nil? @formatted_name = "#{@name.first_name} #{@name.last_name}".strip end - + @name end - + + # Set the organization for this card + # + # @param args [Hash, Array, String, Organization] Organization data + # @option args [String] :name Organization name + # @option args [String] :department Department name (optional) + # @return [Organization] The created Organization object + # @example With hash + # card.organization = { name: 'Acme Corp', department: 'Engineering' } + # @example With array + # card.organization = ['Acme Corp', 'Engineering'] + # @example With string (department will be empty) + # card.organization = 'Acme Corp' def organization=(*args) if args.first.instance_of?(Organization) @organization = args.flatten.first @@ -28,55 +101,99 @@ def organization=(*args) @organization = Organization.new(*args) end end - + + # Set the gender for this card + # + # @param value [String, Symbol, Gender] Gender identifier + # Valid values: 'male', 'female', 'other', 'none', 'unknown', 'M', 'F', 'O', 'N', 'U' + # @return [Gender] The created Gender object + # @raise [ArgumentError] if the gender identifier is invalid + # @example + # card.gender = 'male' + # card.gender = :female def gender=(value) @gender = value.is_a?(Gender) ? value : Gender.new(value) end - + + # Set the birthday for this card + # + # @param value [Date, Time, String, Birthday] Birthday value + # If the value responds to strftime, it will be formatted as YYYY-MM-DD + # @return [Birthday] The created Birthday object + # @example With Date object + # card.birthday = Date.new(1990, 5, 15) + # @example With string + # card.birthday = '1990-05-15' def birthday=(value) @birthday = value.is_a?(Birthday) ? value : Birthday.new(value) end - + + # Get the email collection for this card + # + # @return [Collection::EmailCollection] The email collection + # @example + # card.emails.add('user@example.com', type: 'work') def emails @emails ||= Collection::EmailCollection.new end - + + # Get the address collection for this card + # + # @return [Collection::AddressCollection] The address collection + # @example + # card.addresses.add(street_address: '123 Main St', locality: 'NYC', ...) def addresses @addresses ||= Collection::AddressCollection.new end - + + # Get the phone collection for this card + # + # @return [Collection::PhoneCollection] The phone collection + # @example + # card.phones.add('+1-555-1234', type: 'cell') def phones @phones ||= Collection::PhoneCollection.new end - + + # Convert this card to a VCard 3.0 formatted string + # + # @return [String] VCard string with \r\n line endings + # @example + # vcard_string = card.to_s + # File.write('contact.vcf', vcard_string) def to_s to_a.join("\r\n") end - + + # Convert this card to an array of VCard lines + # + # @return [Array] Array of VCard property lines + # @api private def to_a parts = [ 'BEGIN:VCARD', "VERSION:#{Valerie.configuration.version}", "PRODID:-//#{Valerie.configuration.product}//#{Valerie.configuration.language}", ] - + parts << @name.to_s if @name + parts << "FN:#{@formatted_name}" if @formatted_name parts << @organization.to_s if @organization parts << @birthday.to_s if @birthday parts << @gender.to_s if @gender - + emails.each do |email| parts << email.to_s end - + phones.each do |phone| parts << phone.to_s end - + addresses.each do |address| parts << address.to_s end - + parts << 'END:VCARD' parts end diff --git a/lib/valerie/collection/address_collection.rb b/lib/valerie/collection/address_collection.rb index fc32a41..267771d 100644 --- a/lib/valerie/collection/address_collection.rb +++ b/lib/valerie/collection/address_collection.rb @@ -3,17 +3,49 @@ module Valerie module Collection + # Collection of addresses for a VCard + # + # @example + # collection = Valerie::Collection::AddressCollection.new + # collection.add( + # street_address: '123 Main St', + # locality: 'New York', + # region: 'NY', + # postal_code: '10001', + # country: 'USA' + # ) class AddressCollection < Base - def add(address, **options) + # Add an address to the collection + # + # Addresses are automatically sorted by position after adding. + # + # @param address [Hash, Address, nil] Address data hash or Address object + # @param options [Hash] Additional options merged with address data + # @option options [Integer] :position Position in collection (1-based, auto-assigned if not specified) + # @return [Address] The added address object + # @example Add with hash + # collection.add( + # post_office_box: '', + # extended_address: '', + # street_address: '123 Main St', + # locality: 'New York', + # region: 'NY', + # postal_code: '10001', + # country: 'USA' + # ) + def add(address = nil, **options) @item = if address.is_a?(Address) - address.dup { _1.position = options[:position] || @items.size + 1 } + address.dup.tap { _1.position = options[:position] || @items.size + 1 } else - Address.new(**address, **options) + merged_options = (address || {}).merge(options) + merged_options[:position] ||= @items.size + 1 + + Address.new(**merged_options) end - + @items << @item @items = @items.sort_by(&:position) - + @item end end diff --git a/lib/valerie/collection/email_collection.rb b/lib/valerie/collection/email_collection.rb index 795dded..4ff05eb 100644 --- a/lib/valerie/collection/email_collection.rb +++ b/lib/valerie/collection/email_collection.rb @@ -3,7 +3,27 @@ module Valerie module Collection + # Collection of email addresses for a VCard + # + # @example + # collection = Valerie::Collection::EmailCollection.new + # collection.add('work@example.com', type: :work) + # collection.add('personal@example.com', type: :home, position: 2) class EmailCollection < Base + # Add an email address to the collection + # + # Email addresses are automatically sorted by position after adding. + # + # @param email [String, Email] Email address string or Email object + # @param options [Hash] Options (only used if email is a String) + # @option options [String, Symbol, Array] :type Email type (:work, :home, :internet, etc.) + # @option options [Integer] :position Position in collection (1-based, auto-assigned if not specified) + # @return [Email] The added email object + # @example Add with type + # collection.add('user@example.com', type: :work) + # @example Add Email object + # email = Valerie::Email.new(address: 'user@example.com', type: :work) + # collection.add(email) def add(email, **options) @item = if email.is_a?(Email) email.dup.tap { _1.position = options[:position] || @items.size + 1 } @@ -12,10 +32,10 @@ def add(email, **options) else Email.new(address: email, **options, position: options[:position] || @items.size + 1) end - + @items << @item @items = @items.sort_by(&:position) - + @item end end diff --git a/lib/valerie/collection/phone_collection.rb b/lib/valerie/collection/phone_collection.rb index 59a22c6..16042a2 100644 --- a/lib/valerie/collection/phone_collection.rb +++ b/lib/valerie/collection/phone_collection.rb @@ -3,17 +3,36 @@ module Valerie module Collection + # Collection of phone numbers for a VCard + # + # @example + # collection = Valerie::Collection::PhoneCollection.new + # collection.add('+1-555-1234', type: :work) + # collection.add('+1-555-9999', type: :cell, position: 1) class PhoneCollection < Base + # Add a phone number to the collection + # + # Phone numbers are automatically sorted by position after adding. + # + # @param phone [String, Phone] Phone number string or Phone object + # @param options [Hash] Options (only used if phone is a String) + # @option options [String, Symbol, Array] :type Phone type (:work, :home, :cell, etc.) + # @option options [Integer] :position Position in collection (1-based, auto-assigned if not specified) + # @return [Phone] The added phone object + # @example Add with type + # collection.add('+1-555-1234', type: :work) + # @example Add with position + # collection.add('+1-555-1234', type: :cell, position: 1) def add(phone, **options) @item = if phone.is_a?(Phone) phone.dup.tap { _1.position = options[:position] || @items.size + 1 } else Phone.new(phone, **options, position: options[:position] || @items.size + 1) end - + @items << @item @items = @items.sort_by(&:position) - + @item end end diff --git a/lib/valerie/core/parser.rb b/lib/valerie/core/parser.rb index 4dfb0c0..7c1387f 100644 --- a/lib/valerie/core/parser.rb +++ b/lib/valerie/core/parser.rb @@ -2,7 +2,22 @@ module Valerie module Core + # Parser module for VCard data + # @api private module Parser + # Parse VCard data from string or array format + # + # @param data [String, Array] VCard data to parse + # @return [Array, Card] Parsed card(s) + # - Returns Array when parsing from string + # - Returns single Card when parsing from array + # @raise [ArgumentError] if data is neither String nor Array + # @note The return type inconsistency is a known issue + # @example Parse from string + # cards = Valerie::Card.parse("BEGIN:VCARD\r\n...") + # card = cards.first + # @example Parse from array + # card = Valerie::Card.parse(['N:Doe;John', 'TEL;:555-1234']) def parse(data) if data.instance_of?(String) from_s(data) @@ -12,40 +27,48 @@ def parse(data) raise ArgumentError, "Expected String or Array, got #{data.class}" end end - + private def from_a(data) Card.new.tap do |vcard| if (name = data.find { _1.start_with?("N:") }) vcard.name = Name.from_s name.split(":").last end - + + if (formatted_name = data.find { _1.start_with?("FN:") }) + vcard.instance_variable_set(:@formatted_name, formatted_name.split(":", 2).last) + end + if (organization = data.find { _1.start_with?("ORG:") }) vcard.organization = Organization.from_s(organization) end - + if (birthday = data.find { _1.start_with?("BDAY:") }) vcard.birthday = Birthday.from_s(birthday) end - + if (gender = data.find { _1.start_with?("GENDER:") }) vcard.gender = gender.split(":").last end - + data.select { _1.include?("TEL;") }.each do |phone| vcard.phones.add(Phone.from_s(phone)) end - + data.select { _1.start_with?("EMAIL;") }.each do |email| vcard.emails.add(Email.from_s(email)) end + + data.select { _1.start_with?("ADR;") }.each do |address| + vcard.addresses.add(Address.from_s(address)) + end end end - + def from_s(str) vcards = [] vcard = [] - + unfold(str).each do |entry| if entry.include?("VERSION:") vcards << from_a(vcard) unless vcard.empty? @@ -54,17 +77,17 @@ def from_s(str) vcard << entry end end - + vcards << from_a(vcard) - + vcards end - + UNTERMINATED_QUOTED_PRINTABLE = /ENCODING=QUOTED-PRINTABLE:.*=$/ - + def unfold(vcard) unfolded = [] - + prior_line = nil vcard.lines do |line| line.chomp! @@ -82,7 +105,7 @@ def unfold(vcard) end prior_line = unfolded[-1] end - + unfolded end end diff --git a/lib/valerie/email.rb b/lib/valerie/email.rb index c59a41f..228c502 100644 --- a/lib/valerie/email.rb +++ b/lib/valerie/email.rb @@ -1,42 +1,69 @@ require_relative 'ordered' module Valerie + # Represents an email address in a VCard + # + # @example Simple email + # email = Valerie::Email.new(address: 'user@example.com') + # + # @example Email with type + # email = Valerie::Email.new(address: 'work@example.com', type: :work) + # + # @example Email with multiple types + # email = Valerie::Email.new(address: 'user@example.com', type: [:work, :internet]) class Email include Ordered - + + # Parse an email from VCard EMAIL field string + # + # @param data [String] EMAIL field string (e.g., "EMAIL;TYPE=work:user@example.com") + # @return [Email] Parsed email object + # @api private def self.from_s(data) data = data[data.index("EMAIL;")..] unless data.start_with?("EMAIL;") identifier = data.split(":").last.split(";") options = data.gsub("EMAIL", "").split(":").first.split(";").compact.filter { _1.to_s.include?('=')}.map { _1.downcase.split("=") }.to_h - + new( address: identifier[0], **options ) end - + + # @return [String] The email address + # @return [Hash] Additional options (type, position, etc.) attr_reader :address, :options - + + # Create a new email address + # + # @param address [String] The email address + # @param options [Hash] Additional options + # @option options [String, Symbol, Array] :type Type(s) like :work, :home, :internet + # @option options [Integer] :position Position in collection (1-based, used internally) + # @raise [ArgumentError] if position is invalid (< 1) + # @return [Email] + # @example + # email = Valerie::Email.new(address: 'user@example.com', type: :work) def initialize(address:, **options) @address = address @options = options - + raise ArgumentError, 'Invalid Position' if invalid_position? end - + def [](key) @options[key] end - + def to_s parts = ['EMAIL'] - - parts << "PERF=#{@options[:position]}" if position? + + parts << "PREF=#{@options[:position]}" if position? parts << type_to_s if type? - + parts.join(';') + ":#{@address}" end - + def to_h { address: @address, @@ -44,12 +71,12 @@ def to_h type: @options[:type], } end - + private def type? @options[:type] end - + def type_to_s if @options[:type].is_a?(Array) "TYPE=\"#{@options[:type].join(',')}\"" diff --git a/lib/valerie/name.rb b/lib/valerie/name.rb index 32e3738..216fe10 100644 --- a/lib/valerie/name.rb +++ b/lib/valerie/name.rb @@ -1,24 +1,58 @@ module Valerie + # Represents a person's name in a VCard + # + # @example With hash + # name = Valerie::Name.new(first_name: 'John', last_name: 'Doe') + # + # @example With full details + # name = Valerie::Name.new( + # first_name: 'John', + # last_name: 'Doe', + # middle_name: 'Michael', + # prefix: 'Dr.', + # suffix: 'Jr.' + # ) + # + # @example With string (splits on first space) + # name = Valerie::Name.new('John Doe') class Name class << self + # Create a new Name from various input formats + # + # @param parts [Hash, String] Name data + # @return [Name] def new(parts) if parts.is_a?(Hash) super **parts else first_name, last_name = parts.split(" ") - + super first_name:, last_name: end end - + + # Parse a name from VCard N field string + # + # @param data [String] N field string + # @return [Name] Parsed name object + # @api private def from_s(data) parts = data.gsub("N:", "").split(";") new first_name: parts[1], last_name: parts[0], middle_name: parts[2], prefix: parts[3], suffix: parts[4] end end - + + # @return [String, nil] Given name (first name) + # @return [String, nil] Family name (last name) attr_reader :first_name, :last_name - + + # Create a new name + # + # @param first_name [String, nil] Given name + # @param last_name [String, nil] Family name + # @param middle_name [String, nil] Middle name + # @param prefix [String, nil] Name prefix (e.g., "Dr.", "Ms.") + # @param suffix [String, nil] Name suffix (e.g., "Jr.", "PhD") def initialize(first_name: nil, last_name: nil, middle_name: nil, prefix: nil, suffix: nil) @first_name = first_name @last_name = last_name @@ -26,11 +60,11 @@ def initialize(first_name: nil, last_name: nil, middle_name: nil, prefix: nil, s @prefix = prefix @suffix = suffix end - + def to_s "N:#{[@last_name, @first_name, @middle_name, @prefix, @suffix].join(";")}" end - + def to_h { first_name: @first_name, diff --git a/lib/valerie/phone.rb b/lib/valerie/phone.rb index 4699ce4..a7ca532 100644 --- a/lib/valerie/phone.rb +++ b/lib/valerie/phone.rb @@ -1,11 +1,31 @@ require_relative 'ordered' module Valerie + # Represents a phone number in a VCard + # + # @example Simple phone number + # phone = Valerie::Phone.new('+1-555-1234') + # + # @example Phone with type + # phone = Valerie::Phone.new('+1-555-1234', type: :work) + # + # @example Phone with multiple types + # phone = Valerie::Phone.new('+1-555-1234', type: [:work, :voice]) + # + # @example Phone with position + # phone = Valerie::Phone.new('+1-555-1234', type: :cell, position: 1) class Phone + # Valid phone type values according to VCard spec + # @note This constant is currently not enforced VALID_TYPES = %w[text voice fax cell video pager textphone].freeze include Ordered + # Parse a phone number from VCard TEL field string + # + # @param data [String] TEL field string (e.g., "TEL;TYPE=work:+1-555-1234") + # @return [Phone] Parsed phone object + # @api private def self.from_s(data) data = data[data.index("TEL;")..] unless data.start_with?("TEL;") identifier = data.split(":").last @@ -14,8 +34,20 @@ def self.from_s(data) new(identifier, **options) end + # @return [String] The phone number + # @return [Hash] Additional options (type, position, etc.) attr_reader :number, :options + # Create a new phone number + # + # @param number [String] The phone number + # @param options [Hash] Additional options + # @option options [String, Symbol, Array] :type Type(s) like :work, :home, :cell, :voice, :fax + # @option options [Integer] :position Position in collection (1-based, used internally) + # @raise [ArgumentError] if position is invalid (< 1) + # @return [Phone] + # @example + # phone = Valerie::Phone.new('+1-555-1234', type: :work) def initialize(number, **options) @number = number @options = options.transform_keys!(&:to_sym) @@ -24,19 +56,31 @@ def initialize(number, **options) raise ArgumentError, 'Invalid Position' if invalid_position? end + # Access phone options + # + # @param key [Symbol] Option key + # @return [Object, nil] Option value def [](key) @options[key] end + # Convert to VCard TEL field string + # + # @return [String] TEL field in VCard format + # @example + # phone.to_s #=> "TEL;PREF=1;TYPE=work:+1-555-1234" def to_s parts = ['TEL'] - parts << "PERF=#{@options[:position]}" if position? + parts << "PREF=#{@options[:position]}" if position? parts << type_to_s if type? parts.join(';') + ":#{@number}" end + # Convert to hash representation + # + # @return [Hash] Hash with :number, :type, and :position keys def to_h { number: @number, diff --git a/test/address_test.rb b/test/address_test.rb new file mode 100644 index 0000000..36f6005 --- /dev/null +++ b/test/address_test.rb @@ -0,0 +1,93 @@ +require_relative 'test_helper' + +class AddressTest < Minitest::Test + def test_initialize_with_all_fields + address = Valerie::Address.new( + post_office_box: 'PO Box 123', + extended_address: 'Suite 200', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA' + ) + + assert_equal(address.to_h[:post_office_box], 'PO Box 123') + assert_equal(address.to_h[:extended_address], 'Suite 200') + assert_equal(address.to_h[:street_address], '123 Main St') + assert_equal(address.to_h[:locality], 'New York') + assert_equal(address.to_h[:region], 'NY') + assert_equal(address.to_h[:postal_code], '10001') + assert_equal(address.to_h[:country], 'USA') + end + + def test_to_s_format + address = Valerie::Address.new( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA' + ) + + assert_equal(address.to_s.start_with?('ADR:'), true) + assert_equal(address.identifier, ';;123 Main St;New York;NY;10001;USA') + end + + def test_to_s_with_position + address = Valerie::Address.new( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA', + position: 1 + ) + + assert_equal(address.to_s, 'ADR;PREF=1:;;123 Main St;New York;NY;10001;USA') + end + + def test_with_invalid_position + error = assert_raises(ArgumentError) do + Valerie::Address.new( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA', + position: -1 + ) + end + + assert_equal(error.message, 'Invalid Position') + end + + def test_from_s_parsing + address_string = 'ADR;TYPE=work:PO Box 123;Suite 200;123 Main St;New York;NY;10001;USA' + address = Valerie::Address.from_s(address_string) + + assert_equal(address.to_h[:post_office_box], 'PO Box 123') + assert_equal(address.to_h[:extended_address], 'Suite 200') + assert_equal(address.to_h[:street_address], '123 Main St') + assert_equal(address.to_h[:locality], 'New York') + assert_equal(address.to_h[:region], 'NY') + assert_equal(address.to_h[:postal_code], '10001') + assert_equal(address.to_h[:country], 'USA') + end + + def test_from_s_parsing_with_empty_fields + address_string = 'ADR;TYPE=home:;;456 Oak Ave;Los Angeles;CA;90001;USA' + address = Valerie::Address.from_s(address_string) + + assert_equal(address.to_h[:post_office_box], '') + assert_equal(address.to_h[:extended_address], '') + assert_equal(address.to_h[:street_address], '456 Oak Ave') + assert_equal(address.to_h[:locality], 'Los Angeles') + end +end diff --git a/test/card_test.rb b/test/card_test.rb index 6bf3ada..82245fb 100644 --- a/test/card_test.rb +++ b/test/card_test.rb @@ -1,73 +1,186 @@ require_relative 'test_helper' +require 'date' class VCardTest < Minitest::Test def initialize(name) super @card = Valerie::Card.new end - + def test_setting_name_with_hash_parameter @card.name = { first_name: 'John', last_name: 'Doe' } - + assert_equal(@card.name.first_name, 'John') assert_equal(@card.name.last_name, 'Doe') end - + def test_setting_name_with_string_parameter @card.name = 'Adam Hull' - + assert_equal(@card.name.first_name, 'Adam') assert_equal(@card.name.last_name, 'Hull') end - + def test_setting_organization_with_hash @card.organization = { name: 'Hellotext', department: 'Product Development' } - + assert_equal(@card.organization.name, 'Hellotext') assert_equal(@card.organization.department, 'Product Development') end - + def test_setting_organization_with_string @card.organization = ['Hellotext', 'Product Development'] - + assert_equal(@card.organization.name, 'Hellotext') assert_equal(@card.organization.department, 'Product Development') end - + def test_setting_gender_with_valid_identifier assert_runs_without_errors do @card.gender = 'Male' assert_equal(@card.gender.sex, :male) end end - + def test_setting_gender_with_invalid_identifier assert_raises(ArgumentError) do @card.gender = 'invalid-identifier' end end - + def test_parsing_array data = ['PRODID:-//Hellotext', 'N:Doe;John;;;', "ORG:HelloText;", "TEL;type=CELL:+598 94 000 000"] - + Valerie::Card.parse(data).tap do |vcard| assert_equal(vcard.name.first_name, 'John') assert_equal(vcard.name.last_name, 'Doe') - + assert_equal(vcard.organization.name, 'HelloText') - + assert_equal(vcard.phones.first.number, '+598 94 000 000') assert_equal(vcard.phones.first.options[:type], 'cell') end end - + def test_parsing_string data = "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Hellotext www.hellotext.com//EN\r\nN:Rosenbaum;Shira;;;\r\nTEL;:+598 94 987 924\r\nEND:VCARD" - + Valerie::Card.parse(data).first.tap do |vcard| assert_equal(vcard.name.first_name, 'Shira') assert_equal(vcard.name.last_name, 'Rosenbaum') assert_equal(vcard.phones.first.number, '+598 94 987 924') end end + + def test_formatted_name_auto_generation + @card.name = { first_name: 'Jane', last_name: 'Smith' } + + assert_equal(@card.formatted_name, 'Jane Smith') + end + + def test_formatted_name_setter + @card.formatted_name = 'Dr. John Doe Jr.' + + assert_equal(@card.formatted_name, 'Dr. John Doe Jr.') + end + + def test_formatted_name_in_output + @card.name = { first_name: 'John', last_name: 'Doe' } + @card.formatted_name = 'Dr. John Doe Jr.' + + output = @card.to_s + assert_equal(output.include?('FN:Dr. John Doe Jr.'), true) + end + + def test_formatted_name_auto_generation_in_output + @card.name = { first_name: 'Jane', last_name: 'Smith' } + + output = @card.to_s + assert_equal(output.include?('FN:Jane Smith'), true) + end + + def test_parsing_formatted_name + data = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;;;\r\nFN:Dr. John Doe Jr.\r\nEND:VCARD" + + vcard = Valerie::Card.parse(data).first + assert_equal(vcard.formatted_name, 'Dr. John Doe Jr.') + end + + def test_parsing_address_from_string + data = "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Smith;Jane;;;\r\nADR;TYPE=work:;;123 Main St;New York;NY;10001;USA\r\nEND:VCARD" + + vcard = Valerie::Card.parse(data).first + assert_equal(vcard.addresses.count, 1) + assert_equal(vcard.addresses.first.to_h[:street_address], '123 Main St') + assert_equal(vcard.addresses.first.to_h[:locality], 'New York') + assert_equal(vcard.addresses.first.to_h[:region], 'NY') + assert_equal(vcard.addresses.first.to_h[:postal_code], '10001') + end + + def test_complete_vcard_generation + @card.name = { first_name: 'John', last_name: 'Doe', middle_name: 'Michael' } + @card.formatted_name = 'John Michael Doe' + @card.organization = { name: 'Acme Corp', department: 'Engineering' } + @card.birthday = Date.new(1990, 5, 15) + @card.gender = 'male' + + @card.emails.add('john@example.com', type: 'work') + @card.emails.add('john.doe@personal.com', type: 'home', position: 2) + + @card.phones.add('+1-555-123-4567', type: 'work') + @card.phones.add('+1-555-987-6543', type: 'cell', position: 2) + + @card.addresses.add( + post_office_box: '', + extended_address: 'Suite 100', + street_address: '123 Business Ave', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA' + ) + + output = @card.to_s + + # Verify all fields are present + assert_equal(output.include?('N:Doe;John;Michael;;'), true) + assert_equal(output.include?('FN:John Michael Doe'), true) + assert_equal(output.include?('ORG:Acme Corp;Engineering'), true) + assert_equal(output.include?('BDAY:1990-05-15'), true) + assert_equal(output.include?('GENDER:M'), true) + assert_equal(output.include?('EMAIL'), true) + assert_equal(output.include?('TEL'), true) + assert_equal(output.include?('ADR'), true) + assert_equal(output.include?('123 Business Ave'), true) + end + + def test_parsing_complete_vcard + data = <<~VCARD.gsub("\n", "\r\n") + BEGIN:VCARD + VERSION:3.0 + PRODID:-//Valerie www.hellotext.com//EN + N:Doe;John;Michael;; + FN:John Michael Doe + ORG:Acme Corp;Engineering + BDAY:1990-05-15 + GENDER:M + EMAIL;PREF=1;TYPE=work:john@example.com + TEL;PREF=1;TYPE=work:+1-555-123-4567 + ADR;TYPE=work:;Suite 100;123 Business Ave;New York;NY;10001;USA + END:VCARD + VCARD + + vcard = Valerie::Card.parse(data).first + + assert_equal(vcard.name.first_name, 'John') + assert_equal(vcard.name.last_name, 'Doe') + assert_equal(vcard.formatted_name, 'John Michael Doe') + assert_equal(vcard.organization.name, 'Acme Corp') + assert_equal(vcard.organization.department, 'Engineering') + assert_equal(vcard.birthday.to_date, Date.new(1990, 5, 15)) + assert_equal(vcard.gender.sex, :male) + assert_equal(vcard.emails.count, 1) + assert_equal(vcard.phones.count, 1) + assert_equal(vcard.addresses.count, 1) + end end diff --git a/test/collection/address_collection_test.rb b/test/collection/address_collection_test.rb new file mode 100644 index 0000000..f359bdd --- /dev/null +++ b/test/collection/address_collection_test.rb @@ -0,0 +1,97 @@ +require_relative '../test_helper' + +class AddressCollectionTest < Minitest::Test + def setup + @collection = Valerie::Collection::AddressCollection.new + end + + def test_add_address_as_hash + address = @collection.add( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA' + ) + + assert_instance_of(Valerie::Address, address) + assert_equal(address.to_h[:street_address], '123 Main St') + assert_equal(address.to_h[:locality], 'New York') + end + + def test_add_address_as_address_object + address = Valerie::Address.new( + post_office_box: '', + extended_address: '', + street_address: '456 Oak Ave', + locality: 'Los Angeles', + region: 'CA', + postal_code: '90001', + country: 'USA' + ) + + added = @collection.add(address) + + assert_equal(added.to_h[:street_address], '456 Oak Ave') + assert_equal(@collection.count, 1) + end + + def test_add_multiple_addresses_with_positions + # Add first address without position (gets position 1) + @collection.add( + post_office_box: '', + extended_address: '', + street_address: '456 Oak Ave', + locality: 'Los Angeles', + region: 'CA', + postal_code: '90001', + country: 'USA', + position: 2 + ) + + # Add second address with position 1 (should be first) + @collection.add( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA', + position: 1 + ) + + assert_equal(@collection.count, 2) + # Second address should be first due to position: 1 + assert_equal(@collection.first.to_h[:street_address], '123 Main St') + assert_equal(@collection.first.position, 1) + assert_equal(@collection.to_a.last.to_h[:street_address], '456 Oak Ave') + end + + def test_collection_enumerable + @collection.add( + post_office_box: '', + extended_address: '', + street_address: '123 Main St', + locality: 'New York', + region: 'NY', + postal_code: '10001', + country: 'USA' + ) + + @collection.add( + post_office_box: '', + extended_address: '', + street_address: '456 Oak Ave', + locality: 'Los Angeles', + region: 'CA', + postal_code: '90001', + country: 'USA' + ) + + cities = @collection.map { |addr| addr.to_h[:locality] } + assert_equal(cities, ['New York', 'Los Angeles']) + end +end diff --git a/test/email_test.rb b/test/email_test.rb index 926e778..d5d9c66 100644 --- a/test/email_test.rb +++ b/test/email_test.rb @@ -6,19 +6,19 @@ def test_to_s_with_single_type assert_equal(email.to_s.start_with?('EMAIL;TYPE=work:'), true) end end - + def test_with_invalid_position error = assert_raises(ArgumentError) do Valerie::Email.new(address: 'ahmed@hellotext.com', position: -1) end - + assert_equal(error.message, 'Invalid Position') end - + def test_to_s_with_preferred Valerie::Email.new(address: 'ahmed@hellotext.com', type: :work, position: 1).tap do |email| assert_equal( - email.to_s.include?(';PERF=1;'), + email.to_s.include?(';PREF=1;'), true ) end diff --git a/test/phone_test.rb b/test/phone_test.rb index 002b749..6c21182 100644 --- a/test/phone_test.rb +++ b/test/phone_test.rb @@ -23,7 +23,7 @@ def test_with_invalid_position def test_to_s_with_position Valerie::Phone.new('01000000000', type: :voice, position: 1).tap do |phone| - assert_equal(phone.to_s, 'TEL;PERF=1;TYPE=voice:01000000000') + assert_equal(phone.to_s, 'TEL;PREF=1;TYPE=voice:01000000000') end end diff --git a/valerie.gemspec b/valerie.gemspec index 8a81d96..04e50a1 100644 --- a/valerie.gemspec +++ b/valerie.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |s| s.name = 'valerie' - s.version = '0.0.8' + s.version = '1.0.0' s.summary = 'Easily parse and generate VCard (Contact Card) objects that can be exported to other systems with ease.' s.description = 'VCard (Contact Card) parser and generator.' s.authors = ['Hellotext', 'Ahmed Khattab'] @@ -13,4 +13,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'minitest', '~> 5.14' s.add_development_dependency 'rake', '~> 13.0' + s.add_development_dependency 'yard', '~> 0.9' end