lib/vcard.rb in vcard-0.1.1 vs lib/vcard.rb in vcard-0.2.0

- old
+ new

@@ -1,34 +1,310 @@ -=begin - Copyright (C) 2008 Sam Roberts +# 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 +# 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 +# LICENSE-VPIM.txt for details. -#:main:README -#:title:vPim - vCard and iCalendar support for Ruby -module Vpim - # Exception used to indicate that data being decoded is invalid, the message - # should describe what is invalid. - class InvalidEncodingError < StandardError; end +require "date" +require "open-uri" +require "stringio" - # Exception used to indicate that data being decoded is unsupported, the message - # should describe what is unsupported. - # - # If its unsupported, its likely because I didn't anticipate it being useful - # to support this, and it likely it could be supported on request. - class UnsupportedError < StandardError; end - - # Exception used to indicate that encoding failed, probably because the - # object would not result in validly encoded data. The message should - # describe what is unsupported. - class Unencodeable < StandardError; end -end - require "vcard/attachment" +require "vcard/bnf" require "vcard/dirinfo" require "vcard/enumerator" +require "vcard/errors" require "vcard/field" -require "vcard/rfc2425" require "vcard/vcard" + +module Vcard + # Split on \r\n or \n to get the lines, unfold continued lines (they + # start with " " or \t), and return the array of unfolded lines. + # + # This also supports the (invalid) encoding convention of allowing empty + # lines to be inserted for readability - it does this by dropping zero-length + # lines. + def self.unfold(card) #:nodoc: + unfolded = [] + + card.lines do |line| + line.chomp! + # If it's a continuation line, add it to the last. + # If it's an empty line, drop it from the input. + if( line =~ /^[ \t]/ ) + unfolded[-1] << line[1, line.size-1] + elsif( line =~ /^$/ ) + else + unfolded << line + end + end + + unfolded + end + + # Convert a +sep+-seperated list of values into an array of values. + def self.decode_list(value, sep = ",") # :nodoc: + list = [] + + value.split(sep).each do |item| + item.chomp!(sep) + list << yield(item) + end + list + end + + # Convert a RFC 2425 date into an array of [year, month, day]. + def self.decode_date(v) # :nodoc: + unless v =~ %r{^\s*#{Bnf::DATE}\s*$} + raise Vcard::InvalidEncodingError, "date not valid (#{v})" + end + [$1.to_i, $2.to_i, $3.to_i] + end + + # Convert a RFC 2425 date into a Date object. + def self.decode_date_to_date(v) + Date.new(*decode_date(v)) + end + + # Note in the following the RFC2425 allows yyyy-mm-ddThh:mm:ss, but RFC2445 + # does not. I choose to encode to the subset that is valid for both. + + # Encode a Date object as "yyyymmdd". + def self.encode_date(d) # :nodoc: + "%0.4d%0.2d%0.2d" % [ d.year, d.mon, d.day ] + end + + # Encode a Date object as "yyyymmdd". + def self.encode_time(d) # :nodoc: + "%0.4d%0.2d%0.2d" % [ d.year, d.mon, d.day ] + end + + # Encode a Time or DateTime object as "yyyymmddThhmmss" + def self.encode_date_time(d) # :nodoc: + "%0.4d%0.2d%0.2dT%0.2d%0.2d%0.2d" % [ d.year, d.mon, d.day, d.hour, d.min, d.sec ] + end + + # Convert a RFC 2425 time into an array of [hour,min,sec,secfrac,timezone] + def self.decode_time(v) # :nodoc: + unless match = %r{^\s*#{Bnf::TIME}\s*$}.match(v) + raise Vcard::InvalidEncodingError, "time '#{v}' not valid" + end + hour, min, sec, secfrac, tz = match.to_a[1..5] + + [hour.to_i, min.to_i, sec.to_i, secfrac ? secfrac.to_f : 0, tz] + end + + def self.array_datetime_to_time(dtarray) #:nodoc: + # We get [ year, month, day, hour, min, sec, usec, tz ] + begin + tz = (dtarray.pop == "Z") ? :gm : :local + Time.send(tz, *dtarray) + rescue ArgumentError => e + raise Vcard::InvalidEncodingError, "#{tz} #{e} (#{dtarray.join(', ')})" + end + end + + # Convert a RFC 2425 time into an array of Time objects. + def self.decode_time_to_time(v) # :nodoc: + array_datetime_to_time(decode_date_time(v)) + end + + # Convert a RFC 2425 date-time into an array of [year,mon,day,hour,min,sec,secfrac,timezone] + def self.decode_date_time(v) # :nodoc: + unless match = %r{^\s*#{Bnf::DATE}T#{Bnf::TIME}\s*$}.match(v) + raise Vcard::InvalidEncodingError, "date-time '#{v}' not valid" + end + year, month, day, hour, min, sec, secfrac, tz = match.to_a[1..8] + + [ + # date + year.to_i, month.to_i, day.to_i, + # time + hour.to_i, min.to_i, sec.to_i, secfrac ? secfrac.to_f : 0, tz + ] + end + + def self.decode_date_time_to_datetime(v) #:nodoc: + year, month, day, hour, min, sec = decode_date_time(v) + # TODO - DateTime understands timezones, so we could decode tz and use it. + DateTime.civil(year, month, day, hour, min, sec, 0) + end + + # decode_boolean + # + # float + # + # float_list + + # Convert an RFC2425 INTEGER value into an Integer + def self.decode_integer(v) # :nodoc: + unless %r{\s*#{Bnf::INTEGER}\s*}.match(v) + raise Vcard::InvalidEncodingError, "integer not valid (#{v})" + end + v.to_i + end + + # + # integer_list + + # Convert a RFC2425 date-list into an array of dates. + def self.decode_date_list(v) # :nodoc: + decode_list(v) do |date| + date.strip! + if date.length > 0 + decode_date(date) + end + end.compact + end + + # Convert a RFC 2425 time-list into an array of times. + def self.decode_time_list(v) # :nodoc: + decode_list(v) do |time| + time.strip! + if time.length > 0 + decode_time(time) + end + end.compact + end + + # Convert a RFC 2425 date-time-list into an array of date-times. + def self.decode_date_time_list(v) # :nodoc: + decode_list(v) do |datetime| + datetime.strip! + if datetime.length > 0 + decode_date_time(datetime) + end + end.compact + end + + # Convert RFC 2425 text into a String. + # \\ -> \ + # \n -> NL + # \N -> NL + # \, -> , + # \; -> ; + # + # I've seen double-quote escaped by iCal.app. Hmm. Ok, if you aren't supposed + # to escape anything but the above, everything else is ambiguous, so I'll + # just support it. + def self.decode_text(v) # :nodoc: + # FIXME - I think this should trim leading and trailing space + v.gsub(/\\(.)/) do + case $1 + when "n", "N" + "\n" + else + $1 + end + end + end + + def self.encode_text(v) #:nodoc: + v.to_str.gsub(/([\\,;\n])/) { $1 == "\n" ? "\\n" : "\\"+$1 } + end + + # v is an Array of String, or just a single String + def self.encode_text_list(v, sep = ",") #:nodoc: + begin + v.to_ary.map{ |t| encode_text(t) }.join(sep) + rescue + encode_text(v) + end + end + + # Convert a +sep+-seperated list of TEXT values into an array of values. + def self.decode_text_list(value, sep = ",") # :nodoc: + # Need to do in two stages, as best I can find. + list = value.scan(/([^#{sep}\\]*(?:\\.[^#{sep}\\]*)*)#{sep}/).map do |v| + decode_text(v.first) + end + if value.match(/([^#{sep}\\]*(?:\\.[^#{sep}\\]*)*)$/) + list << $1 + end + list + end + + # param-value = paramtext / quoted-string + # paramtext = *SAFE-CHAR + # quoted-string = DQUOTE *QSAFE-CHAR DQUOTE + def self.encode_paramtext(value) + case value + when %r{\A#{Bnf::SAFECHAR}*\z} + value + else + raise Vcard::Unencodable, "paramtext #{value.inspect}" + end + end + + def self.encode_paramvalue(value) + case value + when %r{\A#{Bnf::SAFECHAR}*\z} + value + when %r{\A#{Bnf::QSAFECHAR}*\z} + '"' + value + '"' + else + raise Vcard::Unencodable, "param-value #{value.inspect}" + end + end + + + # Unfold the lines in +card+, then return an array of one Field object per + # line. + def self.decode(card) #:nodoc: + unfold(card).collect { |line| DirectoryInfo::Field.decode(line) } + end + + + # Expand an array of fields into its syntactic entities. Each entity is a sequence + # of fields where the sequences is delimited by a BEGIN/END field. Since + # BEGIN/END delimited entities can be nested, we build a tree. Each entry in + # the array is either a Field or an array of entries (where each entry is + # either a Field, or an array of entries...). + def self.expand(src) #:nodoc: + # output array to expand the src to + dst = [] + # stack used to track our nesting level, as we see begin/end we start a + # new/finish the current entity, and push/pop that entity from the stack + current = [ dst ] + + for f in src + if f.name? "BEGIN" + e = [ f ] + + current.last.push(e) + current.push(e) + + elsif f.name? "END" + current.last.push(f) + + unless current.last.first.value? current.last.last.value + raise "BEGIN/END mismatch (#{current.last.first.value} != #{current.last.last.value})" + end + + current.pop + + else + current.last.push(f) + end + end + + dst + end + + # Split an array into an array of all the fields at the outer level, and + # an array of all the inner arrays of fields. Return the array [outer, + # inner]. + def self.outer_inner(fields) #:nodoc: + # TODO - use Enumerable#partition + # seperate into the outer-level fields, and the arrays of component + # fields + outer = [] + inner = [] + fields.each do |line| + case line + when Array; inner << line + else; outer << line + end + end + return outer, inner + end +end