-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
/
csv_builder.rb
144 lines (118 loc) · 3.97 KB
/
csv_builder.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# frozen_string_literal: true
module ActiveAdmin
# CSVBuilder stores CSV configuration
#
# Usage example:
#
# csv_builder = CSVBuilder.new
# csv_builder.column :id
# csv_builder.column("Name") { |resource| resource.full_name }
# csv_builder.column(:name, humanize_name: false)
# csv_builder.column("name", humanize_name: false) { |resource| resource.full_name }
#
# csv_builder = CSVBuilder.new col_sep: ";"
# csv_builder = CSVBuilder.new humanize_name: false
# csv_builder.column :id
#
#
class CSVBuilder
# Return a default CSVBuilder for a resource
# The CSVBuilder's columns would be Id followed by this
# resource's content columns
def self.default_for_resource(resource)
new resource: resource do
column :id
resource.content_columns.each { |c| column c }
end
end
attr_reader :columns, :options, :view_context
COLUMN_TRANSITIVE_OPTIONS = [:humanize_name].freeze
def initialize(options = {}, &block)
@resource = options.delete(:resource)
@columns = []
@options = ActiveAdmin.application.csv_options.merge options
@block = block
end
def column(name, options = {}, &block)
@columns << Column.new(name, @resource, column_transitive_options.merge(options), block)
end
def build(controller, csv)
columns = exec_columns controller.view_context
bom = options[:byte_order_mark]
column_names = options.delete(:column_names) { true }
csv_options = options.except :encoding_options, :humanize_name, :byte_order_mark
csv << bom if bom
if column_names
csv << CSV.generate_line(columns.map { |c| sanitize(encode(c.name, options)) }, **csv_options)
end
controller.send(:in_paginated_batches) do |resource|
csv << CSV.generate_line(build_row(resource, columns, options), **csv_options)
end
csv
end
def exec_columns(view_context = nil)
@view_context = view_context
@columns = [] # we want to re-render these every instance
instance_exec &@block if @block.present?
columns
end
def build_row(resource, columns, options)
columns.map do |column|
sanitize(encode(call_method_or_proc_on(resource, column.data), options))
end
end
def encode(content, options)
if options[:encoding]
if options[:encoding_options]
content.to_s.encode options[:encoding], **options[:encoding_options]
else
content.to_s.encode options[:encoding]
end
else
content
end
end
def sanitize(content)
Sanitizer.sanitize(content)
end
def method_missing(method, *args, &block)
if @view_context.respond_to? method
@view_context.public_send method, *args, &block
else
super
end
end
class Column
attr_reader :name, :data, :options
DEFAULT_OPTIONS = { humanize_name: true }
def initialize(name, resource = nil, options = {}, block = nil)
@options = options.reverse_merge(DEFAULT_OPTIONS)
@name = humanize_name(name, resource, @options[:humanize_name])
@data = block || name.to_sym
end
def humanize_name(name, resource, humanize_name_option)
if humanize_name_option
name.is_a?(Symbol) && resource ? resource.resource_class.human_attribute_name(name) : name.to_s.humanize
else
name.to_s
end
end
end
private
def column_transitive_options
@column_transitive_options ||= @options.slice(*COLUMN_TRANSITIVE_OPTIONS)
end
end
# Prevents CSV Injection according to https://owasp.org/www-community/attacks/CSV_Injection
module Sanitizer
extend self
ATTACK_CHARACTERS = ['=', '+', '-', '@', "\t", "\r"].freeze
def sanitize(value)
return "'#{value}" if require_sanitization?(value)
value
end
def require_sanitization?(value)
value.is_a?(String) && value.starts_with?(*ATTACK_CHARACTERS)
end
end
end