module Icalendar class CalendarParser < Icalendar::Base # 1*(ALPHA / DIGIT / "=") NAME = '[-a-z0-9]+' # <"> <"> QSTR = '"[^"]*"' # * PTEXT = '[^";:,]*' # param-value = ptext / quoted-string PVALUE = "#{PTEXT}|#{QSTR}" # Contentline LINE = "(#{NAME})([^:]*)\:(.*)" # param = name "=" param-value *("," param-value) # Note: v2.1 allows a type or encoding param-value to appear without the type= # or the encoding=. This is hideous, but we try and support it, if there # is no "=", then $2 will be "", and we will treat it as a v2.1 param. PARAM = ";(#{NAME})(=?)((?:#{PVALUE})(?:,#{PVALUE})*)" # date = date-fullyear ["-"] date-month ["-"] date-mday # date-fullyear = 4 DIGIT # date-month = 2 DIGIT # date-mday = 2 DIGIT DATE = '(\d\d\d\d)-?(\d\d)-?(\d\d)' # time = time-hour [":"] time-minute [":"] time-second [time-secfrac] [time-zone] # time-hour = 2 DIGIT # time-minute = 2 DIGIT # time-second = 2 DIGIT # time-secfrac = "," 1*DIGIT # time-zone = "Z" / time-numzone # time-numzome = sign time-hour [":"] time-minute TIME = '(\d\d):?(\d\d):?(\d\d)(\.\d+)?(Z|[-+]\d\d:?\d\d)?' def initialize(src) @@logger.info("New Calendar Parser") # Define the next line method different depending on whether # this is a string or an IO object so we can be efficient about # parsing large files... # Just do the unfolding work in one shot if its a whole string if src.respond_to?(:split) unfolded = [] # Split into an array of lines, then unfold those into a new array src.split(/\r?\n/).each do |line| # 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 << unfolded.pop + line[1, line.size-1] elsif( line =~ /^$/ ) else unfolded << line end end @lines = unfolded @index = 0 # Now that we are unfolded we can just iterate through the array. # Dynamically define next line for a string. def next_line if @index == @lines.size return nil else line = @lines[@index] @index += 1 return line end end # If its a file we need to read and unfold on the go to save from reading # large amounts of data into memory. elsif src.respond_to?(:gets) @file = src @prev_line = src.gets if !@prev_line.nil? @prev_line.chomp! end # Dynamically define next line for an IO object def next_line line = @prev_line if line.nil? return nil end # Loop through until we get to a non-continuation line... loop do nextLine = @file.gets if !nextLine.nil? nextLine.chomp! end # If it's a continuation line, add it to the last. # If it's an empty line, drop it from the input. if( nextLine =~ /^[ \t]/ ) line << nextLine[1, nextLine.size] elsif( nextLine =~ /^$/ ) else @prev_line = nextLine break end end line end else raise ArgumentError, "CalendarParser.new cannot be called with a #{src.class} type!" end end # Parse the calendar into an object representation def parse calendars = [] # Outer loop for Calendar objects while (line = next_line) fields = parse_line(line) # Just iterate through until we find the beginning of a calendar object if fields[:name] == "BEGIN" and fields[:value] == "VCALENDAR" cal = parse_calendar calendars << cal end end calendars end # Parse a single VCALENDAR object # -- This should consist of the PRODID, VERSION, option METHOD & CALSCALE, # and then one or more calendar components: VEVENT, VTODO, VJOURNAL, # VFREEBUSY, VTIMEZONE def parse_calendar(component = Calendar.new) while (line = next_line) fields = parse_line(line) name = fields[:name] # Although properties are supposed to come before components, we should # be able to handle them in any order... if name == "END" break elsif name == "BEGIN" # New component case(fields[:value]) when "VEVENT" component.events << parse_calendar(Event.new) when "VTODO" component.todos << parse_calendar(Todo.new) when "VJOURNAL" component.journals << parse_calendar(Journal.new) when "VFREEBUSY" component.freebusys << parse_calendar(Freebusy.new) when "VTIMEZONE" component.timezones << parse_calendar(Timezone.new) when "VALARM" component.alarms << parse_calendar(Alarm.new) end else # If its not a component then it should be properties... # Just set the properties ourselves so that the parser can still # parse invalid files... @@logger.debug("Setting #{name} => #{fields[:value]}") component.properties[name] = fields[:value] if not fields[:params].empty? component.property_params[name] = fields[:params] end # This will generate the correctly formed calls to the dynamic method # handler. # component.send("#{name}=", fields[:value]) # component.send("#{name}_params=", fields[:params]) unless fields[:params].empty? end end component end def parse_line(line) unless line =~ %r{#{LINE}}i # Case insensitive match for a valid line raise "Invalid line in calendar string!" end name = $1.upcase # The case insensitive part is upcased for easier comparison... paramslist = $2 value = $3 params = {} # Collect the params, if any. if paramslist.size > 1 # v3.0 and v2.1 params paramslist.scan( %r{#{PARAM}}i ) do # param names are case-insensitive, and multi-valued pname = $1 pvals = $3 # v2.1 pvals have no '=' sign, figure out what kind of param it # is (either its a known encoding, or we treat it as a 'type' # param). if $2 == "" pvals = $1 case $1 when /quoted-printable/i pname = 'encoding' when /base64/i pname = 'encoding' else pname = 'type' end end unless params.key? pname params[pname] = [] end pvals.scan( %r{(#{PVALUE})} ) do if $1.size > 0 params[pname] << $1 end end end end {:name => name, :params => params, :value => value} end end end