├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── examples └── grouping.rb ├── images └── grouping.png ├── lib ├── generators │ └── xport │ │ └── install │ │ ├── install_generator.rb │ │ └── templates │ │ └── migration.rb ├── xport.rb └── xport │ ├── cell.rb │ ├── download_presenter.rb │ ├── downloads_controller_methods.rb │ ├── export.rb │ ├── export_builder.rb │ ├── export_controller_methods.rb │ ├── formatters │ ├── axlsx.rb │ ├── csv.rb │ ├── rubyxl.rb │ └── xlsxtream.rb │ └── version.rb ├── spec ├── spec_helper.rb ├── xport │ ├── export_axlsx_spec.rb │ ├── export_csv_spec.rb │ ├── export_rubyxl_spec.rb │ └── export_xlsxtream_spec.rb └── xport_spec.rb └── xport.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /*.gem 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.0 4 | - 2.3.3 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in xport.gemspec 4 | gemspec 5 | 6 | gem 'axlsx', git: 'https://github.com/randym/axlsx' 7 | gem 'rubyXL', '>= 3.3.26' 8 | gem 'xlsxtream', '>= 2.0.0' 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 SIA MAK IT 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xport 2 | 3 | [![Build Status](https://travis-ci.org/mak-it/xport.svg?branch=master)](https://travis-ci.org/mak-it/xport) 4 | [![Code Climate](https://codeclimate.com/github/mak-it/xport/badges/gpa.svg)](https://codeclimate.com/github/mak-it/xport) 5 | 6 | Tabular data export to Excel, CSV, etc. 7 | 8 | ## Features 9 | 10 | - [Header groups](#header-groups) - `column :name, group: :project` 11 | - Column widths - `column :name, width: 10` 12 | - Column header titles - `column :name, header: "Full name"` 13 | - Column types - `column :name, type: :string` 14 | - Column styles - `column :pct, style: { num_fmt: Axlsx::NUM_FMT_PERCENT, format_code: '0.0%' }` 15 | - Cell colors - `cell.color = "AAAAAA"` 16 | - Cell comments - `cell.comment = "..."` 17 | 18 | ## Formatters 19 | 20 | | Feature | csv | axlsx | rubyXL | xlsxtream | 21 | |:--------------|:----|:------|:-------|:----------| 22 | | Column groups | No | Yes | Yes | No | 23 | | Column widths | No | Yes | Yes | No | 24 | | Column types | No | Yes | No* | No | 25 | | Column styles | No | Yes | No* | No | 26 | | Cell colors | No | Yes | No* | No | 27 | | Cell comments | No | Yes | No* | No | 28 | 29 | \* - PRs are welcome 30 | 31 | ## Installation 32 | 33 | Add this line to your application's Gemfile: 34 | 35 | ```ruby 36 | gem 'xport' 37 | gem 'axlsx' # optional 38 | gem 'rubyXL' # optional 39 | gem 'xlsxtream' # optional 40 | ``` 41 | 42 | And then execute: 43 | 44 | ```bash 45 | $ bundle 46 | ``` 47 | 48 | ## Usage 49 | 50 | ```ruby 51 | class User < ActiveRecord::Base; end 52 | User.create(name: "John") 53 | User.create(name: "Ben") 54 | 55 | class UserExport < Xport::Export 56 | include Xport::CSV 57 | include Xport::Axlsx 58 | 59 | columns do 60 | column :id 61 | column :name, header: "Full name" do |user| 62 | user.name.upcase 63 | end 64 | column :email do |user| 65 | cell = Xport::Cell.new 66 | cell.value = "#{user.id}@example.com" 67 | cell.color = "AAAAAA" 68 | cell.comment = "Excel comment" 69 | cell 70 | end 71 | end 72 | end 73 | 74 | UserExport.new(User.all).to_csv 75 | UserExport.new(User.all).to_xlsx 76 | ``` 77 | 78 | Output: 79 | 80 | ```csv 81 | id,Full name,email 82 | 1,JOHN,1@example.com 83 | 2,BEN,2@example.com 84 | ``` 85 | 86 | See [examples](examples) for more examples. 87 | 88 | ### Header groups 89 | 90 | ```ruby 91 | class UserExport < Xport::Export 92 | include Xport::Axlsx 93 | 94 | columns do 95 | column(:id, group: "User") 96 | column(:name, group: "User") 97 | column(:email, group: "User") 98 | column(:admin, group: "Roles") { |u| "No" } 99 | column(:owner, group: "Roles") { |u| "Yes" } 100 | end 101 | end 102 | 103 | File.open("export.xlsx", "wb") do |f| 104 | f.write UserExport.new(users).to_xlsx.read 105 | end 106 | ``` 107 | 108 | Output: 109 | 110 | ![Excel screenshot](images/grouping.png) 111 | 112 | ## Contributing 113 | 114 | Bug reports and pull requests are welcome on GitHub at https://github.com/mak-it/xport. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 115 | 116 | ## License 117 | 118 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 119 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "xport" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /examples/grouping.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "xport" 3 | require "axlsx" 4 | 5 | User = Struct.new(:id, :name, :email) 6 | User.class_eval do 7 | def self.human_attribute_name(name) 8 | name 9 | end 10 | end 11 | 12 | users = [ 13 | User.new(1, "John"), 14 | User.new(2, "Ben") 15 | ] 16 | 17 | class UserExport < Xport::Export 18 | include Xport::Axlsx 19 | 20 | columns do 21 | column(:id, group: "User") 22 | column(:name, group: "User") 23 | column(:email, group: "User") 24 | column(:admin, group: "Roles") { |u| "No" } 25 | column(:owner, group: "Roles") { |u| "Yes" } 26 | end 27 | end 28 | 29 | File.open("export.xlsx", "wb") do |f| 30 | f.write UserExport.new(users).to_xlsx.read 31 | end 32 | -------------------------------------------------------------------------------- /images/grouping.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitigate-dev/xport/cd72fb69608484b182ad0d3815879affcaa9481c/images/grouping.png -------------------------------------------------------------------------------- /lib/generators/xport/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | include Rails::Generators::Migration 7 | 8 | source_root File.expand_path('../templates', __FILE__) 9 | 10 | def self.next_migration_number(dirname) 11 | if ActiveRecord::Base.timestamped_migrations 12 | Time.new.utc.strftime("%Y%m%d%H%M%S") 13 | else 14 | format("%.3d", current_migration_number(dirname) + 1) 15 | end 16 | end 17 | 18 | def create_migrations 19 | migration_template 'migration.rb', 'db/migrate/create_downloads.rb' 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/generators/xport/install/templates/migration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateDownloads < ActiveRecord::Migration 4 | def up 5 | create_table :downloads do |t| 6 | t.string :user_id 7 | t.string :file_filename 8 | t.string :filename 9 | t.string :job_id 10 | t.string :export_klass_name 11 | t.string :export_model_name 12 | t.text :query 13 | t.integer :records_count 14 | t.datetime :exported_at 15 | t.string :export_additional_columns, array: true, default: [] 16 | t.timestamps 17 | end 18 | end 19 | 20 | def down 21 | drop_table :downloads 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/xport.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/class/attribute' 4 | require 'active_support/core_ext/module/delegation' 5 | require 'active_support/core_ext/string/inflections' 6 | require 'active_support/concern' 7 | 8 | require 'xport/version' 9 | require 'xport/export' 10 | require 'xport/export_builder' 11 | require 'xport/cell' 12 | require 'xport/downloads_controller_methods' 13 | require 'xport/export_controller_methods' 14 | require 'xport/download_presenter' 15 | 16 | require 'xport/formatters/csv' 17 | require 'xport/formatters/axlsx' 18 | require 'xport/formatters/rubyxl' 19 | require 'xport/formatters/xlsxtream' 20 | 21 | module Xport 22 | end 23 | -------------------------------------------------------------------------------- /lib/xport/cell.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | class Cell 5 | attr_accessor :value, :color, :comment 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/xport/download_presenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | class DownloadPresenter 5 | attr_reader :h 6 | 7 | def initialize(download, h) 8 | @download = download 9 | @h = h 10 | end 11 | 12 | def progress_bar(job_id) 13 | return unless job_id 14 | job = Resque::Plugins::Status::Hash.get(job_id) 15 | return unless job 16 | 17 | case job.status 18 | when Resque::Plugins::Status::STATUS_COMPLETED 19 | nil 20 | when Resque::Plugins::Status::STATUS_QUEUED 21 | progress_bar_pending 22 | else 23 | h.content_tag(:div, class: 'progress', data: { component: 'Future.ProgressBar' }) do 24 | h.content_tag(:div, class: 'progress-bar', style: "width: #{job.pct_complete}%") do 25 | "#{job.pct_complete}% #{job.message}" 26 | end 27 | end 28 | end 29 | end 30 | 31 | def progress_bar_pending 32 | message = I18n.translate( 33 | 'helpers.progress_bar.please_wait_pending', 34 | count: Resque.info[:pending] 35 | ) 36 | 37 | h.content_tag(:div, class: 'progress', data: { component: 'Future.ProgressBar' }) do 38 | h.content_tag(:div, class: 'progress-bar progress-bar-pending', style: 'width: 100%') do 39 | h.content_tag(:span, message) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/xport/downloads_controller_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module DownloadsControllerMethods 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | before_action :set_download, only: %i(show update destroy) 9 | before_action :set_downloads, only: :index 10 | end 11 | 12 | def index; end 13 | 14 | def show 15 | respond_to do |format| 16 | format.html do 17 | if !request.xhr? && @download.file? 18 | redirect_to format: @download.type 19 | else 20 | render 'show' 21 | end 22 | end 23 | 24 | format.any(:xlsx, :csv) do 25 | send_data @download.file.read, filename: @download.filename 26 | end 27 | end 28 | end 29 | 30 | def update 31 | @download.schedule_export! 32 | redirect_to @download 33 | end 34 | 35 | def destroy 36 | @download.destroy 37 | redirect_to action: 'index' 38 | end 39 | 40 | private 41 | 42 | def resource_class 43 | controller_path.classify.constantize 44 | end 45 | 46 | def set_download 47 | @download = resource_class.find(params[:id]) 48 | end 49 | 50 | def set_downloads 51 | @downloads = resource_class.page(params[:page]) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/xport/export.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | class Export 5 | attr_reader :workbook, :objects, :builder 6 | 7 | class_attribute :builder_block 8 | 9 | def self.columns(&block) 10 | self.builder_block = block 11 | end 12 | 13 | def initialize(objects = []) 14 | @objects = objects 15 | @builder = Xport::ExportBuilder.new(self, &builder_block) 16 | end 17 | 18 | def to_file(formatter = nil, &block) 19 | preload! 20 | # TODO: There shouldn't be a default formatter 21 | formatter ||= Xport::Axlsx::Formatter.new(self) 22 | write_contents(formatter, &block) 23 | formatter.to_file 24 | end 25 | 26 | def object_class 27 | self.class.name.sub(/Export/, '').singularize.constantize 28 | end 29 | 30 | # TODO: Extract to xport-downloads? 31 | def download(filename:, user: nil) 32 | return unless objects.respond_to?(:to_sql) 33 | download = download_class.new 34 | download.user = user 35 | download.export_klass_name = self.class.name 36 | 37 | if objects.respond_to?(:model) 38 | download.export_model_name = objects.model.name 39 | end 40 | if respond_to?(:additional_columns) 41 | download.export_additional_columns = additional_columns 42 | end 43 | # TODO: Remove `unprepared_statement` call when `to_sql` is fixed 44 | # https://github.com/rails/rails/issues/18379 45 | object_class.connection.unprepared_statement do 46 | download.query = objects.to_sql 47 | end 48 | download.filename = filename 49 | download.save! 50 | download.schedule_export! 51 | download 52 | end 53 | 54 | def download_class 55 | module_name = self.class.to_s.deconstantize 56 | if module_name.present? 57 | "#{module_name}::Download".constantize 58 | else 59 | Download 60 | end 61 | end 62 | 63 | private 64 | 65 | def preload!; end 66 | 67 | def write_contents(formatter, &block) 68 | formatter.add_worksheet do |worksheet| 69 | write_header(formatter, worksheet) 70 | write_body(formatter, worksheet, &block) 71 | write_widths(formatter, worksheet) 72 | end 73 | end 74 | 75 | def write_header(formatter, worksheet) 76 | if builder.grouped? 77 | group_row = [] 78 | builder.groups_with_offset_and_colspan.each do |group, offset, colspan| 79 | group_row << human_attribute_name(group) 80 | (colspan - 1).times do 81 | group_row << nil 82 | end 83 | end 84 | formatter.add_header_row worksheet, group_row 85 | 86 | builder.groups_with_offset_and_colspan.each do |group, offset, colspan| 87 | colspan -= 1 88 | formatter.merge_header_cells(worksheet, offset..offset + colspan) 89 | end 90 | end 91 | formatter.add_header_row worksheet, header_row 92 | end 93 | 94 | def write_body(formatter, worksheet) 95 | each_object do |object, rownum| 96 | yield rownum, objects.size if block_given? 97 | formatter.add_row worksheet, object_row(object) 98 | end 99 | end 100 | 101 | def write_widths(formatter, worksheet) 102 | formatter.column_widths(worksheet, *builder.widths) 103 | end 104 | 105 | def find_in_batches? 106 | false 107 | end 108 | 109 | def each_object(&block) 110 | if find_in_batches? 111 | objects.find_each.with_index(&block) 112 | else 113 | objects.each_with_index(&block) 114 | end 115 | end 116 | 117 | def header_row 118 | builder.headers.map do |name| 119 | human_attribute_name(name) 120 | end 121 | end 122 | 123 | def human_attribute_name(name) 124 | return if name.to_s.start_with?('empty') 125 | 126 | klass = object_class 127 | case name 128 | when String 129 | name 130 | when Symbol 131 | klass.human_attribute_name(name) 132 | end 133 | end 134 | 135 | def object_row(object) 136 | builder.columns.map.with_index do |name, index| 137 | block = builder.blocks[index] 138 | if block 139 | value = instance_exec(object, &block) 140 | elsif respond_to?(name, true) 141 | value = send(name, object) 142 | else 143 | value = object.send(name) 144 | if respond_to?("convert_#{name}", true) 145 | value = send("convert_#{name}", value) 146 | end 147 | end 148 | value 149 | end 150 | end 151 | 152 | def helper 153 | ApplicationController.helpers 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/xport/export_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | class ExportBuilder 5 | attr_reader :export, :columns, :headers, :groups, :styles, :types, :widths, :blocks 6 | 7 | def initialize(export, &block) 8 | @export = export 9 | @columns = [] 10 | @headers = [] 11 | @groups = [] 12 | @styles = [] 13 | @types = [] 14 | @widths = [] 15 | @blocks = [] 16 | instance_exec(export, &block) 17 | end 18 | 19 | def column(name, type: nil, style: nil, width: nil, header: nil, group: nil, &block) 20 | columns << name 21 | headers << (header || name) 22 | groups << group 23 | types << type 24 | styles << style 25 | widths << width 26 | blocks << block 27 | end 28 | 29 | def grouped? 30 | groups.any? 31 | end 32 | 33 | def groups_with_offset_and_colspan 34 | offset = 0 35 | colspan = 1 36 | [].tap do |result| 37 | groups.each do |group| 38 | last = result.last 39 | # check if current group is same as last group 40 | if last && last[0] == group 41 | # if group is the same, update colspan 42 | last[2] += 1 43 | else 44 | result << [group, offset, colspan] 45 | end 46 | offset += 1 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/xport/export_controller_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module ExportControllerMethods 5 | def xport_export(export, filename:) 6 | download = export.download(filename: filename, user: current_user) 7 | if download 8 | redirect_to download 9 | else 10 | send_data export.to_file.read, filename: filename 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/xport/formatters/axlsx.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module Axlsx 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | require 'axlsx' 9 | end 10 | 11 | def to_xlsx(&block) 12 | formatter = Xport::Axlsx::Formatter.new(self) 13 | to_file(formatter, &block) 14 | end 15 | 16 | class Formatter 17 | attr_reader :export, :workbook 18 | 19 | delegate :builder, to: :export 20 | 21 | def initialize(export) 22 | @export = export 23 | @package = ::Axlsx::Package.new 24 | @workbook = @package.workbook 25 | 26 | # Support Numbers and multiline strings in Excel Mac 2011 27 | # https://github.com/randym/axlsx/issues/252 28 | @package.use_shared_strings = true 29 | end 30 | 31 | def to_file 32 | @package.to_stream 33 | end 34 | 35 | def add_worksheet 36 | worksheet = @workbook.add_worksheet 37 | yield worksheet 38 | end 39 | 40 | def add_header_row(worksheet, row) 41 | worksheet.add_row row, style: header_style 42 | end 43 | 44 | def add_row(worksheet, row) 45 | values = row.map { |v| v.is_a?(Xport::Cell) ? v.value : v } 46 | axlsx_row = worksheet.add_row(values, style: styles, types: builder.types) 47 | row.each.with_index do |cell, i| 48 | next unless cell.is_a?(Xport::Cell) 49 | axlsx_cell = axlsx_row.cells[i] 50 | axlsx_cell.color = cell.color 51 | if cell.comment 52 | worksheet.add_comment( 53 | ref: axlsx_cell.reference(false), 54 | author: 'Conditions', 55 | text: cell.comment, 56 | visible: false 57 | ) 58 | end 59 | end 60 | end 61 | 62 | def merge_header_cells(worksheet, range) 63 | worksheet.merge_cells worksheet.rows.first.cells[range] 64 | end 65 | 66 | def column_widths(worksheet, *widths) 67 | worksheet.column_widths(*widths) 68 | end 69 | 70 | private 71 | 72 | def styles 73 | @styles ||= builder.styles.map do |options| 74 | workbook.styles.add_style(options) if options 75 | end 76 | end 77 | 78 | def header_style 79 | @header_style ||= workbook.styles.add_style b: true 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/xport/formatters/csv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module CSV 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | require 'csv' 9 | end 10 | 11 | def to_csv(&block) 12 | formatter = Xport::CSV::Formatter.new(self) 13 | to_file(formatter, &block) 14 | end 15 | 16 | class Formatter 17 | def initialize(export) 18 | @io = StringIO.new 19 | @csv = ::CSV.new(@io) 20 | end 21 | 22 | def to_file 23 | @io.rewind 24 | @io 25 | end 26 | 27 | def add_worksheet 28 | yield 29 | end 30 | 31 | def add_row(worksheet, row) 32 | values = row.map { |v| v.is_a?(Xport::Cell) ? v.value : v } 33 | @csv << values 34 | end 35 | alias_method :add_header_row, :add_row 36 | 37 | def merge_header_cells(worksheet, range); end 38 | def column_widths(worksheet, *widths); end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/xport/formatters/rubyxl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module RubyXL 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | require 'rubyXL' 9 | end 10 | 11 | def to_xlsx(&block) 12 | formatter = Xport::RubyXL::Formatter.new(self) 13 | to_file(formatter, &block) 14 | end 15 | 16 | class Formatter 17 | attr_reader :export, :workbook 18 | 19 | delegate :builder, to: :export 20 | 21 | def initialize(export) 22 | @export = export 23 | @workbook = ::RubyXL::Workbook.new 24 | @i = 0 25 | end 26 | 27 | def to_file 28 | workbook.stream 29 | end 30 | 31 | def add_worksheet 32 | worksheet = workbook.worksheets[0] 33 | yield worksheet 34 | end 35 | 36 | def add_header_row(worksheet, row) 37 | worksheet.change_row_bold(@i, true) 38 | add_row(worksheet, row) 39 | end 40 | 41 | def add_row(worksheet, row) 42 | row.each.with_index do |v, j| 43 | value = v.is_a?(Xport::Cell) ? v.value : v 44 | worksheet.add_cell(@i, j, value) 45 | end 46 | @i += 1 47 | end 48 | 49 | def merge_header_cells(worksheet, range) 50 | worksheet.merge_cells(0, range.first, 0, range.last) 51 | end 52 | 53 | def column_widths(worksheet, *widths) 54 | widths.each.with_index do |width, i| 55 | next unless width 56 | worksheet.change_column_width(i, width) 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/xport/formatters/xlsxtream.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Xport 4 | module Xlsxtream 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | require 'xlsxtream' 9 | end 10 | 11 | def to_xlsx(&block) 12 | formatter = Xport::Xlsxtream::Formatter.new(self) 13 | to_file(formatter, &block) 14 | end 15 | 16 | class Formatter 17 | attr_reader :export, :workbook 18 | 19 | delegate :builder, to: :export 20 | 21 | def initialize(export) 22 | @io = StringIO.new 23 | @workbook = ::Xlsxtream::Workbook.new(@io) 24 | @io.rewind 25 | end 26 | 27 | def to_file 28 | workbook.close 29 | @io.rewind 30 | @io 31 | end 32 | 33 | def add_worksheet(&block) 34 | workbook.write_worksheet('Sheet1', use_shared_strings: true, &block) 35 | end 36 | 37 | def add_row(worksheet, row) 38 | values = row.map { |v| v.is_a?(Xport::Cell) ? v.value : v } 39 | worksheet << values 40 | end 41 | alias_method :add_header_row, :add_row 42 | 43 | def merge_header_cells(worksheet, range); end 44 | def column_widths(worksheet, *widths); end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/xport/version.rb: -------------------------------------------------------------------------------- 1 | module Xport 2 | VERSION = "0.3.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require "xport" 3 | require "saxlsx" 4 | 5 | User = Struct.new(:id, :name, :email) 6 | User.class_eval do 7 | def self.human_attribute_name(name) 8 | name.to_s 9 | end 10 | end 11 | 12 | class UserExport < Xport::Export 13 | include Xport::CSV 14 | include Xport::Axlsx 15 | include Xport::RubyXL 16 | include Xport::Xlsxtream 17 | 18 | columns do 19 | column :id, width: 10 20 | column :name, header: "Full name" do |user| 21 | user.name.upcase 22 | end 23 | column :email do |user| 24 | cell = Xport::Cell.new 25 | cell.value = "#{user.id}@example.com" 26 | cell.color = "AAAAAA" 27 | cell 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/xport/export_axlsx_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Xport::Export, "with axlsx" do 4 | subject(:export) do 5 | UserExport.new(users) 6 | end 7 | 8 | let(:users) do 9 | [ 10 | User.new(1, "John"), 11 | User.new(2, "Ben") 12 | ] 13 | end 14 | 15 | describe "#to_xlsx" do 16 | it "returns serialized contents" do 17 | export = UserExport.new(users) 18 | formatter = Xport::Axlsx::Formatter.new(export) 19 | content = export.to_file(formatter) 20 | file = Tempfile.new(['export', '.xlsx'], encoding: 'ascii-8bit') 21 | file.write content.read 22 | file.rewind 23 | rows = [] 24 | Saxlsx::Workbook.open(file.path) do |doc| 25 | rows = doc.sheets[0].rows.to_a 26 | end 27 | expect(rows).to eq([ 28 | ["id", "Full name", "email"], 29 | [1.0, "JOHN", "1@example.com"], 30 | [2.0, "BEN", "2@example.com"] 31 | ]) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/xport/export_csv_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Xport::Export, "with csv" do 4 | subject(:export) do 5 | UserExport.new(users) 6 | end 7 | 8 | let(:users) do 9 | [ 10 | User.new(1, "John"), 11 | User.new(2, "Ben") 12 | ] 13 | end 14 | 15 | describe "#to_csv" do 16 | it "returns serialized contents" do 17 | content = export.to_csv 18 | expect(content.read).to eq(<<-EOS) 19 | id,Full name,email 20 | 1,JOHN,1@example.com 21 | 2,BEN,2@example.com 22 | EOS 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/xport/export_rubyxl_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Xport::Export, "with rubyXL" do 4 | subject(:export) do 5 | UserExport.new(users) 6 | end 7 | 8 | let(:users) do 9 | [ 10 | User.new(1, "John"), 11 | User.new(2, "Ben") 12 | ] 13 | end 14 | 15 | describe "#to_xlsx" do 16 | it "returns serialized contents" do 17 | export = UserExport.new(users) 18 | formatter = Xport::RubyXL::Formatter.new(export) 19 | content = export.to_file(formatter) 20 | file = Tempfile.new(['export', '.xlsx'], encoding: 'ascii-8bit') 21 | file.write content.read 22 | file.rewind 23 | rows = [] 24 | Saxlsx::Workbook.open(file.path) do |doc| 25 | rows = doc.sheets[0].rows.to_a 26 | end 27 | expect(rows).to eq([ 28 | ["id", "Full name", "email"], 29 | [1.0, "JOHN", "1@example.com"], 30 | [2.0, "BEN", "2@example.com"] 31 | ]) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/xport/export_xlsxtream_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Xport::Export, "with xlsxtream" do 4 | subject(:export) do 5 | UserExport.new(users) 6 | end 7 | 8 | let(:users) do 9 | [ 10 | User.new(1, "John"), 11 | User.new(2, "Ben") 12 | ] 13 | end 14 | 15 | describe "#to_xlsx" do 16 | it "returns serialized contents" do 17 | export = UserExport.new(users) 18 | formatter = Xport::Xlsxtream::Formatter.new(export) 19 | content = export.to_file(formatter) 20 | file = Tempfile.new(['export', '.xlsx'], encoding: 'ascii-8bit') 21 | file.write content.read 22 | file.rewind 23 | rows = [] 24 | Saxlsx::Workbook.open(file.path) do |doc| 25 | rows = doc.sheets[0].rows.to_a 26 | end 27 | expect(rows).to eq([ 28 | ["id", "Full name", "email"], 29 | [1.0, "JOHN", "1@example.com"], 30 | [2.0, "BEN", "2@example.com"] 31 | ]) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/xport_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Xport do 4 | it "has a version number" do 5 | expect(Xport::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /xport.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'xport/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'xport' 9 | s.version = Xport::VERSION 10 | s.authors = ['Janis Vitols', 'Edgars Beigarts'] 11 | s.summary = 'CSV/Excel exports with own DSL and background jobs' 12 | 13 | s.files = Dir['{lib}/**/*'] 14 | 15 | s.add_dependency 'activesupport' 16 | 17 | s.add_development_dependency 'bundler', "~> 1.5" 18 | s.add_development_dependency 'rake', '~> 10.1' 19 | s.add_development_dependency 'rspec', '~> 3.6' 20 | s.add_development_dependency 'saxlsx' 21 | end 22 | --------------------------------------------------------------------------------