skandragon/thing

View on GitHub
app/lib/calendar_renderer.rb

Summary

Maintainability
F
4 days
Test Coverage
require 'csv'

class CalendarRenderer
  include GriffinPdf
  include GriffinMarkdown

  PDF_FONT_SIZE = 7.5

  def initialize(instances, instructables)
    @instances = instances
    @instructables = instructables
  end

  def render_ics(options, filename, cache_filename = nil)
    @options = options
    @options = {} if options.nil?
    @options.reverse_merge!({
      calendar_name: "Pennsic #{Pennsic.year} Master Schedule",
      calendar_id: 'all',
    })

    now = Time.now.utc

    calendar = RiCal.Calendar do |cal|
      cal.default_tzid = 'America/New_York'
      cal.prodid = '//thing.pennsicuniversity.org//PennsicU Converter.0//EN'
      cal.add_x_property('X-WR-CALNAME', @options[:calendar_name])
      cal.add_x_property('X-WR-RELCALID', make_uid) # should be static per calendar
      cal.add_x_property('X-WR-CALDESC', "PennsicU #{Pennsic.year} Master Schedule")
      cal.add_x_property('X-PUBLISHED-TTL', '3600')

      @instances.each { |instance|
        cal.event do |event|
          instructable = instance.instructable
          prefix = []
          prefix << "Subject: #{instructable.formatted_topic}"
          prefix << "Instructor: #{instructable.titled_sca_name}"
          prefix << "Additional Instructors: #{instructable.additional_instructors.join(', ')}" if instructable.additional_instructors.present?
          prefix << "Material limit: #{instructable.material_limit}" if instructable.material_limit
          prefix << "Handout limit: #{instructable.handout_limit}" if instructable.handout_limit

          suffix = []
          if instructable.instances.count > 1 and instructable.instances.map(&:formatted_location).uniq.count == 1
            dates = []
            instructable.instances.each do |inst|
              dates << inst.start_time.strftime('%a %b %e %-I:%M %p') if inst != instance and inst.start_time
            end
            suffix << 'Also Taught: ' + dates.join(', ')
          end

          event.dtstamp = now.utc
          event.created = instance.instructable.created_at.utc
          if instance.start_time
            event.dtstart = instance.start_time.utc
            event.dtend = instance.end_time.utc
          end
          event.summary = instructable.name
          event.description = [prefix.join("\n"), '', instructable.description_web, '', suffix.join("\n")].join("\n")
          event.location = instance.formatted_location
          event.uid = make_uid(instance.to_s)
          event.transp = 'OPAQUE'
          event.status = 'CONFIRMED'
          event.sequence = instructable.updated_at.to_i
        end
      }
    end

    calendar.to_s.gsub('::', ':')
  end

  def render_pdf(options, filename, cache_filename = nil, user = nil)
    @options = options
    @options = {} if @options.nil?
    @options.reverse_merge!({
      user: nil,
      omit_descriptions: false,
      no_page_numbers: false,
      no_long_descriptions: false,
      omit_table_headers: false
    })
    pp @options
    @render_instructors = @options[:schedule] == 'Pennsic University'

    generate_magic_tokens unless @options[:no_long_descriptions].present?

    margin_top = 0.125
    margin_bottom = 0.5
    margin_left = 0.5
    margin_right = 0.125

    pdf = Prawn::Document.new(page_size: [ 8.5, 10.5 ].map { |x| x * 72 },
      margin: [margin_top, margin_right, margin_bottom, margin_left].map { |x| x * 72 },
      page_layout: :portrait,
      compress: true,
      optimize_objects: true,
      info: {
        Title: "Pennsic #{Pennsic.year} Master Schedule",
        Author: 'Pennsic',
        Subject: "Pennsic #{Pennsic.year} Master Schedule",
        Keywords: 'pennsic university classes martial thrown weapons master schedule',
        Creator: 'Pennsic Univeristy Class Maker, http://thing.pennsicuniversity.org/',
        Producer: 'Pennsic Univeristy Class Maker',
        CreationDate: Time.now,
    })

    pdf.font_families.update(
        'Arial' => {
          normal: Rails.root.join('app', 'assets', 'fonts', 'Arial.ttf'),
          bold: Rails.root.join('app', 'assets', 'fonts', 'Arial Bold.ttf'),
          italic: Rails.root.join('app', 'assets', 'fonts', 'Arial Italic.ttf'),
          bold_italic: Rails.root.join('app', 'assets', 'fonts', 'Arial Bold Italic.ttf'),
        },
    )

    pdf.font 'Arial'
    omit_table_headers = options[:omit_table_headers]

    header = [
      { content: 'TIME', background_color: 'eeeeee' },
      { content: 'EVENT', background_color: 'eeeeee' },
      { content: 'LOCATION', background_color: 'eeeeee' },
    ]
    column_widths = [ 95, 180, 110 ]

    if @render_instructors
      header << { content: 'INSTRUCTOR', background_color: 'eeeeee' }
      column_widths << 95
    end

    unless @options[:omit_descriptions]
      header << { content: 'DESCRIPTION', background_color: 'eeeeee' }
      column_widths << 300
    end

    desired_width = 540

    total_width = column_widths.inject(:+)
    adjustment = desired_width.to_f / total_width
    column_widths = column_widths.map { |x| (x * adjustment).floor.to_i }
    total_width = column_widths.inject(:+)

    last_date = nil
    items = []

    @instances.each { |instance|
      next unless instance.start_time
      if last_date != instance.start_time.to_date
        if items.size > 0
          pdf_render_table(pdf, items, omit_table_headers ? nil : header, total_width, column_widths)
          items = []
        end

        unless pdf.cursor == pdf.bounds.top
          pdf.move_down 15
        end
        pdf.font_size 12
        pdf.text instance.start_time.to_date.strftime('%A, %B %e').upcase, style: :bold, align: :center
        pdf.font_size PDF_FONT_SIZE
        last_date = instance.start_time.to_date
      end

      if !@options[:omit_descriptions] and instance.formatted_location =~ /A\&S /
        times = []
        times << "#{instance.formatted_location}"
        times << "#{instance.start_time.strftime('%-I:%M %p')} - #{instance.end_time.strftime('%-I:%M %p')}"
        times_content = times.join("\n")

        location = nil
      else
        times = []
        times << "#{instance.start_time.strftime('%-I:%M %p')} - #{instance.end_time.strftime('%-I:%M %p')}"
        times_content = times.join(@options[:omit_descriptions] ? ' ' : "\n")
        location = instance.formatted_location
      end

      maybe_newline = @options[:omit_descriptions] ? ' - ' : "\n"

      unless @options[:no_long_descriptions].present?
        token = @instructable_magic_tokens[instance.instructable.id].to_s
      end

      title = markdown_pdf(instance.instructable.name)
      unless @options[:no_long_descriptions].present?
        title += " (#{token})"
      end

      new_items = [
          {content: times_content},
          {content: title, inline_format: true },
          {content: location },
      ]
      if (@render_instructors)
        new_items << { content: instance.instructable.user.titled_sca_name }
      end
      unless @options[:omit_descriptions]
        taught_message = nil
        if instance.instructable.repeat_count > 1
          times = instance.instructable.instances.pluck(:start_time).compact
          formatted_times = times.select { |t| t != instance.start_time }.map { |t| t.strftime('%m/%d') }.join(', ')
          taught_message = "Also on #{formatted_times}"
        end
        new_items << {
          inline_format: true,
          content: markdown_pdf([
                                    instance.instructable.description_book,
                                    materials_and_handout_content(instance.instructable).join(' '),
                                    taught_message,
                                 ].compact.join(' '))
        }
      end
      items << new_items
    }

    pdf_render_table(pdf, items, omit_table_headers ? nil : header, total_width, column_widths)

    unless @options[:no_long_descriptions].present?
      # Render class summary

      pdf.move_down PDF_FONT_SIZE
      #pdf.start_new_page(layout: :portrait)

      instructables = []
      last_topic = nil

      pdf.start_new_page

      pdf.column_box([0, pdf.cursor ], reflow_margins: true, columns: 3, spacer: 6, width: pdf.bounds.width * 0.95) do
        @instructables.each do |instructable|
          if last_topic != instructable.topic && !instructables.empty?
            render_topic_list(pdf, instructables)
            instructables = []
          end

          last_topic = instructable.topic
          instructables << instructable
        end

        unless instructables.empty?
          render_topic_list(pdf, instructables)
          instructables = []
        end
      end
    end

    # set page footer
    render_options = { :at => [pdf.bounds.left, -5],
                :width => pdf.bounds.right,
                :align => :center,
                :start_count_at => 1,
                font_size: 6 }

    for_user = ''
    for_user = "-- for #{@options[:user].best_name}" if @options[:user]

    unless @options[:no_page_numbers]
      now = Time.now.in_time_zone.strftime('%A, %B %d, %H:%M %p')
      pdf.number_pages "Generated on #{now} #{for_user} -- page <page> of <total>", render_options
    end

    pdf.render
  end

  def render_csv(options, filename)
    @options = options
    @options = {} if @options.nil?

    column_names = %w(
      name track culture topic subtopic
      adult_only
      description_book description_web
      duration fee_itemization handout_fee handout_limit material_fee
      material_limit repeat_count updated_at schedule
    )
    CSV.generate do |csv|
      names = %w(id location start_time end_time instructor instructor_kingdom instance_id) + column_names
      csv << names
      @instances.each do |instance|
        next unless instance.scheduled?
        data = [
            instance.instructable.id,
            instance.formatted_location,
            instance.start_time,
            instance.end_time,
            instance.instructable.user.titled_sca_name,
            instance.instructable.user.kingdom,
            instance.id
        ]
        data += instance.instructable.attributes.values_at(*column_names)
        csv << data
      end
    end
  end

  def render_xlsx(options, filename)
    @options = options
    @options = {} if @options.nil?

    p = Axlsx::Package.new
    p.use_shared_strings = true

    wb = p.workbook

    wb.styles do |s|
      header_style = s.add_style bg_color: '00', fg_color: 'FF'
      date_format = wb.styles.add_style format_code: 'MM-DD hh:mm'

      column_names = %W(
        name track culture formatted_topic
        adult_only repeat_count updated_at
        description_book description_web
        duration fee_itemization handout_fee handout_limit material_fee
        material_limit
      )
      header = %w(id location start_time end_time instructor instructor_kingdom) + column_names

      wb.add_worksheet(name: "Pennsic #{Pennsic.year}") do |sheet|
        sheet.add_row header

        @instances.each { |instance|
          instructable = instance.instructable
          start_time = Time.at(instance.start_time.to_f + instance.start_time.utc_offset.to_f)  + 14400 # Fix for Excel times being 4 hours 0ff
          end_time = Time.at(instance.end_time.to_f + instance.end_time.utc_offset.to_f) + 14400
          data = [
              instructable.id,
              instance.formatted_location,
              start_time,
              end_time,
              instructable.titled_sca_name,
              instance.instructable.user.kingdom,
          ]
          column_names.each do |column_name|
            data += [instructable.send(column_name)]
          end

          sheet.add_row data, style: [
              nil, nil, date_format, date_format, nil,
              nil, nil, nil, nil, nil, nil, date_format,
          ]
          sheet.column_widths 4, 10, 10, 10, nil, nil, nil, nil, nil, nil, 4, 6
        }

        sheet.row_style 0, header_style
      end
    end

    p.to_stream.read
  end

  private

  def generate_magic_tokens
    last_topic = nil
    magic_token = 0

    @instructable_magic_tokens = {}
    @instructables.each do |instructable|
      if last_topic != instructable.topic
        magic_token += 100 - (magic_token % 100)
        last_topic = instructable.topic
      end
      @instructable_magic_tokens[instructable.id] = magic_token
      magic_token += 1
    end
  end

  def materials_and_handout_content(instructable)
    materials = []
    handout = []
    handout << "limit: #{instructable.handout_limit}" if instructable.handout_limit
    materials << "limit: #{instructable.material_limit}" if instructable.material_limit

    handout << "fee: $#{'%.2f' % instructable.handout_fee}" if instructable.handout_fee
    materials << "fee: $#{'%.2f' % instructable.material_fee}" if instructable.material_fee

    handout_content = nil
    handout_content = 'Handout ' + handout.join(', ') + '. ' if handout.size > 0

    materials_content = nil
    materials_content = 'Materials ' + materials.join(', ') + '. ' if materials.size > 0

    [ handout_content, materials_content ].compact
  end

  def render_topic_list(pdf, instructables)
    pdf.move_down 8 unless pdf.cursor == pdf.bounds.top
    pdf.font_size 14
    pdf.text instructables.first.topic
    pdf.font_size PDF_FONT_SIZE

    instructables.each do |instructable|
      pdf.move_down 5 unless pdf.cursor == pdf.bounds.top
      name = markdown_pdf(instructable.name, tags_remove: 'strong')

      if @instructable_magic_tokens
        token = @instructable_magic_tokens[instructable.id]
      end

      topic = "Topic: #{instructable.formatted_topic}"
      culture = instructable.culture.present? ? "Culture: #{instructable.culture}" : nil

      if token.present?
        heading = "<strong>#{token}</strong>: <strong>#{name}</strong>"
      else
        heading = "<strong>#{name}</strong>"
      end

      lines = [
        heading,
        [topic, culture].compact.join(', ')
      ]
      if instructable.schedule != "Battlefield"
        lines << "Instructor: #{instructable.user.titled_sca_name}"
      end

      if instructable.instances.count > 1 and instructable.instances.map(&:formatted_location).uniq.count == 1
        lines << instructable.instances.select {|x| x.start_time }.map { |x| "#{x.start_time.strftime('%a %b %e, %-I:%M %p')}" }.join(', ')
        lines << 'Location: ' + instructable.instances.first.formatted_location
      else
        lines << instructable.instances.select {|x| x.start_time }.map { |x| "#{x.start_time.strftime('%a %b %e, %-I:%M %p')} #{x.formatted_location}" }.join(', ')
      end

      lines << materials_and_handout_content(instructable).join(' ')

      pdf.text lines.join("\n"), inline_format: true

      pdf.move_down 2 unless pdf.cursor == pdf.bounds.top
      pdf.text markdown_pdf(instructable.description_web.present? ? instructable.description_web : instructable.description_book), inline_format: true, align: :justify
    end
  end

  def make_uid(*items)
    items << @options[:calendar_id] or 'all'
    d = Digest::SHA1.new
    d << items.join('/')
    d.hexdigest + '@thing.pennsicuniversity.org'
  end
end