class Tilia::VObject::FreeBusyGenerator

This class helps with generating FREEBUSY reports based on existing sets of objects.

It only looks at VEVENT and VFREEBUSY objects from the sourcedata, and generates a single VFREEBUSY object.

VFREEBUSY components are described in RFC5545, The rules for what should go in a single freebusy report is taken from RFC4791, section 7.10.

Attributes

base_object[W]

Sets the VCALENDAR object.

If this is set, it will not be generated for you. You are responsible for setting things like the METHOD, CALSCALE, VERSION, etc..

The VFREEBUSY object will be automatically added though.

@param [Document] vcalendar @return [void]

time_zone[W]

Sets the reference timezone for floating times.

@param [ActiveSupport::TimeZone] time_zone

@return [void]

Public Class Methods

new(start = nil, ending = nil, objects = nil, time_zone = nil) click to toggle source

Creates the generator.

Check the setTimeRange and setObjects methods for details about the arguments.

@param [Time] start @param [Time] end @param objects @param [ActiveSupport::TimeZone] time_zone

# File lib/tilia/v_object/free_busy_generator.rb, line 62
def initialize(start = nil, ending = nil, objects = nil, time_zone = nil)
  start = Time.zone.parse(Settings.min_date) unless start
  ending = Time.zone.parse(Settings.max_date) unless ending

  self.time_range = start..ending
  @objects = []

  self.objects = objects if objects
  time_zone = ActiveSupport::TimeZone.new('UTC') unless time_zone
  self.time_zone = time_zone
end

Public Instance Methods

objects=(objects) click to toggle source

Sets the input objects.

You must either specify a valendar object as a string, or as the parse Component. It's also possible to specify multiple objects as an array.

@param objects

@return [void]

# File lib/tilia/v_object/free_busy_generator.rb, line 102
def objects=(objects)
  objects = [objects] unless objects.is_a?(Array)

  @objects = []
  objects.each do |object|
    if object.is_a?(String)
      @objects << Reader.read(object)
    elsif object.is_a?(Component)
      @objects << object
    else
      fail ArgumentError, 'You can only pass strings or Component arguments to setObjects'
    end
  end
end
result() click to toggle source

Parses the input data and returns a correct VFREEBUSY object, wrapped in a VCALENDAR.

@return [Component]

# File lib/tilia/v_object/free_busy_generator.rb, line 141
def result
  fb_data = FreeBusyData.new(@start.to_i, @end.to_i)

  calculate_availability(fb_data, @vavailability) if @vavailability

  calculate_busy(fb_data, @objects)
  generate_free_busy_calendar(fb_data)
end
time_range=(range) click to toggle source

Sets the time range.

Any freebusy object falling outside of this time range will be ignored.

@param [Time] start @param [Time] end

@return [void]

# File lib/tilia/v_object/free_busy_generator.rb, line 125
def time_range=(range)
  @start = range.begin
  @end = range.end
end
v_availability=(vcalendar) click to toggle source

Sets a VAVAILABILITY document.

@param [Document] vcalendar @return [void]

# File lib/tilia/v_object/free_busy_generator.rb, line 89
def v_availability=(vcalendar)
  @vavailability = vcalendar
end

Protected Instance Methods

calculate_availability(fb_data, vavailability) click to toggle source

This method takes a VAVAILABILITY component and figures out all the available times.

@param [FreeBusyData] fb_data @param [VCalendar] vavailability @return [void]

