require "csv" require "action_view" require "active_support/core_ext/string/inflections" require "spreadsheet" module Decisive class TemplateHandler def self.register ActionView::Template.register_template_handler 'decisive', self end def self.call template, source=template.source <<~RUBY extend Decisive::DSL; context = (#{source}) response.headers["Content-Transfer-Encoding"] = "binary" response.headers["Content-Disposition"] = %(attachment; filename="\#{context.filename}") if context.csv? response.headers["Content-Type"] = "text/csv" if controller.is_a?(ActionController::Live) begin context.each do |row| response.stream.write row.to_csv(force_quotes: true) end ensure response.stream.close end "" else context.to_csv(force_quotes: true) end else response.headers["Content-Type"] = "application/vnd.ms-excel" context.to_xls end RUBY end end class StreamIncompatibleBlockArgumentError < StandardError def message "#csv cannot take a block with a record argument while streaming, because the headers have to be known in advance. Either disable streaming by passing `stream: false` to #csv, or convert the template to yield the record to the block passed to each #column call." end end class StreamingNotEnabledByControllerError < StandardError def message "the controller does not have ActionController::Live included, and thus cannot stream this csv. Either disable streaming by passing `stream: false` to #csv, or include ActionController::Live into the controller." end end module DSL def csv records, filename:, stream: true, &block if stream raise StreamingNotEnabledByControllerError unless controller.is_a?(ActionController::Live) raise StreamIncompatibleBlockArgumentError if block.arity != 0 StreamContext.new([], records, filename, &block) else RenderContext.new(records, filename, block) end end def xls worksheets=nil, filename:, &block if worksheets XLSContext.new(worksheets, filename, block) else XLSWithWorksheetsContext.new(filename, [], &block) end end end class XLSWithWorksheetsContext < Struct.new(:filename, :worksheets) class Worksheet < Struct.new(:records, :name, :block); end def initialize *args, &block super instance_eval &block end def to_xls to_string(render(Spreadsheet::Workbook.new)) end def csv? false end private def worksheet records, name:, &block worksheets.push Worksheet.new(records, name, block) end def render xls worksheets.each do |worksheet| sheet = xls.create_worksheet(name: sanitize_name(worksheet.name)) rows = to_array(worksheet) rows.each.with_index do |row, index| sheet.row(index).concat row end end xls end def sanitize_name name name .gsub(/[\[\]\*\?:\/\\\t\n\r]/, " ") .gsub(/^'/, "") .gsub(/'$/, "") .strip .slice(0,31) end def to_array worksheet context = RenderContext.new(worksheet.records, nil, worksheet.block) context.send(:header) + context.send(:body) end def to_string xls io = StringIO.new xls.write(io) io.rewind string = io.read string.force_encoding(Encoding::ASCII_8BIT) string end end class XLSContext < Struct.new(:worksheets, :filename, :block) def to_xls to_string(render(Spreadsheet::Workbook.new)) end def csv? false end private def render xls worksheets.each do |name, enumerable| sheet = xls.create_worksheet(name: sanitize_name(name)) rows = to_array(enumerable) rows.each.with_index do |row, index| sheet.row(index).concat row end end xls end def sanitize_name name name .gsub(/[\[\]\*\?:\/\\\t\n\r]/, " ") .gsub(/^'/, "") .gsub(/'$/, "") .strip .slice(0,31) end def to_array records context = RenderContext.new(records, nil, block) context.send(:header) + context.send(:body) end def to_string xls io = StringIO.new xls.write(io) io.rewind string = io.read string.force_encoding(Encoding::ASCII_8BIT) string end end class StreamContext < Struct.new(:columns, :records, :filename) class Column < Struct.new(:label, :block); end def initialize *args, &block super instance_eval &block end def column label, value=nil, &block # field, label: field.to_s.humanize, &block value ||= label.parameterize.underscore.to_sym block ||= ->(record) { record.send(value) } columns << Column.new(label, block) end def each yield header records.map do |record| row = columns.map do |column| column.block.call(record).to_s end yield row end end def csv? true end private def header columns.map(&:label) end end class RenderContext < Struct.new(:records, :filename, :block) def to_csv(*args, **kwargs) (header + body).map do |row| row.to_csv(*args, **kwargs) end.join end def csv? true end private def header [keys] end def body hashes.map do |hash| hash.values_at(*keys) end end def keys @keys ||= hashes.flat_map(&:keys).uniq end def hashes @hashes ||= records.map do |record| Row.new(record, block).to_hash end end class Row < Struct.new(:record, :block) module Nothing; end def to_hash @hash = {} instance_exec record, &block @hash end private def column key, value=Nothing, &block @hash[key] = if block_given? block.call(record) elsif value.is_a?(Symbol) record.send(value) elsif value == Nothing record.send(key.parameterize.underscore.to_sym) else value end.to_s end end end end