lib/phone.rb
# An object representing a phone number.
#
# The phone number is recorded in 3 separate parts:
# * country_code - e.g. "385", "386"
# * area_code - e.g. "91", "47"
# * number - e.g. "5125486", "451588"
#
# All parts are mandatory, but country code and area code can be set for all phone numbers using
# Phone.default_country_code
# Phone.default_area_code
#
require "phone/country"
require "phone/errors"
module Phoner
class Phone
module ClassAttributes
attr_accessor :default_country_code
attr_accessor :default_area_code
attr_accessor :named_formats
attr_accessor :n1_length
end
extend ClassAttributes
module ClassAttributeAccessors
def default_country_code
self.class.default_country_code
end
def default_area_code
self.class.default_area_code
end
def named_formats
self.class.named_formats
end
def n1_length
self.class.n1_length
end
end
include ClassAttributeAccessors
self.named_formats = {
:default => "+%c%a%n",
:default_with_extension => "+%c%a%nx%x",
:europe => "+%c (0) %a %f %l",
:us => "(%a) %f-%l"
}
# length of first number part (using multi number format)
# default length of first number part
self.n1_length = 3
# common extension patterns
COMMON_EXTENSIONS = /[ ]*(ext|ex|x|xt|#|:)+[^0-9]*\(*([-0-9]{1,})\)*#?$/i
# common extra characters and sequences we normalize out
COMMON_EXTRAS = /(\(0\)|[^0-9+]|^\+?00?)/
# replacements for extra characters
COMMON_EXTRAS_REPLACEMENTS = {
"00" => "+",
"+00" => "+",
"+0" => "+"
}
# default replacement
COMMON_EXTRAS_REPLACEMENTS.default = ""
# tokens used in the formatting string: %c, %a, %A, etc
FORMAT_TOKENS = /(%[caAnflx])/
# is this string a valid phone number?
def self.valid?(string, options = {})
begin
!parse(string, options).nil?
# if we encountered exceptions (missing country code, missing area code etc)
rescue PhoneError
return false
end
end
# create a new phone number by parsing a string
# the format of the string is detect automatically (from FORMATS)
def self.parse(string, options={})
return if string.to_s.empty?
Country.load
string, extension = extract_extension(string)
string = normalize(string)
options[:country_code] ||= self.default_country_code
options[:area_code] ||= self.default_area_code
parts = split_to_parts(string, options)
pn = Phone.new(parts.merge(:extension => extension)) if parts
return pn
end
# Returns an array of the number with the extension removed, and the extension.
#
# Example:
#
# number, ext = Phoner::Phone.extract_extension("+1 (123) 456-7890 x321")
# # []
def self.extract_extension(string)
return [nil, nil] if string.nil?
if subbed = string.sub(COMMON_EXTENSIONS, "")
[subbed, $2]
else
[string, nil]
end
end
# fix string so it's easier to parse, remove extra characters etc.
def self.normalize(string_with_number)
# TODO: When we drop 1.8.7 we can pass the hash in as an arg to #gsub
string_with_number.gsub(COMMON_EXTRAS) do |match|
COMMON_EXTRAS_REPLACEMENTS[match.to_s]
end
end
# split string into hash with keys :country_code, :area_code and :number
def self.split_to_parts(string, options = {})
country = detect_country(string)
if country
options[:country_code] = country.country_code
string = string.gsub(country.country_code_regexp, "0")
else
if options[:country_code]
country = Country.find_by_country_code options[:country_code]
end
end
if country.nil?
if options[:country_code].nil?
raise CountryCodeError, "Must enter country code or set default country code"
else
raise CountryCodeError, "Could not find country with country code #{options[:country_code]}"
end
end
format = detect_format(string, country)
return nil if format.nil?
# Override the format IF overriding options are not present
format = :short if options[:area_code].nil?
parts = string.match formats(country)[format]
return nil if parts.nil?
case format
when :short
{
:number => parts[2],
:area_code => parts[1],
:country_code => options[:country_code]
}
when :really_short
{
:number => parts[1],
:area_code => options[:area_code],
:country_code => options[:country_code]
}
end
end
# detect country from the string entered
def self.detect_country(string)
detected_country = nil
# find if the number has a country code
Country.all.each_pair do |country_code, country|
if string =~ country.country_code_regexp
detected_country = country
end
end
detected_country
end
# detect format (from FORMATS) of input string
def self.detect_format(string_with_number, country)
arr = []
formats(country).each_pair do |format, regexp|
arr << format if string_with_number =~ regexp
end
# raise "Detected more than 1 format for #{string_with_number}" if arr.size > 1
if arr.length > 1
# puts %Q{detect_format: more than one format found - #{arr.inspect}}
return :really_short
end
arr.first
end
def self.formats(country)
area_code_regexp = country.area_code
number_regex = "([0-9]{1,#{country.max_num_length}})$".freeze
{
# 047451588, 013668734
:short => Regexp.new("^0?(#{area_code_regexp})#{number_regex}"),
# 451588
:really_short => Regexp.new("^#{number_regex}")
}
end
attr_accessor :country_code, :area_code, :number, :extension
# Initialize a new instance with a list or hash of phone number parts
#
# Example:
# Phone.new("5125486", "91", "385")
#
# # or
#
# Phone.new(
# :number => "5125486",
# :area_code => "91",
# :country_code => "385",
# :extension => "143"
# )
def initialize(*args)
input = args.first.is_a?(Hash) ? args.first : args_to_hash(args)
# set defaults
input[:area_code] ||= self.default_area_code
input[:country_code] ||= self.default_country_code
@number = input[:number].to_s.strip
raise BlankNumberError, "Must enter number" if @number.empty?
@area_code = input[:area_code].to_s.strip
raise AreaCodeError,
"Must enter area code or set default" if @area_code.empty?
@country_code = input[:country_code].to_s.strip
raise CountryCodeError,
"Must enter country code or set default" if @country_code.empty?
@extension = input[:extension]
end
# format area_code with trailing zero (e.g. 91 as 091)
# format area_code with trailing zero (e.g. 91 as 091)
def area_code_long
"0#{area_code}" if area_code
end
# first n characters of :number
def number1
number[0...self.class.n1_length]
end
# everything left from number after the first n characters (see number1)
def number2
n2_length = number.size - self.class.n1_length
number[-n2_length, n2_length]
end
# Formats the phone number.
#
# if the method argument is a String, it is used as a format string, with the following fields being interpolated:
#
# * %c - country_code (385)
# * %a - area_code (91)
# * %A - area_code with leading zero (091)
# * %n - number (5125486)
# * %f - first @@n1_length characters of number (configured through Phone.n1_length), default is 3 (512)
# * %l - last characters of number (5486)
# * %x - entire extension
#
# if the method argument is a Symbol, it is used as a lookup key for a format String in Phone.named_formats
# pn.format(:europe)
def format(fmt)
if fmt.is_a?(Symbol)
raise "The format #{fmt} doesn't exist" unless named_formats.has_key?(fmt)
format_number named_formats[fmt]
else
format_number(fmt)
end
end
# the default format is "+%c%a%n"
def to_s
format(:default)
end
# does this number belong to the default country code?
def has_default_country_code?
country_code == self.class.default_country_code
end
# does this number belong to the default area code?
def has_default_area_code?
area_code == self.class.default_area_code
end
# comparison of 2 phone objects
def ==(other)
methods = [:country_code, :area_code, :number, :extension]
methods.all? { |method| other.respond_to?(method) && send(method) == other.send(method) }
end
private
# Convert initialize arguments into parameter hash
#
# Example:
#
# args_to_hash ["5125486", "91", "385"]
# #=> { :number => "5125486",
# :area_code => "91",
# :country_code => "385",
# :extension => nil }
def args_to_hash(args)
{
:number => args[0],
:area_code => args[1],
:country_code => args[2],
:extension => args[3]
}
end
def format_number(fmt)
replacements = {
"%c" => (country_code || ""),
"%a" => (area_code || ""),
"%A" => (area_code_long || ""),
"%n" => (number || ""),
"%f" => (number1 || ""),
"%l" => (number2 || ""),
"%x" => (extension || "")
}
# TODO: When we drop 1.8.7 we can pass the hash in as an arg to #gsub
fmt.gsub(FORMAT_TOKENS) do |match|
replacements[match.to_s]
end
end
end
end