# File lib/tilia/v_object/free_busy_generator.rb, line 158
def calculate_availability(fb_data, vavailability)
  vavail_comps = vavailability['VAVAILABILITY'].to_a
  vavail_comps.sort! do |a, b|
    # We need to order the components by priority. Priority 1
    # comes first, up until priority 9. Priority 0 comes after
    # priority 9. No priority implies priority 0.
    #
    # Yes, I'm serious.
    priority_a = a.key?('PRIORITY') ? a['PRIORITY'].value.to_i : 0
    priority_b = b.key?('PRIORITY') ? b['PRIORITY'].value.to_i : 0

    priority_a = 10 if priority_a == 0
    priority_b = 10 if priority_b == 0

    priority_a <=> priority_b
  end

  # Now we go over all the VAVAILABILITY components and figure if
  # there's any we don't need to consider.
  #
  # This is can be because of one of two reasons: either the
  # VAVAILABILITY component falls outside the time we are interested in,
  # or a different VAVAILABILITY component with a higher priority has
  # already completely covered the time-range.
  old = vavail_comps
  new = []

  old.each do |vavail|
    (comp_start, comp_end) = vavail.effective_start_end

    # We don't care about datetimes that are earlier or later than the
    # start and end of the freebusy report, so this gets normalized
    # first.
    comp_start = @start if comp_start.nil? || comp_start < @start
    comp_end = @end if comp_end.nil? || comp_end > @end

    # If the item fell out of the timerange, we can just skip it.
    next if comp_start > @end || comp_end < @start

    # Going through our existing list of components to see if there's
    # a higher priority component that already fully covers this one.
    skip = false
    new.each do |higher_vavail|
      (higher_start, higher_end) = higher_vavail.effective_start_end

      next unless (higher_start.nil? || higher_start < comp_start) && (higher_end.nil? || higher_end > comp_end)

      # Component is fully covered by a higher priority
      # component. We can skip this component.
      skip = true
      break
    end
    next if skip

    # We're keeping it!
    new << vavail
  end

  # Lastly, we need to traverse the remaining components and fill in the
  # freebusydata slots.
  #
  # We traverse the components in reverse, because we want the higher
  # priority components to override the lower ones.
  new.reverse_each do |vavail|
    busy_type = vavail.key?('BUSYTYPE') ? vavail['BUSYTYPE'].to_s.upcase : 'BUSY-UNAVAILABLE'
    (vavail_start, vavail_end) = vavail.effective_start_end

    # Making the component size no larger than the requested free-busy
    # report range.
    vavail_start = @start if !vavail_start || vavail_start < @start
    vavail_end = @end if !vavail_end || vavail_end > @end

    # Marking the entire time range of the VAVAILABILITY component as
    # busy.
    fb_data.add(
      vavail_start.to_i,
      vavail_end.to_i,
      busy_type
    )

    # Looping over the AVAILABLE components.
    next unless vavail.key?('AVAILABLE')

    vavail['AVAILABLE'].each do |available|
      (avail_start, avail_end) = available.effective_start_end
      fb_data.add(
        avail_start.to_i,
        avail_end.to_i,
        'FREE'
      )

      next unless available['RRULE']

      # Our favourite thing: recurrence!!
      rrule_iterator = Recur::RRuleIterator.new(
        available['RRULE'].value,
        avail_start
      )

      rrule_iterator.fast_forward(vavail_start)

      start_end_diff = avail_end - avail_start

      while rrule_iterator.valid
        recur_start = rrule_iterator.current
        recur_end = recur_start + start_end_diff

        if recur_start > vavail_end
          # We're beyond the legal timerange.
          break
        end

        if recur_end > vavail_end
          # Truncating the end if it exceeds the
          # VAVAILABILITY end.
          recur_end = vavail_end
        end

        fb_data.add(
          recur_start.to_i,
          recur_end.to_i,
          'FREE'
        )

        rrule_iterator.next
      end
    end
  end
end
calculate_busy(fb_data, objects) click to toggle source

This method takes an array of iCalendar objects and applies its busy times on fbData.

