am-kantox/see_as_vee

View on GitHub
lib/see_as_vee/sheet.rb

Summary

Maintainability
A
0 mins
Test Coverage
require_relative 'helpers'

module SeeAsVee
  class Sheet
    CELL_ERROR_MARKER = '⚑ '.freeze
    CELL_ERROR_STYLE = {
      bg_color: 'FF880000',
      fg_color: 'FFFFFFFF',
      sz: 14,
      border: { style: :thin, color: 'FFFF0000' }
    }.freeze
    WORK_SHEET_NAME = 'Processing errors shown in red'.freeze
    LEAVE_ERROR_MARKER = true

    attr_reader :rows, :formatters, :checkers

    def initialize whatever, formatters: {}, checkers: {}, skip_blank_rows: false
      @formatters = formatters.map { |k, v| [str_to_sym(k), v] }.to_h
      @checkers = checkers.map { |k, v| [str_to_sym(k), v] }.to_h
      @rows = whatever.is_a?(Array) ? whatever : Helpers.harvest_csv(whatever)

      @rows = @rows.map do |row|
        row unless skip_blank_rows && row.compact.empty?
      end.compact.map.with_index do |row, idx|
        idx.zero? ? row : plough_row(row)
      end
    end

    def values
      @rows[1..-1]
    end

    def headers symbolic = false
      headers = @rows.first
      unless headers.uniq.length == headers.length
        groups = headers.group_by { |h| h }.select { |_, group| group.size > 1 }
        headers = headers.map.with_index { |e, idx| groups[e].nil? ? e : "#{e} #{idx}" }
      end

      headers = headers.map.with_index { |s, ind| str_to_sym(s || "col #{ind}") } if symbolic
      headers
    end

    def [] index, key = nil
      key.nil? ? values[index] : values[index][header_index(key)]
    end

    def each
      return enum_for unless block_given?

      values.each_with_index do |row, idx|
        result = headers.zip(row).to_h
        errors = result.select { |_, v| malformed?(v) }
        yield idx, errors, result
      end
    end

    def map
      return enum_for unless block_given?

      values.map do |row|
        yield headers(true).zip(row).to_h
      end
    end

    def produce csv: true, xlsx: nil, **params
      [csv && produce_csv(**params), xlsx && produce_xlsx(**params)]
    end

    private

    def malformed? str
      str.to_s.start_with? CELL_ERROR_MARKER
    end

    def produce_csv **params
      return if @rows.empty?

      for_ms_excel = params.delete(:ms_excel) == true
      params.merge!(col_sep: "\t") if for_ms_excel

      Tempfile.open(['see_as_vee', '.csv']).tap do |f|
        content =
          CSV.generate(params) do |csv|
            @rows.each { |row| csv << row }
          end
        content =
          "\xFF\xFE".force_encoding(Encoding::UTF_16LE) <<
          content.encode(Encoding::UTF_16LE) if for_ms_excel

        mode = for_ms_excel ? "w:UTF-16LE" : "w:UTF-8"

        File.open(f.path, mode) { |f| f.write content }
      end
    end

    def produce_xlsx **params
      params, axlsx_params = split_params(params)
      Tempfile.new(['see_as_vee', '.xlsx']).tap do |f|
        Axlsx::Package.new do |p|
          red = p.workbook.styles.add_style(**params[:ces]) if params[:ces].is_a?(Hash)
          p.workbook.add_worksheet(**axlsx_params) do |sheet|
            @rows.each do |row|
              styles = row.map { |cell| malformed?(cell) ? red : nil }
              row = row.map { |cell| malformed?(cell) ? cell.to_s.gsub(/\A#{CELL_ERROR_MARKER}/, '') : cell } if params[:lem]
              sheet.add_row row, style: styles
            end
          end
          p.serialize(f.path)
        end
      end
    end

    def header_index key
      headers(true).index(str_to_sym(key))
    end

    def str_to_sym str
      str.is_a?(Symbol) ? str : squish(str).downcase.gsub(/\W/, '_').to_sym
    end

    def plough_row row
      return row if @formatters.empty? && @checkers.empty? # performance

      row.map.with_index do |cell, i|
        cell = format_cell(cell, i) unless @formatters.empty?
        cell = check_cell(cell, i) unless @checkers.empty?
        cell
      end
    end

    def format_cell cell, i
      case f = @formatters[headers(true)[i]]
      when Proc then f.call(cell)
      when Symbol then cell.public_send f
      else cell
      end
    end

    # rubocop:disable Style/MultilineTernaryOperator
    def check_cell cell, i
      f = @checkers[headers(true)[i]]
      case f
      when Proc then f.call(cell)
      when Symbol then cell.public_send(f)
      else true
      end ? cell : CELL_ERROR_MARKER + cell.to_s.split('').map { |c| "#{c}\u0336" }.join
    end
    # rubocop:enable Style/MultilineTernaryOperator

    def split_params params
      params = params.dup
      [
        { ces: params.delete(:cell_error_style) { CELL_ERROR_STYLE.dup },
          lem: params.delete(:leave_error_marker) { LEAVE_ERROR_MARKER } },
        { name: WORK_SHEET_NAME }.merge(params)
      ]
    end

    def squish str
      return str unless str.is_a?(String)

      str.
        gsub(/\A[[:space:]]+/, '').
        gsub(/[[:space:]]+\z/, '').
        gsub(/[[:space:]]+/, ' ')
    end
  end
end