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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +38 -0
- data/CONTRIBUTING.md +29 -0
- data/GOVERNANCE.md +32 -0
- data/LICENSE.md +21 -0
- data/README.md +208 -0
- data/SECURITY.md +28 -0
- data/activeadmin_batched_export.gemspec +67 -0
- data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +188 -0
- data/app/views/active_admin/batched_export/_actions.html.erb +25 -0
- data/app/views/active_admin/batched_export/_columns.html.erb +21 -0
- data/app/views/active_admin/batched_export/_filters.html.erb +11 -0
- data/app/views/active_admin/batched_export/_progress.html.erb +14 -0
- data/app/views/active_admin/batched_export/_summary.html.erb +40 -0
- data/app/views/active_admin/batched_export/workspace.html.erb +22 -0
- data/app/views/active_admin/shared/_download_format_links.html.erb +7 -0
- data/config/locales/activeadmin_batched_export.en.yml +29 -0
- data/config/polyrun_coverage.yml +7 -0
- data/lib/activeadmin/batched_export/configuration.rb +37 -0
- data/lib/activeadmin/batched_export/controller_methods.rb +276 -0
- data/lib/activeadmin/batched_export/engine.rb +45 -0
- data/lib/activeadmin/batched_export/export_macro_catalog.rb +77 -0
- data/lib/activeadmin/batched_export/export_macro_resolver.rb +73 -0
- data/lib/activeadmin/batched_export/install.rb +33 -0
- data/lib/activeadmin/batched_export/resource_extension.rb +65 -0
- data/lib/activeadmin/batched_export/styles.rb +45 -0
- data/lib/activeadmin/batched_export/version.rb +7 -0
- data/lib/activeadmin/batched_export.rb +25 -0
- data/lib/activeadmin_batched_export.rb +5 -0
- data/sig/activeadmin/batched_export.rbs +5 -0
- 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,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
|