@param [FreeBusyData] fb_data @param [VCalendar objects

# File lib/tilia/v_object/free_busy_generator.rb, line 293
def calculate_busy(fb_data, objects)
  objects.each_with_index do |object, key|
    object.base_components.each do |component|
      case component.name
      when 'VEVENT'
        skip = false
        fb_type = 'BUSY'
        if component.key?('TRANSP') && component['TRANSP'].to_s.upcase == 'TRANSPARENT'
          skip = true
        end
        if component.key?('STATUS')
          status = component['STATUS'].to_s.upcase
          if status == 'CANCELLED'
            skip = true
          elsif status == 'TENTATIVE'
            fb_type = 'BUSY-TENTATIVE'
          end
        end

        unless skip
          times = []

          if component.key?('RRULE')
            begin
              iterator = Recur::EventIterator.new(object, component['UID'].to_s, @time_zone)
            rescue Recur::NoInstancesException
              # This event is recurring, but it doesn't have a single
              # instance. We are skipping this event from the output
              # entirely.
              @objects.delete_at(key)
              next
            end

            iterator.fast_forward(@start) if @start

            max_recurrences = Settings.max_recurrences

            while iterator.valid && max_recurrences > 0
              max_recurrences -= 1

              start_time = iterator.dt_start
              break if @end && start_time > @end
              times << [
                iterator.dt_start,
                iterator.dt_end
              ]

              iterator.next
            end
          else
            start_time = component['DTSTART'].date_time(@time_zone)
            skip = true if @end && start_time > @end

            end_time = nil
            if component.key?('DTEND')
              end_time = component['DTEND'].date_time(@time_zone)
            elsif component.key?('DURATION')
              duration = DateTimeParser.parse_duration(component['DURATION'].to_s)
              end_time = start_time + duration
            elsif !component['DTSTART'].time?
              end_time = start_time + 1.day
            else
              # The event had no duration (0 seconds)
              skip = true
            end

            times << [start_time, end_time] unless skip
          end

          times.each do |time|
            break if @end && time[0] > @end
            break if @start && time[1] < @start

            fb_data.add(
              time[0].to_i,
              time[1].to_i,
              fb_type
            )
          end
        end
      when 'VFREEBUSY'
        component['FREEBUSY'].each do |freebusy|
          fb_type = freebusy.key?('FBTYPE') ? freebusy['FBTYPE'].to_s.upcase : 'BUSY'

          # Skipping intervals marked as 'free'
          next if fb_type == 'FREE'

          values = freebusy.to_s.split(',')
          values.each do |value|
            (start_time, end_time) = value.split('/')
            start_time = DateTimeParser.parse_date_time(start_time)

            if end_time[0] == 'P' || end_time[0..1] == '-P'
              duration = DateTimeParser.parse_duration(end_time)
              end_time = start_time + duration
            else
              end_time = DateTimeParser.parse_date_time(end_time)
            end

            next if @start && @start > end_time
            next if @end && @end < start_time

            fb_data.add(
              start_time.to_i,
              end_time.to_i,
              fb_type
            )
          end
        end
      end
    end
  end
end
generate_free_busy_calendar(fb_data) click to toggle source

This method takes a FreeBusyData object and generates the VCALENDAR object associated with it.

@return [VCalendar]

# File lib/tilia/v_object/free_busy_generator.rb, line 411
def generate_free_busy_calendar(fb_data)
  if @base_object
    calendar = @base_object
  else
    calendar = Component::VCalendar.new
  end

  vfreebusy = calendar.create_component('VFREEBUSY')
  calendar.add(vfreebusy)

  if @start
    dtstart = calendar.create_property('DTSTART')
    dtstart.date_time = @start
    vfreebusy.add(dtstart)
  end
  if @end
    dtend = calendar.create_property('DTEND')
    dtend.date_time = @end
    vfreebusy.add(dtend)
  end

  tz = ActiveSupport::TimeZone.new('UTC')
  dtstamp = calendar.create_property('DTSTAMP')
  dtstamp.date_time = tz.now
  vfreebusy.add(dtstamp)

  fb_data.data.each do |busy_time|
    busy_type = busy_time['type'].upcase

    # Ignoring all the FREE parts, because those are already assumed.
    next if busy_type == 'FREE'

    tmp = []
    tmp << tz.at(busy_time['start'])
    tmp << tz.at(busy_time['end'])

    prop = calendar.create_property(
      'FREEBUSY',
      tmp[0].strftime('%Y%m%dT%H%M%SZ') + '/' + tmp[1].strftime('%Y%m%dT%H%M%SZ')
    )

    # Only setting FBTYPE if it's not BUSY, because BUSY is the
    # default anyway.
    prop['FBTYPE'] = busy_type unless busy_type == 'BUSY'

    vfreebusy.add(prop)
  end

  calendar
end