activeadmin_batched_export 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/CODE_OF_CONDUCT.md +38 -0
  4. data/CONTRIBUTING.md +29 -0
  5. data/GOVERNANCE.md +32 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +208 -0
  8. data/SECURITY.md +28 -0
  9. data/activeadmin_batched_export.gemspec +67 -0
  10. data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +188 -0
  11. data/app/views/active_admin/batched_export/_actions.html.erb +25 -0
  12. data/app/views/active_admin/batched_export/_columns.html.erb +21 -0
  13. data/app/views/active_admin/batched_export/_filters.html.erb +11 -0
  14. data/app/views/active_admin/batched_export/_progress.html.erb +14 -0
  15. data/app/views/active_admin/batched_export/_summary.html.erb +40 -0
  16. data/app/views/active_admin/batched_export/workspace.html.erb +22 -0
  17. data/app/views/active_admin/shared/_download_format_links.html.erb +7 -0
  18. data/config/locales/activeadmin_batched_export.en.yml +29 -0
  19. data/config/polyrun_coverage.yml +7 -0
  20. data/lib/activeadmin/batched_export/configuration.rb +37 -0
  21. data/lib/activeadmin/batched_export/controller_methods.rb +276 -0
  22. data/lib/activeadmin/batched_export/engine.rb +45 -0
  23. data/lib/activeadmin/batched_export/export_macro_catalog.rb +77 -0
  24. data/lib/activeadmin/batched_export/export_macro_resolver.rb +73 -0
  25. data/lib/activeadmin/batched_export/install.rb +33 -0
  26. data/lib/activeadmin/batched_export/resource_extension.rb +65 -0
  27. data/lib/activeadmin/batched_export/styles.rb +45 -0
  28. data/lib/activeadmin/batched_export/version.rb +7 -0
  29. data/lib/activeadmin/batched_export.rb +25 -0
  30. data/lib/activeadmin_batched_export.rb +5 -0
  31. data/sig/activeadmin/batched_export.rbs +5 -0
  32. metadata +377 -0
