=begin
Copyright (C) 2008 Sam Roberts
This library is free software; you can redistribute it and/or modify it
under the same terms as the ruby language itself, see the file COPYING for
details.
=end
require 'vpim/vpim'
require 'vpim/attachment'
require 'vpim/dirinfo'
require 'open-uri'
require 'stringio'
module Vpim
# A vCard, a specialization of a directory info object.
#
# The vCard format is specified by:
# - RFC2426[http://www.ietf.org/rfc/rfc2426.txt]: vCard MIME Directory Profile (vCard 3.0)
# - RFC2425[http://www.ietf.org/rfc/rfc2425.txt]: A MIME Content-Type for Directory Information
#
# This implements vCard 3.0, but it is also capable of working with vCard 2.1
# if used with care.
#
# All line values can be accessed with Vcard#value, Vcard#values, or even by
# iterating through Vcard#lines. Line types that don't have specific support
# and non-standard line types ("X-MY-SPECIAL", for example) will be returned
# as a String, with any base64 or quoted-printable encoding removed.
#
# Specific support exists to return more useful values for the standard vCard
# types, where appropriate.
#
# The wrapper functions (#birthday, #nicknames, #emails, etc.) exist
# partially as an API convenience, and partially as a place to document
# the values returned for the more complex types, like PHOTO and EMAIL.
#
# For types that do not sensibly occur multiple times (like BDAY or GEO),
# sometimes a wrapper exists only to return a single line, using #value.
# However, if you find the need, you can still call #values to get all the
# lines, and both the singular and plural forms will eventually be
# implemented.
#
# For more information see:
# - RFC2426[http://www.ietf.org/rfc/rfc2426.txt]: vCard MIME Directory Profile (vCard 3.0)
# - RFC2425[http://www.ietf.org/rfc/rfc2425.txt]: A MIME Content-Type for Directory Information
# - vCard2.1[http://www.imc.org/pdi/pdiproddev.html]: vCard 2.1 Specifications
#
# vCards are usually transmitted in files with .vcf
# extensions.
#
# = Examples
#
# - link:ex_mkvcard.txt: example of creating a vCard
# - link:ex_cpvcard.txt: example of copying and them modifying a vCard
# - link:ex_mkv21vcard.txt: example of creating version 2.1 vCard
# - link:mutt-aliases-to-vcf.txt: convert a mutt aliases file to vCards
# - link:ex_get_vcard_photo.txt: pull photo data from a vCard
# - link:ab-query.txt: query the OS X Address Book to find vCards
# - link:vcf-to-mutt.txt: query vCards for matches, output in formats useful
# with Mutt (see link:README.mutt for details)
# - link:tabbed-file-to-vcf.txt: convert a tab-delimited file to vCards, a
# (small but) complete application contributed by Dane G. Avilla, thanks!
# - link:vcf-to-ics.txt: example of how to create calendars of birthdays from vCards
# - link:vcf-dump.txt: utility for dumping contents of .vcf files
class Vcard < DirectoryInfo
# Represents the value of an ADR field.
#
# #location, #preferred, and #delivery indicate information about how the
# address is to be used, the other attributes are parts of the address.
#
# Using values other than those defined for #location or #delivery is
# unlikely to be portable, or even conformant.
#
# All attributes are optional. #location and #delivery can be set to arrays
# of strings.
class Address
# post office box (String)
attr_accessor :pobox
# seldom used, its not clear what it is for (String)
attr_accessor :extended
# street address (String)
attr_accessor :street
# usually the city (String)
attr_accessor :locality
# usually the province or state (String)
attr_accessor :region
# postal code (String)
attr_accessor :postalcode
# country name (String)
attr_accessor :country
# home, work (Array of String): the location referred to by the address
attr_accessor :location
# true, false (boolean): where this is the preferred address (for this location)
attr_accessor :preferred
# postal, parcel, dom (domestic), intl (international) (Array of String): delivery
# type of this address
attr_accessor :delivery
# nonstandard types, their meaning is undefined (Array of String). These
# might be found during decoding, but shouldn't be set during encoding.
attr_reader :nonstandard
# Used to simplify some long and tedious code. These symbols are in the
# order required for the ADR field structured TEXT value, the order
# cannot be changed.
@@adr_parts = [
:@pobox,
:@extended,
:@street,
:@locality,
:@region,
:@postalcode,
:@country,
]
# TODO
# - #location?
# - #delivery?
def initialize #:nodoc:
# TODO - Add #label to support LABEL. Try to find LABEL
# in either same group, or with sam params.
@@adr_parts.each do |part|
instance_variable_set(part, '')
end
@location = []
@preferred = false
@delivery = []
@nonstandard = []
end
def encode #:nodoc:
parts = @@adr_parts.map do |part|
instance_variable_get(part)
end
value = Vpim.encode_text_list(parts, ";")
params = [ @location, @delivery, @nonstandard ]
params << 'pref' if @preferred
params = params.flatten.compact.map { |s| s.to_str.downcase }.uniq
paramshash = {}
paramshash['TYPE'] = params if params.first
Vpim::DirectoryInfo::Field.create( 'ADR', value, paramshash)
end
def Address.decode(card, field) #:nodoc:
adr = new
parts = Vpim.decode_text_list(field.value_raw, ';')
@@adr_parts.each_with_index do |part,i|
adr.instance_variable_set(part, parts[i] || '')
end
params = field.pvalues('TYPE')
if params
params.each do |p|
p.downcase!
case p
when 'home', 'work'
adr.location << p
when 'postal', 'parcel', 'dom', 'intl'
adr.delivery << p
when 'pref'
adr.preferred = true
else
adr.nonstandard << p
end
end
# Strip duplicates
[ adr.location, adr.delivery, adr.nonstandard ].each do |a|
a.uniq!
end
end
adr
end
end
# Represents the value of an EMAIL field.
class Email < String
# true, false (boolean): whether this is the preferred email address
attr_accessor :preferred
# internet, x400 (String): the email address format, rarely specified
# since the default is 'internet'
attr_accessor :format
# home, work (Array of String): the location referred to by the address. The
# inclusion of location parameters in a vCard seems to be non-conformant,
# strictly speaking, but also seems to be widespread.
attr_accessor :location
# nonstandard types, their meaning is undefined (Array of String). These
# might be found during decoding, but shouldn't be set during encoding.
attr_reader :nonstandard
def initialize(email='') #:nodoc:
@preferred = false
@format = 'internet'
@location = []
@nonstandard = []
super(email)
end
def inspect #:nodoc:
s = "#<#{self.class.to_s}: #{to_str.inspect}"
s << ", pref" if preferred
s << ", #{format}" if format != 'internet'
s << ", " << @location.join(", ") if @location.first
s << ", #{@nonstandard.join(", ")}" if @nonstandard.first
s
end
def encode #:nodoc:
value = to_str.strip
if value.length < 1
raise InvalidEncodingError, "EMAIL must have a value"
end
params = [ @location, @nonstandard ]
params << @format if @format != 'internet'
params << 'pref' if @preferred
params = params.flatten.compact.map { |s| s.to_str.downcase }.uniq
paramshash = {}
paramshash['TYPE'] = params if params.first
Vpim::DirectoryInfo::Field.create( 'EMAIL', value, paramshash)
end
def Email.decode(field) #:nodoc:
value = field.to_text.strip
if value.length < 1
raise InvalidEncodingError, "EMAIL must have a value"
end
eml = Email.new(value)
params = field.pvalues('TYPE')
if params
params.each do |p|
p.downcase!
case p
when 'home', 'work'
eml.location << p
when 'pref'
eml.preferred = true
when 'x400', 'internet'
eml.format = p
else
eml.nonstandard << p
end
end
# Strip duplicates
[ eml.location, eml.nonstandard ].each do |a|
a.uniq!
end
end
eml
end
end
# Represents the value of a TEL field.
#
# The value is supposed to be a "X.500 Telephone Number" according to RFC
# 2426, but that standard is not freely available. Otherwise, anything that
# looks like a phone number should be OK.
class Telephone < String
# true, false (boolean): whether this is the preferred email address
attr_accessor :preferred
# home, work, cell, car, pager (Array of String): the location
# of the device
attr_accessor :location
# voice, fax, video, msg, bbs, modem, isdn, pcs (Array of String): the
# capabilities of the device
attr_accessor :capability
# nonstandard types, their meaning is undefined (Array of String). These
# might be found during decoding, but shouldn't be set during encoding.
attr_reader :nonstandard
def initialize(telephone='') #:nodoc:
@preferred = false
@location = []
@capability = []
@nonstandard = []
super(telephone)
end
def inspect #:nodoc:
s = "#<#{self.class.to_s}: #{to_str.inspect}"
s << ", pref" if preferred
s << ", " << @location.join(", ") if @location.first
s << ", " << @capability.join(", ") if @capability.first
s << ", #{@nonstandard.join(", ")}" if @nonstandard.first
s
end
def encode #:nodoc:
value = to_str.strip
if value.length < 1
raise InvalidEncodingError, "TEL must have a value"
end
params = [ @location, @capability, @nonstandard ]
params << 'pref' if @preferred
params = params.flatten.compact.map { |s| s.to_str.downcase }.uniq
paramshash = {}
paramshash['TYPE'] = params if params.first
Vpim::DirectoryInfo::Field.create( 'TEL', value, paramshash)
end
def Telephone.decode(field) #:nodoc:
value = field.to_text.strip
if value.length < 1
raise InvalidEncodingError, "TEL must have a value"
end
tel = Telephone.new(value)
params = field.pvalues('TYPE')
if params
params.each do |p|
p.downcase!
case p
when 'home', 'work', 'cell', 'car', 'pager'
tel.location << p
when 'voice', 'fax', 'video', 'msg', 'bbs', 'modem', 'isdn', 'pcs'
tel.capability << p
when 'pref'
tel.preferred = true
else
tel.nonstandard << p
end
end
# Strip duplicates
[ tel.location, tel.capability, tel.nonstandard ].each do |a|
a.uniq!
end
end
tel
end
end
# The name from a vCard, including all the components of the N: and FN:
# fields.
class Name
# family name, from N
attr_accessor :family
# given name, from N
attr_accessor :given
# additional names, from N
attr_accessor :additional
# such as "Ms." or "Dr.", from N
attr_accessor :prefix
# such as "BFA", from N
attr_accessor :suffix
# full name, the FN field. FN is a formatted version of the N field,
# intended to be in a form more aligned with the cultural conventions of
# the vCard owner than +formatted+ is.
attr_accessor :fullname
# all the components of N formtted as "#{prefix} #{given} #{additional} #{family}, #{suffix}"
attr_reader :formatted
# Override the attr reader to make it dynamic
remove_method :formatted
def formatted #:nodoc:
f = [ @prefix, @given, @additional, @family ].map{|i| i == '' ? nil : i.strip}.compact.join(' ')
if @suffix != ''
f << ', ' << @suffix
end
f
end
def initialize(n='', fn='') #:nodoc:
n = Vpim.decode_text_list(n, ';') do |item|
item.strip
end
@family = n[0] || ""
@given = n[1] || ""
@additional = n[2] || ""
@prefix = n[3] || ""
@suffix = n[4] || ""
# FIXME - make calls to #fullname fail if fn is nil
@fullname = (fn || "").strip
end
def encode #:nodoc:
Vpim::DirectoryInfo::Field.create('N',
Vpim.encode_text_list([ @family, @given, @additional, @prefix, @suffix ].map{|n| n.strip}, ';')
)
end
def encode_fn #:nodoc:
fn = @fullname.strip
if @fullname.length == 0
fn = formatted
end
Vpim::DirectoryInfo::Field.create('FN', fn)
end
end
def decode_invisible(field) #:nodoc:
nil
end
def decode_default(field) #:nodoc:
Line.new( field.group, field.name, field.value )
end
def decode_version(field) #:nodoc:
Line.new( field.group, field.name, (field.value.to_f * 10).to_i )
end
def decode_text(field) #:nodoc:
Line.new( field.group, field.name, Vpim.decode_text(field.value_raw) )
end
def decode_n(field) #:nodoc:
Line.new( field.group, field.name, Name.new(field.value, self['FN']).freeze )
end
def decode_date_or_datetime(field) #:nodoc:
date = nil
begin
date = Vpim.decode_date_to_date(field.value_raw)
rescue Vpim::InvalidEncodingError
date = Vpim.decode_date_time_to_datetime(field.value_raw)
end
Line.new( field.group, field.name, date )
end
def decode_bday(field) #:nodoc:
begin
return decode_date_or_datetime(field)
rescue Vpim::InvalidEncodingError
# Hack around BDAY dates hat are correct in the month and day, but have
# some kind of garbage in the year.
if field.value =~ /^\s*(\d+)-(\d+)-(\d+)\s*$/
y = $1.to_i
m = $2.to_i
d = $3.to_i
if(y < 1900)
y = Time.now.year
end
Line.new( field.group, field.name, Date.new(y, m, d) )
else
raise
end
end
end
def decode_geo(field) #:nodoc:
geo = Vpim.decode_list(field.value_raw, ';') do |item| item.to_f end
Line.new( field.group, field.name, geo )
end
def decode_address(field) #:nodoc:
Line.new( field.group, field.name, Address.decode(self, field) )
end
def decode_email(field) #:nodoc:
Line.new( field.group, field.name, Email.decode(field) )
end
def decode_telephone(field) #:nodoc:
Line.new( field.group, field.name, Telephone.decode(field) )
end
def decode_list_of_text(field) #:nodoc:
Line.new( field.group, field.name,
Vpim.decode_text_list(field.value_raw).select{|t| t.length > 0}.uniq
)
end
def decode_structured_text(field) #:nodoc:
Line.new( field.group, field.name, Vpim.decode_text_list(field.value_raw, ';') )
end
def decode_uri(field) #:nodoc:
Line.new( field.group, field.name, Attachment::Uri.new(field.value, nil) )
end
def decode_agent(field) #:nodoc:
case field.kind
when 'text'
decode_text(field)
when 'uri'
decode_uri(field)
when 'vcard', nil
Line.new( field.group, field.name, Vcard.decode(Vpim.decode_text(field.value_raw)).first )
else
raise InvalidEncodingError, "AGENT type #{field.kind} is not allowed"
end
end
def decode_attachment(field) #:nodoc:
Line.new( field.group, field.name, Attachment.decode(field, 'binary', 'TYPE') )
end
@@decode = {
'BEGIN' => :decode_invisible, # Don't return delimiter
'END' => :decode_invisible, # Don't return delimiter
'FN' => :decode_invisible, # Returned as part of N.
'ADR' => :decode_address,
'AGENT' => :decode_agent,
'BDAY' => :decode_bday,
'CATEGORIES' => :decode_list_of_text,
'EMAIL' => :decode_email,
'GEO' => :decode_geo,
'KEY' => :decode_attachment,
'LOGO' => :decode_attachment,
'MAILER' => :decode_text,
'N' => :decode_n,
'NAME' => :decode_text,
'NICKNAME' => :decode_list_of_text,
'NOTE' => :decode_text,
'ORG' => :decode_structured_text,
'PHOTO' => :decode_attachment,
'PRODID' => :decode_text,
'PROFILE' => :decode_text,
'REV' => :decode_date_or_datetime,
'ROLE' => :decode_text,
'SOUND' => :decode_attachment,
'SOURCE' => :decode_text,
'TEL' => :decode_telephone,
'TITLE' => :decode_text,
'UID' => :decode_text,
'URL' => :decode_uri,
'VERSION' => :decode_version,
}
@@decode.default = :decode_default
# Cache of decoded lines/fields, so we don't have to decode a field more than once.
attr_reader :cache #:nodoc:
# An entry in a vCard. The #value object's type varies with the kind of
# line (the #name), and on how the line was encoded. The objects returned
# for a specific kind of line are often extended so that they support a
# common set of methods. The goal is to allow all types of objects for a
# kind of line to be treated with some uniformity, but still allow specific
# handling for the various value types if desired.
#
# See the specific methods for details.
class Line
attr_reader :group
attr_reader :name
attr_reader :value
def initialize(group, name, value) #:nodoc:
@group, @name, @value = (group||''), name.to_str, value
end
def self.decode(decode, card, field) #:nodoc:
card.cache[field] || (card.cache[field] = card.send(decode[field.name], field))
end
end
#@lines = {} FIXME - dead code
# Return line for a field
def f2l(field) #:nodoc:
begin
Line.decode(@@decode, self, field)
rescue InvalidEncodingError
# Skip invalidly encoded fields.
end
end
# With no block, returns an Array of Line. If +name+ is specified, the
# Array will only contain the +Line+s with that +name+. The Array may be
# empty.
#
# If a block is given, each Line will be yielded instead of being returned
# in an Array.
def lines(name=nil) #:yield: Line
# FIXME - this would be much easier if #lines was #each, and there was a
# different #lines that returned an Enumerator that used #each
unless block_given?
map do |f|
if( !name || f.name?(name) )
f2l(f)
else
nil
end
end.compact
else
each do |f|
if( !name || f.name?(name) )
line = f2l(f)
if line
yield line
end
end
end
self
end
end
private_class_method :new
def initialize(fields, profile) #:nodoc:
@cache = {}
super(fields, profile)
end
# Create a vCard 3.0 object with the minimum required fields, plus any
# +fields+ you want in the card (they can also be added later).
def Vcard.create(fields = [] )
fields.unshift Field.create('VERSION', "3.0")
super(fields, 'VCARD')
end
# Decode a collection of vCards into an array of Vcard objects.
#
# +card+ can be either a String or an IO object.
#
# Since vCards are self-delimited (by a BEGIN:vCard and an END:vCard),
# multiple vCards can be concatenated into a single directory info object.
# They may or may not be related. For example, AddressBook.app (the OS X
# contact manager) will export multiple selected cards in this format.
#
# Input data will be converted from unicode if it is detected. The heuristic
# is based on the first bytes in the string:
# - 0xEF 0xBB 0xBF: UTF-8 with a BOM, the BOM is stripped
# - 0xFE 0xFF: UTF-16 with a BOM (big-endian), the BOM is stripped and string
# is converted to UTF-8
# - 0xFF 0xFE: UTF-16 with a BOM (little-endian), the BOM is stripped and string
# is converted to UTF-8
# - 0x00 'B' or 0x00 'b': UTF-16 (big-endian), the string is converted to UTF-8
# - 'B' 0x00 or 'b' 0x00: UTF-16 (little-endian), the string is converted to UTF-8
#
# If you know that you have only one vCard, then you can decode that
# single vCard by doing something like:
#
# vcard = Vcard.decode(card_data).first
#
# Note: Should the import encoding be remembered, so that it can be reencoded in
# the same format?
def Vcard.decode(card)
if card.respond_to? :to_str
string = card.to_str
elsif card.respond_to? :read
string = card.read(nil)
else
raise ArgumentError, "Vcard.decode cannot be called with a #{card.type}"
end
case string
when /^\xEF\xBB\xBF/
string = string.sub("\xEF\xBB\xBF", '')
when /^\xFE\xFF/
arr = string.unpack('n*')
arr.shift
string = arr.pack('U*')
when /^\xFF\xFE/
arr = string.unpack('v*')
arr.shift
string = arr.pack('U*')
when /^\x00B/i
string = string.unpack('n*').pack('U*')
when /^B\x00/i
string = string.unpack('v*').pack('U*')
end
entities = Vpim.expand(Vpim.decode(string))
# Since all vCards must have a begin/end, the top-level should consist
# entirely of entities/arrays, even if its a single vCard.
if entities.detect { |e| ! e.kind_of? Array }
raise "Not a valid vCard"
end
vcards = []
for e in entities
vcards.push(new(e.flatten, 'VCARD'))
end
vcards
end
# The value of the field named +name+, optionally limited to fields of
# type +type+. If no match is found, nil is returned, if multiple matches
# are found, the first match to have one of its type values be 'PREF'
# (preferred) is returned, otherwise the first match is returned.
#
# FIXME - this will become an alias for #value.
def [](name, type=nil)
fields = enum_by_name(name).find_all { |f| type == nil || f.type?(type) }
valued = fields.select { |f| f.value != '' }
if valued.first
fields = valued
end
# limit to preferred, if possible
pref = fields.select { |f| f.pref? }
if pref.first
fields = pref
end
fields.first ? fields.first.value : nil
end
# Return the Line#value for a specific +name+, and optionally for a
# specific +type+.
#
# If no line with the +name+ (and, optionally, +type+) exists, nil is
# returned.
#
# If multiple lines exist, the order of preference is:
# - lines with values over lines without
# - lines with a type of 'pref' over lines without
# If multiple lines are equally preferred, then the first line will be
# returned.
#
# This is most useful when looking for a line that can not occur multiple
# times, or when the line can occur multiple times, and you want to pick
# the first preferred line of a specific type. See #values if you need to
# access all the lines.
#
# Note that the +type+ field parameter is used for different purposes by
# the various kinds of vCard lines, but for the addressing lines (ADR,
# LABEL, TEL, EMAIL) it is has a reasonably consistent usage. Each
# addressing line can occur multiple times, and a +type+ of 'pref'
# indicates that a particular line is the preferred line. Other +type+
# values tend to indicate some information about the location ('home',
# 'work', ...) or some detail about the address ('cell', 'fax', 'voice',
# ...). See the methods for the specific types of line for information
# about supported types and their meaning.
def value(name, type = nil)
v = nil
fields = enum_by_name(name).find_all { |f| type == nil || f.type?(type) }
valued = fields.select { |f| f.value != '' }
if valued.first
fields = valued
end
pref = fields.select { |f| f.pref? }
if pref.first
fields = pref
end
if fields.first
line = begin
Line.decode(@@decode, self, fields.first)
rescue Vpim::InvalidEncodingError
end
if line
return line.value
end
end
nil
end
# A variant of #lines that only iterates over specific Line names. Since
# the name is known, only the Line#value is returned or yielded.
def values(name)
unless block_given?
lines(name).map { |line| line.value }
else
lines(name) { |line| yield line.value }
end
end
# The first ADR value of type +type+, a Address. Any of the location or
# delivery attributes of Address can be used as +type+. A wrapper around
# #value('ADR', +type+).
def address(type=nil)
value('ADR', type)
end
# The ADR values, an array of Address. If a block is given, the values are
# yielded. A wrapper around #values('ADR').
def addresses #:yield:address
values('ADR')
end
# The AGENT values. Each AGENT value is either a String, a Uri, or a Vcard.
# If a block is given, the values are yielded. A wrapper around
# #values('AGENT').
def agents #:yield:agent
values('AGENT')
end
# The BDAY value as either a Date or a DateTime, or nil if there is none.
#
# If the BDAY value is invalidly formatted, a feeble heuristic is applied
# to find the month and year, and return a Date in the current year.
def birthday
value('BDAY')
end
# The CATEGORIES values, an array of String. A wrapper around
# #value('CATEGORIES').
def categories
value('CATEGORIES')
end
# The first EMAIL value of type +type+, a Email. Any of the location
# attributes of Email can be used as +type+. A wrapper around
# #value('EMAIL', +type+).
def email(type=nil)
value('EMAIL', type)
end
# The EMAIL values, an array of Email. If a block is given, the values are
# yielded. A wrapper around #values('EMAIL').
def emails #:yield:email
values('EMAIL')
end
# The GEO value, an Array of two Floats, +[ latitude, longitude]+. North
# of the equator is positive latitude, east of the meridian is positive
# longitude. See RFC2445 for more info, there are lots of special cases
# and RFC2445's description is more complete thant RFC2426.
def geo
value('GEO')
end
# Return an Array of KEY Line#value, or yield each Line#value if a block
# is given. A wrapper around #values('KEY').
#
# KEY is a public key or authentication certificate associated with the
# object that the vCard represents. It is not commonly used, but could
# contain a X.509 or PGP certificate.
#
# See Attachment for a description of the value.
def keys(&proc) #:yield: Line.value
values('KEY', &proc)
end
# Return an Array of LOGO Line#value, or yield each Line#value if a block
# is given. A wrapper around #values('LOGO').
#
# LOGO is a graphic image of a logo associated with the object the vCard
# represents. Its not common, but would probably be equivalent to the logo
# on a printed card.
#
# See Attachment for a description of the value.
def logos(&proc) #:yield: Line.value
values('LOGO', &proc)
end
## MAILER
# The N and FN as a Name object.
#
# N is required for a vCards, this raises InvalidEncodingError if
# there is no N so it cannot return nil.
def name
value('N') || raise(Vpim::InvalidEncodingError, "Missing mandatory N field")
end
# The first NICKNAME value, nil if there are none.
def nickname
v = value('NICKNAME')
v = v.first if v
v
end
# The NICKNAME values, an array of String. The array may be empty.
def nicknames
values('NICKNAME').flatten.uniq
end
# The NOTE value, a String. A wrapper around #value('NOTE').
def note
value('NOTE')
end
# The ORG value, an Array of String. The first string is the organization,
# subsequent strings are departments within the organization. A wrapper
# around #value('ORG').
def org
value('ORG')
end
# Return an Array of PHOTO Line#value, or yield each Line#value if a block
# is given. A wrapper around #values('PHOTO').
#
# PHOTO is an image or photograph information that annotates some aspect of
# the object the vCard represents. Commonly there is one PHOTO, and it is a
# photo of the person identified by the vCard.
#
# See Attachment for a description of the value.
def photos(&proc) #:yield: Line.value
values('PHOTO', &proc)
end
## PRODID
## PROFILE
## REV
## ROLE
# Return an Array of SOUND Line#value, or yield each Line#value if a block
# is given. A wrapper around #values('SOUND').
#
# SOUND is digital sound content information that annotates some aspect of
# the vCard. By default this type is used to specify the proper
# pronunciation of the name associated with the vCard. It is not commonly
# used. Also, note that there is no mechanism available to specify that the
# SOUND is being used for anything other than the default.
#
# See Attachment for a description of the value.
def sounds(&proc) #:yield: Line.value
values('SOUND', &proc)
end
## SOURCE
# The first TEL value of type +type+, a Telephone. Any of the location or
# capability attributes of Telephone can be used as +type+. A wrapper around
# #value('TEL', +type+).
def telephone(type=nil)
value('TEL', type)
end
# The TEL values, an array of Telephone. If a block is given, the values are
# yielded. A wrapper around #values('TEL').
def telephones #:yield:tel
values('TEL')
end
# The TITLE value, a text string specifying the job title, functional
# position, or function of the object the card represents. A wrapper around
# #value('TITLE').
def title
value('TITLE')
end
## UID
# The URL value, a Attachment::Uri. A wrapper around #value('URL').
def url
value('URL')
end
# The URL values, an Attachment::Uri. A wrapper around #values('URL').
def urls
values('URL')
end
# The VERSION multiplied by 10 as an Integer. For example, a VERSION:2.1
# vCard would have a version of 21, and a VERSION:3.0 vCard would have a
# version of 30.
#
# VERSION is required for a vCard, this raises InvalidEncodingError if
# there is no VERSION so it cannot return nil.
def version
v = value('VERSION')
unless v
raise Vpim::InvalidEncodingError, 'Invalid vCard - it has no version field!'
end
v
end
# Make changes to a vCard.
#
# Yields a Vpim::Vcard::Maker that can be used to modify this vCard.
def make #:yield: maker
Vpim::Vcard::Maker.make2(self) do |maker|
yield maker
end
end
# Delete +line+ if block yields true.
def delete_if #:nodoc: :yield: line
# Do in two steps to not mess up progress through the enumerator.
rm = []
each do |f|
line = f2l(f)
if line && yield(line)
rm << f
# Hack - because we treat N and FN as one field
if f.name? 'N'
rm << field('FN')
end
end
end
rm.each do |f|
@fields.delete( f )
@cache.delete( f )
end
end
# A class to make and make changes to vCards.
#
# It can be used to create completely new vCards using Vcard#make2.
#
# Its is also yielded from Vpim::Vcard#make, in which case it allows a kind
# of transactional approach to changing vCards, so their values can be
# validated after any changes have been made.
#
# Examples:
# - link:ex_mkvcard.txt: example of creating a vCard
# - link:ex_cpvcard.txt: example of copying and them modifying a vCard
# - link:ex_mkv21vcard.txt: example of creating version 2.1 vCard
# - link:ex_mkyourown.txt: example of adding support for new fields to Vcard::Maker
class Maker
# Make a vCard.
#
# Yields +maker+, a Vpim::Vcard::Maker which allows fields to be added to
# +card+, and returns +card+, a Vpim::Vcard.
#
# If +card+ is nil or not provided a new Vpim::Vcard is created and the
# fields are added to it.
#
# Defaults:
# - vCards must have both an N and an FN field, #make2 will fail if there
# is no N field in the +card+ when your block is finished adding fields.
# - If there is an N field, but no FN field, FN will be set from the
# information in N, see Vcard::Name#preformatted for more information.
# - vCards must have a VERSION field. If one does not exist when your block is
# is finished it will be set to 3.0.
def self.make2(card = Vpim::Vcard.create, &block) # :yields: maker
new(nil, card).make(&block)
end
# Deprecated, use #make2.
#
# If set, the FN field will be set to +full_name+. Otherwise, FN will
# be set from the values in #name.
def self.make(full_name = nil, &block) # :yields: maker
new(full_name, Vpim::Vcard.create).make(&block)
end
def make # :nodoc:
yield self
unless @card['N']
raise Unencodeable, 'N field is mandatory'
end
fn = @card.field('FN')
if fn && fn.value.strip.length == 0
@card.delete(fn)
fn = nil
end
unless fn
@card << Vpim::DirectoryInfo::Field.create('FN', Vpim::Vcard::Name.new(@card['N'], '').formatted)
end
unless @card['VERSION']
@card << Vpim::DirectoryInfo::Field.create('VERSION', "3.0")
end
@card
end
private
def initialize(full_name, card) # :nodoc:
@card = card || Vpim::Vcard::create
if full_name
@card << Vpim::DirectoryInfo::Field.create('FN', full_name.strip )
end
end
public
# Deprecated, see #name.
#
# Use
# maker.name do |n| n.fullname = "foo" end
# to set just fullname, or set the other fields to set fullname and the
# name.
def fullname=(fullname) #:nodoc: bacwards compat
if @card.field('FN')
raise Vpim::InvalidEncodingError, "Not allowed to add more than one FN field to a vCard."
end
@card << Vpim::DirectoryInfo::Field.create( 'FN', fullname );
end
# Set the name fields, N and FN.
#
# Attributes of +name+ are:
# - family: family name
# - given: given name
# - additional: additional names
# - prefix: such as "Ms." or "Dr."
# - suffix: such as "BFA", or "Sensei"
#
# +name+ is a Vcard::Name.
#
# All attributes are optional, though have all names be zero-length
# strings isn't really in the spirit of things. FN's value will be set
# to Vcard::Name#formatted if Vcard::Name#fullname isn't given a specific
# value.
#
# Warning: This is the only mandatory field.
def name #:yield:name
x = begin
@card.name.dup
rescue
Vpim::Vcard::Name.new
end
fn = x.fullname
yield x
x.fullname.strip!
delete_if do |line|
line.name == 'N'
end
@card << x.encode
@card << x.encode_fn
self
end
alias :add_name :name #:nodoc: backwards compatibility
# Add an address field, ADR. +address+ is a Vpim::Vcard::Address.
def add_addr # :yield: address
x = Vpim::Vcard::Address.new
yield x
@card << x.encode
self
end
# Add a telephone field, TEL. +tel+ is a Vpim::Vcard::Telephone.
#
# The block is optional, its only necessary if you want to specify
# the optional attributes.
def add_tel(number) # :yield: tel
x = Vpim::Vcard::Telephone.new(number)
if block_given?
yield x
end
@card << x.encode
self
end
# Add an email field, EMAIL. +email+ is a Vpim::Vcard::Email.
#
# The block is optional, its only necessary if you want to specify
# the optional attributes.
def add_email(email) # :yield: email
x = Vpim::Vcard::Email.new(email)
if block_given?
yield x
end
@card << x.encode
self
end
# Set the nickname field, NICKNAME.
#
# It can be set to a single String or an Array of String.
def nickname=(nickname)
delete_if { |l| l.name == 'NICKNAME' }
@card << Vpim::DirectoryInfo::Field.create( 'NICKNAME', nickname );
end
# Add a birthday field, BDAY.
#
# +birthday+ must be a time or date object.
#
# Warning: It may confuse both humans and software if you add multiple
# birthdays.
def birthday=(birthday)
if !birthday.respond_to? :month
raise ArgumentError, 'birthday must be a date or time object.'
end
delete_if { |l| l.name == 'BDAY' }
@card << Vpim::DirectoryInfo::Field.create( 'BDAY', birthday );
end
# Add a note field, NOTE. The +note+ String can contain newlines, they
# will be escaped.
def add_note(note)
@card << Vpim::DirectoryInfo::Field.create( 'NOTE', Vpim.encode_text(note) );
end
# Add an instant-messaging/point of presence address field, IMPP. The address
# is a URL, with the syntax depending on the protocol.
#
# Attributes of IMPP are:
# - preferred: true - set if this is the preferred address
# - location: home, work, mobile - location of address
# - purpose: personal,business - purpose of communications
#
# All attributes are optional, and so is the block.
#
# The URL syntaxes for the messaging schemes is fairly complicated, so I
# don't try and build the URLs here, maybe in the future. This forces
# the user to know the URL for their own address, hopefully not too much
# of a burden.
#
# IMPP is defined in draft-jennings-impp-vcard-04.txt. It refers to the
# URI scheme of a number of messaging protocols, but doesn't give
# references to all of them:
# - "xmpp" indicates to use XMPP, draft-saintandre-xmpp-uri-06.txt
# - "irc" or "ircs" indicates to use IRC, draft-butcher-irc-url-04.txt
# - "sip" indicates to use SIP/SIMPLE, RFC 3261
# - "im" or "pres" indicates to use a CPIM or CPP gateway, RFC 3860 and RFC 3859
# - "ymsgr" indicates to use yahoo
# - "msn" might indicate to use Microsoft messenger
# - "aim" indicates to use AOL
#
def add_impp(url) # :yield: impp
params = {}
if block_given?
x = Struct.new( :location, :preferred, :purpose ).new
yield x
x[:preferred] = 'PREF' if x[:preferred]
types = x.to_a.flatten.compact.map { |s| s.downcase }.uniq
params['TYPE'] = types if types.first
end
@card << Vpim::DirectoryInfo::Field.create( 'IMPP', url, params)
self
end
# Add an X-AIM account name where +xaim+ is an AIM screen name.
#
# I don't know if this is conventional, or supported by anything other
# than AddressBook.app, but an example is:
# X-AIM;type=HOME;type=pref:exampleaccount
#
# Attributes of X-AIM are:
# - preferred: true - set if this is the preferred address
# - location: home, work, mobile - location of address
#
# All attributes are optional, and so is the block.
def add_x_aim(xaim) # :yield: xaim
params = {}
if block_given?
x = Struct.new( :location, :preferred ).new
yield x
x[:preferred] = 'PREF' if x[:preferred]
types = x.to_a.flatten.compact.map { |s| s.downcase }.uniq
params['TYPE'] = types if types.first
end
@card << Vpim::DirectoryInfo::Field.create( 'X-AIM', xaim, params)
self
end
# Add a photo field, PHOTO.
#
# Attributes of PHOTO are:
# - image: set to image data to include inline
# - link: set to the URL of the image data
# - type: string identifying the image type, supposed to be an "IANA registered image format",
# or a non-registered image format (usually these start with an x-)
#
# An error will be raised if neither image or link is set, or if both image
# and link is set.
#
# Setting type is optional for a link image, because either the URL, the
# image file extension, or a HTTP Content-Type may specify the type. If
# it's not a link, setting type is mandatory, though it can be set to an
# empty string, ''
, if the type is unknown.
#
# TODO - I'm not sure about this API. I'm thinking maybe it should be
# #add_photo(image, type), and that I should detect when the image is a
# URL, and make type mandatory if it wasn't a URL.
def add_photo # :yield: photo
x = Struct.new(:image, :link, :type).new
yield x
if x[:image] && x[:link]
raise Vpim::InvalidEncodingError, 'Image is not allowed to be both inline and a link.'
end
value = x[:image] || x[:link]
if !value
raise Vpim::InvalidEncodingError, 'A image link or inline data must be provided.'
end
params = {}
# Don't set type to the empty string.
params['TYPE'] = x[:type] if( x[:type] && x[:type].length > 0 )
if x[:link]
params['VALUE'] = 'URI'
else # it's inline, base-64 encode it
params['ENCODING'] = :b64
if !x[:type]
raise Vpim::InvalidEncodingError, 'Inline image data must have it\'s type set.'
end
end
@card << Vpim::DirectoryInfo::Field.create( 'PHOTO', value, params )
self
end
# Set the title field, TITLE.
#
# It can be set to a single String.
def title=(title)
delete_if { |l| l.name == 'TITLE' }
@card << Vpim::DirectoryInfo::Field.create( 'TITLE', title );
end
# for adding a job title
def add_title(value)
@card << Vpim::DirectoryInfo::Field.create( 'TITLE', value.to_str );
end
# Set the org field, ORG.
#
# It can be set to a single String or an Array of String.
def org=(org)
delete_if { |l| l.name == 'ORG' }
@card << Vpim::DirectoryInfo::Field.create( 'ORG', org );
end
# for adding a company
def add_org(value)
@card << Vpim::DirectoryInfo::Field.create( 'ORG', value.to_str );
end
# Add a URL field, URL.
# Microsoft Outlook is not following the vCard 3.0 specs
def add_url(url)
params = {}
if block_given?
x = Struct.new( :location, :preferred, :purpose ).new
yield x
x[:preferred] = 'PREF' if x[:preferred]
types = x.to_a.flatten.compact.map { |s| s.downcase }.uniq
params['TYPE'] = types if types.first
end
@card << Vpim::DirectoryInfo::Field.create( 'URL', url.to_str, params)
self
end
# Add a Field, +field+.
def add_field(field)
fieldname = field.name.upcase
case
when [ 'BEGIN', 'END' ].include?(fieldname)
raise Vpim::InvalidEncodingError, "Not allowed to manually add #{field.name} to a vCard."
when [ 'VERSION', 'N', 'FN' ].include?(fieldname)
if @card.field(fieldname)
raise Vpim::InvalidEncodingError, "Not allowed to add more than one #{fieldname} to a vCard."
end
@card << field
else
@card << field
end
end
# for marking a vCard as a company
def set_as_company
@card << Vpim::DirectoryInfo::Field.create( 'X-ABShowAs', 'COMPANY' );
end
# Copy the fields from +card+ into self using #add_field. If a block is
# provided, each Field from +card+ is yielded. The block should return a
# Field to add, or nil. The Field doesn't have to be the one yielded,
# allowing the field to be copied and modified (see Field#copy) before adding, or
# not added at all if the block yields nil.
#
# The vCard fields BEGIN and END aren't copied, and VERSION, N, and FN are copied
# only if the card doesn't have them already.
def copy(card) # :yields: Field
card.each do |field|
fieldname = field.name.upcase
case
when [ 'BEGIN', 'END' ].include?(fieldname)
# Never copy these
when [ 'VERSION', 'N', 'FN' ].include?(fieldname) && @card.field(fieldname)
# Copy these only if they don't already exist.
else
if block_given?
field = yield field
end
if field
add_field(field)
end
end
end
end
# Delete +line+ if block yields true.
def delete_if #:yield: line
begin
@card.delete_if do |line|
yield line
end
rescue NoMethodError
# FIXME - this is a hideous hack, allowing a DirectoryInfo to
# be passed instead of a Vcard, and for it to almost work. Yuck.
end
end
end
end
end