@@ -0,0 +1,11 @@
1
+ <div class="<%= styles[:card] %>">
2
+ <%= render partial: "active_admin/resource/active_filters", locals: {active_filters: @batched_export_active_filters} %>
3
+ </div>
4
+
5
+ <div class="space-y-2">
6
+ <h2 class="<%= styles[:heading] %>"><%= t("active_admin.batched_export") %></h2>
7
+ <p class="<%= styles[:hint] %>"><%= t("active_admin.batched_export_page.blurb") %></p>
8
+ <% if @batched_export_large_export %>
9
+ <p class="<%= styles[:warning] %>"><%= t("active_admin.batched_export_page.large_export_warning") %></p>
10
+ <% end %>
11
+ </div>
@@ -0,0 +1,14 @@
1
+ <div class="<%= styles[:progress_wrap] %>" data-<%= stimulus_controller %>-target="progressWrap">
2
+ <div class="<%= styles[:progress_status_row] %>">
3
+ <span data-<%= stimulus_controller %>-target="status"><%= t("active_admin.batched_export_page.preparing") %></span>
4
+ <span data-<%= stimulus_controller %>-target="fraction"></span>
5
+ </div>
6
+ <progress
7
+ value="0"
8
+ max="100"
9
+ class="<%= styles[:progress_bar] %>"
10
+ data-<%= stimulus_controller %>-target="bar"
11
+ ></progress>
12
+ </div>
13
+
14
+ <p class="<%= styles[:error] %>" data-<%= stimulus_controller %>-target="error"></p>
@@ -0,0 +1,40 @@
1
+ <% preview = @batched_export_preview %>
2
+ <div class="<%= styles[:card] %>">
3
+ <h3 class="<%= styles[:card_title] %>"><%= t("active_admin.batched_export_page.summary_title") %></h3>
4
+ <table class="<%= styles[:table] %>">
5
+ <tbody class="<%= styles[:table_body] %>">
6
+ <tr class="<%= styles[:table_row] %>">
7
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.resource_label") %></th>
8
+ <td class="<%= styles[:table_cell] %>"><%= active_admin_config.plural_resource_label %></td>
9
+ </tr>
10
+ <tr class="<%= styles[:table_row] %>">
11
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.scope_label") %></th>
12
+ <td class="<%= styles[:table_cell] %>"><%= current_scope ? scope_name(current_scope) : "—" %></td>
13
+ </tr>
14
+ <tr class="<%= styles[:table_row] %>">
15
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.sort_label") %></th>
16
+ <td class="<%= styles[:table_cell] %>"><%= @batched_export_sort_description %></td>
17
+ </tr>
18
+ <tr class="<%= styles[:table_row] %>">
19
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.format_label") %></th>
20
+ <td class="<%= styles[:table_cell] %>"><%= preview[:export_format].to_s.upcase %></td>
21
+ </tr>
22
+ <tr class="<%= styles[:table_row] %>">
23
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.total_rows_label") %></th>
24
+ <td class="<%= styles[:table_cell] %>"><%= number_with_delimiter(preview[:total_count]) %></td>
25
+ </tr>
26
+ <tr class="<%= styles[:table_row] %>">
27
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.batches_label") %></th>
28
+ <td class="<%= styles[:table_cell] %>"><%= number_with_delimiter(preview[:total_batches]) %></td>
29
+ </tr>
30
+ <tr class="<%= styles[:table_row] %>">
31
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.batch_size_label") %></th>
32
+ <td class="<%= styles[:table_cell] %>"><%= number_with_delimiter(preview[:batch_size]) %></td>
33
+ </tr>
34
+ <tr>
35
+ <th scope="row" class="<%= styles[:table_header] %>"><%= t("active_admin.batched_export_page.filename_label") %></th>
36
+ <td class="<%= styles[:table_cell_mono] %>"><%= preview[:filename] %></td>
37
+ </tr>
38
+ </tbody>
39
+ </table>
40
+ </div>
@@ -0,0 +1,22 @@
1
+ <% styles = @batched_export_styles %>
2
+ <% stimulus_controller = @batched_export_stimulus_controller %>
3
+ <div
4
+ class="<%= styles[:workspace] %>"
5
+ data-controller="<%= stimulus_controller %>"
6
+ data-<%= stimulus_controller %>-meta-url-value="<%= @batched_export_meta_url %>"
7
+ data-<%= stimulus_controller %>-meta-value="<%= @batched_export_preview.to_json %>"
8
+ data-<%= stimulus_controller %>-batch-base-url-value="<%= @batched_export_batch_base_url %>"
9
+ data-<%= stimulus_controller %>-format-value="<%= @batched_export_format %>"
10
+ data-<%= stimulus_controller %>-preparing-message-value="<%= t("active_admin.batched_export_page.preparing") %>"
11
+ data-<%= stimulus_controller %>-loading-batch-template-value="<%= t("active_admin.batched_export_page.loading_batch") %>"
12
+ data-<%= stimulus_controller %>-empty-message-value="<%= t("active_admin.batched_export_page.empty") %>"
13
+ data-<%= stimulus_controller %>-failed-batch-template-value="<%= t("active_admin.batched_export_page.failed_batch") %>"
14
+ data-<%= stimulus_controller %>-ready-message-value="<%= t("active_admin.batched_export_page.ready") %>"
15
+ data-<%= stimulus_controller %>-needs-column-message-value="<%= t("active_admin.batched_export_page.select_at_least_one_column") %>"
16
+ >
17
+ <%= render "active_admin/batched_export/summary", styles: styles %>
18
+ <%= render "active_admin/batched_export/columns", styles: styles, stimulus_controller: stimulus_controller %>
19
+ <%= render "active_admin/batched_export/filters", styles: styles %>
20
+ <%= render "active_admin/batched_export/progress", styles: styles, stimulus_controller: stimulus_controller %>
21
+ <%= render "active_admin/batched_export/actions", styles: styles, stimulus_controller: stimulus_controller %>
22
+ </div>
@@ -0,0 +1,7 @@
1
+ <% export_base_params = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta) %>
2
+ <div>
3
+ <span><%= I18n.t("active_admin.download") %></span>
4
+ <% formats.each do |format| %>
5
+ <%= link_to format.upcase, url_for(action: :batched_export, params: export_base_params.merge(export_format: format)) %>
6
+ <% end %>
7
+ </div>
@@ -0,0 +1,29 @@
1
+ en:
2
+ active_admin:
3
+ batched_export: Export download
4
+ batched_export_page:
5
+ back_to_list: Back to list
6
+ batch_size_label: Rows per batch
7
+ batches_label: Download batches
8
+ blurb: The export loads in separate batches (one HTTP request per batch). Rows are not shown on this page. When loading finishes, use Save file to store the export on your computer.
9
+ columns_hint: All columns are included by default. Uncheck any you do not need.
10
+ columns_title: Columns to include
11
+ empty: There is nothing to export for this filter.
12
+ failed_batch: "Could not load batch %{page}: %{message}"
13
+ filename_label: File name
14
+ format_label: File format
15
+ large_export_warning: This export is large. The browser keeps the full file in memory before you save it. Narrow filters or export fewer columns if the tab becomes slow.
16
+ loading_batch: Loading batch %{current} of %{total}
17
+ preparing: Preparing…
18
+ ready: Ready. You can save the file now.
19
+ resource_label: Resource
20
+ save_file: Save file
21
+ scope_label: Scope
22
+ select_at_least_one_column: Select at least one column to export.
23
+ sort_ascending: ascending
24
+ sort_descending: descending
25
+ sort_label: Sort order
26
+ start: Load export
27
+ summary_title: What you are exporting
28
+ total_rows_label: Rows to export
29
+ unknown_macro: Unknown export macro
@@ -0,0 +1,7 @@
1
+ track_under:
2
+ - lib
3
+ minimum_line_percent: 50
4
+ report_formats:
5
+ - html
6
+ - cobertura
7
+ - console
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activeadmin/batched_export/styles"
4
+ require "activeadmin/batched_export/export_macro_catalog"
5
+
6
+ module ActiveAdmin
7
+ module BatchedExport
8
+ class Configuration
9
+ attr_accessor :styles, :batch_size, :max_batch_size, :large_export_row_threshold, :stimulus_controller,
10
+ :default_enabled, :default_column_selection
11
+
12
+ def initialize
13
+ @styles = Styles.new
14
+ @batch_size = 1000
15
+ @max_batch_size = 10_000
16
+ @large_export_row_threshold = 25_000
17
+ @stimulus_controller = "activeadmin-batched-export--batched-export"
18
+ @default_enabled = true
19
+ @default_column_selection = true
20
+ @filename_proc = nil
21
+ @registered_macros = {}
22
+ end
23
+
24
+ attr_reader :filename_proc, :registered_macros
25
+
26
+ def filename_proc=(callable)
27
+ raise ArgumentError, "filename_proc must respond to :call" unless callable.respond_to?(:call)
28
+
29
+ @filename_proc = callable
30
+ end
31
+
32
+ def register_macro(name, callable)
33
+ ExportMacroCatalog.register(name, callable, registry: @registered_macros)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "builder"
4
+ require "csv"
5
+
6
+ module ActiveAdmin
7
+ module BatchedExport
8
+ module ControllerMethods
9
+ extend ActiveSupport::Concern
10
+
11
+ def batched_export
12
+ authorize! ActiveAdmin::Authorization::READ, active_admin_config.resource_class
13
+ export_format = normalized_export_format
14
+ return if export_format.nil?
15
+
16
+ if request.format.json? && params[:export_meta].present?
17
+ ensure_batch_download_format_allowed!(export_format)
18
+ return render(json: batched_export_meta(export_format))
19
+ end
20
+
21
+ batch_page = params[:batch_page].to_i
22
+ if batch_page.positive?
23
+ if request.format.symbol != export_format
24
+ head :not_acceptable
25
+ return
26
+ end
27
+ ensure_batch_download_format_allowed!(export_format)
28
+ page = page_relation(batch_page)
29
+ if page.out_of_range?
30
+ head :not_found
31
+ return
32
+ end
33
+ begin
34
+ body = batched_export_batch_body(export_format, batch_page, page: page)
35
+ rescue ExportMacroCatalog::UnknownMacroError
36
+ return render(
37
+ plain: I18n.t("active_admin.batched_export_page.unknown_macro"),
38
+ status: :unprocessable_content
39
+ )
40
+ end
41
+ return render(plain: body, content_type: batch_content_type(export_format))
42
+ end
43
+
44
+ ensure_batch_download_format_allowed!(export_format)
45
+ @batched_export_format = export_format
46
+ @batched_export_meta_url = batched_export_url_for(
47
+ request_format: :json,
48
+ extra_params: {"export_meta" => "1", "export_format" => export_format.to_s}
49
+ )
50
+ @batched_export_batch_base_url = batched_export_url_for(
51
+ request_format: export_format,
52
+ extra_params: {"export_format" => export_format.to_s}
53
+ )
54
+ @batched_export_styles = BatchedExport.styles
55
+ @batched_export_stimulus_controller = BatchedExport.config.stimulus_controller
56
+ assign_batched_export_workspace_extras!(export_format)
57
+ render "active_admin/batched_export/workspace", layout: "active_admin"
58
+ end
59
+
60
+ private
61
+
62
+ def normalized_export_format
63
+ format_name = params[:export_format].to_s.downcase
64
+ format_name = "csv" if format_name.blank?
65
+ unless %w[csv xml json].include?(format_name)
66
+ render(plain: "Invalid export format", status: :bad_request)
67
+ return nil
68
+ end
69
+ format_name.to_sym
70
+ end
71
+
72
+ def batched_export_url_for(request_format:, extra_params: {})
73
+ query = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta)
74
+ hash = query.respond_to?(:to_unsafe_h) ? query.to_unsafe_h : query.to_h
75
+ hash = hash.merge(extra_params.stringify_keys)
76
+ url_for(action: :batched_export, format: request_format, params: hash, only_path: true)
77
+ end
78
+
79
+ def ensure_batch_download_format_allowed!(format_symbol)
80
+ return if format_symbol == :html
81
+
82
+ presenter = active_admin_config.get_page_presenter(:index)
83
+ download_links = (presenter || {}).fetch(:download_links, active_admin_config.namespace.download_links)
84
+ allowed = build_download_formats(download_links)
85
+ unless allowed.include?(format_symbol)
86
+ raise ActiveAdmin::AccessDenied.new(current_active_admin_user, :index)
87
+ end
88
+ end
89
+
90
+ def batched_export_meta(export_format)
91
+ base = find_collection(except: [:pagination])
92
+ first_page = paginate(base, 1, effective_batch_size)
93
+ total_count = first_page.total_count
94
+ total_batches = total_count.zero? ? 0 : first_page.total_pages
95
+ {
96
+ export_format: export_format,
97
+ total_count: total_count,
98
+ total_batches: total_batches,
99
+ batch_size: effective_batch_size,
100
+ filename: export_filename(export_format),
101
+ large_export: total_count >= BatchedExport.config.large_export_row_threshold
102
+ }
103
+ end
104
+
105
+ def export_filename(format_symbol)
106
+ filename_proc = active_admin_config.batched_export_filename_proc
107
+ if filename_proc
108
+ return filename_proc.call(active_admin_config, format_symbol, self)
109
+ end
110
+
111
+ base = resource_collection_name.to_s.tr("_", "-")
112
+ "#{base}-#{Time.zone.now.to_date}.#{format_symbol}"
113
+ end
114
+
115
+ def batched_export_batch_body(export_format, batch_page, page: nil)
116
+ case export_format
117
+ when :csv then batched_csv_chunk(batch_page, page: page)
118
+ when :json then batched_json_chunk(batch_page, page: page)
119
+ when :xml then batched_xml_chunk(batch_page, page: page)
120
+ else ""
121
+ end
122
+ end
123
+
124
+ def batch_content_type(export_format)
125
+ case export_format
126
+ when :csv then "text/csv; charset=utf-8"
127
+ when :json then "application/json; charset=utf-8"
128
+ when :xml then "application/xml; charset=utf-8"
129
+ else "text/plain; charset=utf-8"
130
+ end
131
+ end
132
+
133
+ def batched_csv_chunk(batch_page, page: nil)
134
+ builder = active_admin_config.csv_builder
135
+ options = builder.options.dup
136
+ csv_options = options.except(:encoding_options, :humanize_name, :byte_order_mark)
137
+ columns = batched_export_filter_columns(builder.exec_columns(view_context))
138
+ buffer = +""
139
+ byte_order_mark = options[:byte_order_mark]
140
+ buffer << byte_order_mark if batch_page == 1 && byte_order_mark
141
+ if batch_page == 1 && options.fetch(:column_names, true)
142
+ header_line = columns.map do |column|
143
+ ActiveAdmin::Sanitizer.sanitize(builder.send(:encode, column.name, options))
144
+ end
145
+ buffer << CSV.generate_line(header_line, **csv_options)
146
+ end
147
+ paginated_export_rows(batch_page, page: page) do |resource|
148
+ row = builder.build_row(resource, columns, options)
149
+ row = apply_export_macros(row, columns, resource)
150
+ buffer << CSV.generate_line(row, **csv_options)
151
+ end
152
+ buffer
153
+ end
154
+
155
+ def batched_json_chunk(batch_page, page: nil)
156
+ builder = active_admin_config.csv_builder
157
+ options = builder.options
158
+ columns = batched_export_filter_columns(builder.exec_columns(view_context))
159
+ names = columns.map(&:name)
160
+ rows = []
161
+ paginated_export_rows(batch_page, page: page) do |resource|
162
+ row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
163
+ rows << names.zip(row).to_h
164
+ end
165
+ rows.to_json
166
+ end
167
+
168
+ def batched_xml_chunk(batch_page, page: nil)
169
+ builder = active_admin_config.csv_builder
170
+ options = builder.options
171
+ columns = batched_export_filter_columns(builder.exec_columns(view_context))
172
+ xml = Builder::XmlMarkup.new(indent: 0)
173
+ paginated_export_rows(batch_page, page: page) do |resource|
174
+ row = apply_export_macros(builder.build_row(resource, columns, options), columns, resource)
175
+ xml.batch do
176
+ xml.record do
177
+ columns.each_with_index do |column, index|
178
+ xml.field("name" => column.name) { xml.text!(row[index].to_s) }
179
+ end
180
+ end
181
+ end
182
+ end
183
+ xml.target!
184
+ end
185
+
186
+ def apply_export_macros(row, columns, resource)
187
+ ExportMacroResolver.apply(
188
+ row: row,
189
+ columns: columns,
190
+ resource: resource,
191
+ resource_settings: active_admin_config.batched_export_settings,
192
+ registry: merged_macro_registry
193
+ )
194
+ end
195
+
196
+ def merged_macro_registry
197
+ BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
198
+ end
199
+
200
+ def paginated_export_rows(batch_page, page: nil)
201
+ (page || page_relation(batch_page)).each do |resource|
202
+ yield apply_decorator(resource)
203
+ end
204
+ end
205
+
206
+ def page_relation(page)
207
+ collection = find_collection(except: [:pagination])
208
+ includes_list = active_admin_config.batched_export_includes
209
+ collection = collection.includes(includes_list) if includes_list.present?
210
+ paginate(collection, page, effective_batch_size)
211
+ end
212
+
213
+ def effective_batch_size
214
+ active_admin_config.batched_export_effective_batch_size
215
+ end
216
+
217
+ def assign_batched_export_workspace_extras!(export_format)
218
+ @batched_export_preview = batched_export_meta(export_format)
219
+ @batched_export_csv_columns =
220
+ if active_admin_config.batched_export_column_selection?
221
+ batched_export_csv_column_metadata
222
+ else
223
+ []
224
+ end
225
+ summary_chain = scoped_collection
226
+ summary_chain = apply_authorization_scope(summary_chain)
227
+ ransack_search = summary_chain.ransack(
228
+ params[:q] || {},
229
+ auth_object: active_admin_authorization
230
+ )
231
+ @batched_export_active_filters =
232
+ ActiveAdmin::Filters::Active.new(active_admin_config, ransack_search)
233
+ @batched_export_sort_description = batched_export_sort_description
234
+ @batched_export_large_export = @batched_export_preview[:large_export]
235
+ end
236
+
237
+ def batched_export_sort_description
238
+ order_param = params[:order].presence || active_admin_config.sort_order
239
+ clause = ActiveAdmin::OrderClause.new(active_admin_config, order_param)
240
+ return order_param.to_s.tr("_", " ").strip.presence || "—" unless clause.valid?
241
+
242
+ attribute = active_admin_config.resource_class.human_attribute_name(clause.field)
243
+ direction =
244
+ if clause.order == "desc"
245
+ I18n.t("active_admin.batched_export_page.sort_descending")
246
+ else
247
+ I18n.t("active_admin.batched_export_page.sort_ascending")
248
+ end
249
+ "#{attribute} — #{direction}"
250
+ end
251
+
252
+ def batched_export_csv_column_metadata
253
+ builder = active_admin_config.csv_builder
254
+ columns = builder.exec_columns(view_context)
255
+ columns.each_with_index.map do |column, index|
256
+ {index: index, label: column.name}
257
+ end
258
+ end
259
+
260
+ def batched_export_selected_indices
261
+ raw = params[:export_columns].presence || params[:export_column_indices]
262
+ return [] if raw.blank?
263
+
264
+ Array(raw).filter_map { |value| Integer(value, exception: false) }.select { |index| index >= 0 }
265
+ end
266
+
267
+ def batched_export_filter_columns(columns)
268
+ indices = batched_export_selected_indices
269
+ return columns if indices.empty?
270
+
271
+ resolved = indices.filter_map { |index| columns[index] }
272
+ resolved.presence || columns
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class Engine < ::Rails::Engine
6
+ engine_name "activeadmin_batched_export"
7
+
8
+ initializer "activeadmin_batched_export.load_lib" do
9
+ require "activeadmin/batched_export"
10
+ require "activeadmin/batched_export/styles"
11
+ require "activeadmin/batched_export/export_macro_catalog"
12
+ require "activeadmin/batched_export/export_macro_resolver"
13
+ require "activeadmin/batched_export/configuration"
14
+ require "activeadmin/batched_export/resource_extension"
15
+ require "activeadmin/batched_export/controller_methods"
16
+ require "activeadmin/batched_export/install"
17
+ end
18
+
19
+ initializer "activeadmin_batched_export.assets" do |app|
20
+ assets_path = root.join("app/assets")
21
+ app.config.importmap.cache_sweepers << assets_path.join("controllers") if app.config.respond_to?(:importmap)
22
+ end
23
+
24
+ initializer "activeadmin_batched_export.importmap", after: :load_config_initializers do
25
+ next unless Rails.application.respond_to?(:importmap) && Rails.application.importmap
26
+
27
+ pin_controller = proc do |importmap|
28
+ importmap.pin "controllers/activeadmin_batched_export/batched_export_controller",
29
+ to: "activeadmin_batched_export/batched_export_controller.js"
30
+ end
31
+
32
+ Rails.application.importmap.draw(&pin_controller)
33
+ ActiveAdmin.importmap.draw(&pin_controller) if defined?(ActiveAdmin)
34
+ end
35
+
36
+ initializer "activeadmin_batched_export.i18n" do
37
+ I18n.load_path << root.join("config/locales/activeadmin_batched_export.en.yml")
38
+ end
39
+
40
+ config.to_prepare do
41
+ ActiveAdmin::BatchedExport::Install.call if defined?(ActiveAdmin)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module ActiveAdmin
7
+ module BatchedExport
8
+ class ExportMacroCatalog
9
+ class UnknownMacroError < KeyError; end
10
+
11
+ def self.register(name, callable, registry: nil)
12
+ key = name.to_sym
13
+ raise ArgumentError, "macro must respond to :call" unless callable.respond_to?(:call)
14
+
15
+ target = registry || global_registry
16
+ target[key] = callable
17
+ end
18
+
19
+ def self.fetch(name, registry: nil)
20
+ key = name.to_sym
21
+ target = registry || global_registry
22
+ target[key] || built_ins[key] || raise(UnknownMacroError, "unknown export macro: #{name}")
23
+ end
24
+
25
+ def self.resolve(name, registry: nil)
26
+ macro = name
27
+ return macro if macro.respond_to?(:call)
28
+
29
+ fetch(macro, registry: registry)
30
+ end
31
+
32
+ def self.global_registry
33
+ @global_registry ||= {}
34
+ end
35
+
36
+ def self.built_ins
37
+ @built_ins ||= {
38
+ mask_email: lambda { |value, _record, _column|
39
+ next nil if value.blank?
40
+
41
+ local, domain = value.to_s.split("@", 2)
42
+ next "[redacted]" if local.blank? || domain.blank?
43
+
44
+ visible = local[0, 2] || ""
45
+ "#{visible}***@#{domain}"
46
+ },
47
+ mask_phone: lambda { |value, _record, _column|
48
+ next nil if value.blank?
49
+
50
+ digits = value.to_s.gsub(/\D/, "")
51
+ next "[redacted]" if digits.length < 4
52
+
53
+ "#{"*" * (digits.length - 4)}#{digits[-4, 4]}"
54
+ },
55
+ truncate_middle: lambda { |value, _record, _column|
56
+ next nil if value.blank?
57
+
58
+ text = value.to_s
59
+ next text if text.length <= 8
60
+
61
+ "#{text[0, 3]}…#{text[-3, 3]}"
62
+ },
63
+ hash_identifier: lambda { |value, _record, _column|
64
+ next nil if value.blank?
65
+
66
+ Digest::SHA256.hexdigest(value.to_s)[0, 12]
67
+ },
68
+ redact: lambda { |_value, _record, _column|
69
+ "[redacted]"
70
+ }
71
+ }.freeze
72
+ end
73
+
74
+ private_class_method :built_ins
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module BatchedExport
5
+ class ExportMacroResolver
6
+ MAX_MACROS = 50
7
+
8
+ def self.apply(row:, columns:, resource:, resource_settings:, registry:)
9
+ new(
10
+ row: row,
11
+ columns: columns,
12
+ resource: resource,
13
+ resource_settings: resource_settings,
14
+ registry: registry
15
+ ).apply
16
+ end
17
+
18
+ def initialize(row:, columns:, resource:, resource_settings:, registry:)
19
+ @row = row.dup
20
+ @columns = columns
21
+ @resource = resource
22
+ @resource_settings = resource_settings || {}
23
+ @registry = registry
24
+ @resource_macros = normalize_macro_map(@resource_settings[:macros] || @resource_settings["macros"])
25
+ end
26
+
27
+ def apply
28
+ @columns.each_with_index do |column, index|
29
+ macro = macro_for(column)
30
+ next if macro.nil?
31
+
32
+ @row[index] = macro.call(@row[index], @resource, column)
33
+ end
34
+ @row
35
+ end
36
+
37
+ private
38
+
39
+ def macro_for(column)
40
+ explicit = column.options[:macro] if column.respond_to?(:options)
41
+ return resolve_macro(explicit) unless explicit.nil?
42
+
43
+ keys = macro_lookup_keys(column)
44
+ keys.each do |key|
45
+ mapped = @resource_macros[key]
46
+ return resolve_macro(mapped) unless mapped.nil?
47
+ end
48
+
49
+ nil
50
+ end
51
+
52
+ def macro_lookup_keys(column)
53
+ keys = []
54
+ keys << column.data if column.respond_to?(:data) && column.data.is_a?(Symbol)
55
+ keys << column.name if column.respond_to?(:name)
56
+ keys.map { |key| key.to_s.downcase.tr(" ", "_") }.uniq
57
+ end
58
+
59
+ def normalize_macro_map(macros)
60
+ return {} unless macros.is_a?(Hash)
61
+ raise ArgumentError, "too many export macros" if macros.size > MAX_MACROS
62
+
63
+ macros.each_with_object({}) do |(key, value), normalized|
64
+ normalized[key.to_s.downcase.tr(" ", "_")] = value
65
+ end
66
+ end
67
+
68
+ def resolve_macro(macro)
69
+ ExportMacroCatalog.resolve(macro, registry: @registry)
70
+ end
71
+ end
72
+ end
73
+ end