activeadmin_batched_export 0.2.0 → 0.3.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.
@@ -1,8 +1,22 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import {
3
+ appendExportColumnParams,
4
+ assembleExportParts,
5
+ } from "activeadmin_batched_export/chunk_assembly"
2
6
 
3
- // Progressive ActiveAdmin export: sequential batch fetches + single client-side save.
7
+ // Progressive ActiveAdmin export: sequential cursor fetches + single client-side save.
4
8
  export default class extends Controller {
5
- static targets = ["progressWrap", "status", "fraction", "bar", "error", "start", "save", "columnCheckbox"]
9
+ static targets = [
10
+ "progressWrap",
11
+ "status",
12
+ "fraction",
13
+ "bar",
14
+ "error",
15
+ "start",
16
+ "save",
17
+ "cancel",
18
+ "columnCheckbox",
19
+ ]
6
20
  static values = {
7
21
  metaUrl: String,
8
22
  meta: Object,
@@ -14,6 +28,9 @@ export default class extends Controller {
14
28
  failedBatchTemplate: String,
15
29
  readyMessage: String,
16
30
  needsColumnMessage: String,
31
+ cancelledMessage: String,
32
+ incompleteMessage: String,
33
+ overMaxMessage: String,
17
34
  }
18
35
 
19
36
  connect() {
@@ -21,6 +38,7 @@ export default class extends Controller {
21
38
  this.filename = null
22
39
  this.mime = "application/octet-stream"
23
40
  this.readyBlob = null
41
+ this.abortController = null
24
42
  this.hideError()
25
43
  }
26
44
 
@@ -44,18 +62,47 @@ export default class extends Controller {
44
62
 
45
63
  /** Appends export_columns[]=… from checked column checkboxes (all checked by default). */
46
64
  urlWithExportColumns(urlString) {
47
- const u = new URL(urlString, window.location.origin)
48
- u.searchParams.delete("export_columns[]")
49
- u.searchParams.delete("export_columns")
50
-
51
- if (!this.hasColumnCheckboxTarget) return u.toString()
65
+ if (!this.hasColumnCheckboxTarget) {
66
+ return appendExportColumnParams(urlString, [], window.location.origin)
67
+ }
52
68
 
53
69
  const checked = this.columnCheckboxTargets.filter((cb) => cb.checked)
54
70
  if (checked.length === 0) {
55
71
  throw new Error(this.needsColumnMessageValue)
56
72
  }
57
- checked.forEach((cb) => u.searchParams.append("export_columns[]", cb.value))
58
- return u.toString()
73
+ return appendExportColumnParams(
74
+ urlString,
75
+ checked.map((cb) => cb.value),
76
+ window.location.origin,
77
+ )
78
+ }
79
+
80
+ mimeFor(exportFmt) {
81
+ if (exportFmt === "csv") return "text/csv;charset=utf-8"
82
+ if (exportFmt === "json") return "application/json;charset=utf-8"
83
+ if (exportFmt === "xml") return "application/xml;charset=utf-8"
84
+ return "application/octet-stream"
85
+ }
86
+
87
+ offerSave(filename) {
88
+ this.filename = filename
89
+ this.readyBlob = new Blob(assembleExportParts(this.exportFmt, this.parts), { type: this.mime })
90
+ if (this.hasSaveTarget) {
91
+ this.saveTarget.classList.remove("hidden")
92
+ this.saveTarget.disabled = false
93
+ }
94
+ }
95
+
96
+ setBusy(busy) {
97
+ if (this.hasStartTarget) this.startTarget.disabled = busy
98
+ if (this.hasCancelTarget) {
99
+ this.cancelTarget.classList.toggle("hidden", !busy)
100
+ this.cancelTarget.disabled = !busy
101
+ }
102
+ }
103
+
104
+ cancel() {
105
+ if (this.abortController) this.abortController.abort()
59
106
  }
60
107
 
61
108
  async start() {
@@ -63,13 +110,16 @@ export default class extends Controller {
63
110
  this.parts = []
64
111
  this.readyBlob = null
65
112
  this.filename = null
113
+ this.baseFilename = null
114
+ this.exportFmt = this.formatValue
115
+ this.abortController = new AbortController()
116
+ const { signal } = this.abortController
66
117
 
67
- if (this.hasStartTarget) this.startTarget.disabled = true
118
+ this.setBusy(true)
68
119
  if (this.hasSaveTarget) {
69
120
  this.saveTarget.disabled = true
70
121
  this.saveTarget.classList.add("hidden")
71
122
  }
72
-
73
123
  if (this.hasProgressWrapTarget) this.progressWrapTarget.classList.remove("hidden")
74
124
  if (this.hasBarTarget) {
75
125
  this.barTarget.value = 0
@@ -79,100 +129,90 @@ export default class extends Controller {
79
129
  if (this.hasFractionTarget) this.fractionTarget.textContent = ""
80
130
 
81
131
  try {
82
- const batchBaseUrl = this.urlWithExportColumns(this.batchBaseUrlValue)
83
-
84
- let meta = this.hasMetaValue ? this.metaValue : null
85
- if (!meta || typeof meta.total_batches !== "number") {
86
- const metaUrl = this.urlWithExportColumns(this.metaUrlValue)
87
- const metaRes = await fetch(metaUrl, {
88
- credentials: "same-origin",
89
- headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
90
- })
91
- if (!metaRes.ok) throw new Error(`${metaRes.status} ${metaRes.statusText}`)
92
- meta = await metaRes.json()
93
- }
94
-
95
- this.filename = meta.filename
96
- const total = meta.total_batches
97
- const exportFmt = meta.export_format || this.formatValue
132
+ await this.runExport(signal)
133
+ } catch (err) {
134
+ this.keepPartialFile(err)
135
+ } finally {
136
+ this.setBusy(false)
137
+ this.abortController = null
138
+ }
139
+ }
98
140
 
99
- if (total === 0) {
100
- if (this.hasStatusTarget) this.statusTarget.textContent = this.emptyMessageValue
101
- if (this.hasBarTarget) this.barTarget.removeAttribute("value")
102
- if (this.hasStartTarget) this.startTarget.disabled = false
103
- return
104
- }
141
+ async runExport(signal) {
142
+ const batchBaseUrl = this.urlWithExportColumns(this.batchBaseUrlValue)
143
+ const metaUrl = this.urlWithExportColumns(this.metaUrlValue)
144
+ const metaRes = await fetch(metaUrl, {
145
+ credentials: "same-origin",
146
+ signal,
147
+ headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
148
+ })
149
+ if (!metaRes.ok) throw new Error(this.failedBatchTemplateValue.replace("%{page}", "1").replace("%{message}", "metadata"))
150
+ const meta = await metaRes.json()
151
+
152
+ this.exportFmt = meta.export_format || this.formatValue
153
+ this.mime = this.mimeFor(this.exportFmt)
154
+ const total = meta.total_batches
155
+ this.baseFilename = meta.filename
156
+
157
+ if (meta.over_max) throw new Error(this.overMaxMessageValue)
158
+
159
+ if (total === 0) {
160
+ if (this.hasStatusTarget) this.statusTarget.textContent = this.emptyMessageValue
161
+ if (this.hasBarTarget) this.barTarget.removeAttribute("value")
162
+ return
163
+ }
105
164
 
106
- if (exportFmt === "csv") {
107
- this.mime = "text/csv;charset=utf-8"
108
- } else if (exportFmt === "json") {
109
- this.mime = "application/json;charset=utf-8"
110
- } else if (exportFmt === "xml") {
111
- this.mime = "application/xml;charset=utf-8"
165
+ let cursor = null
166
+ let snapshotToken = null
167
+ let page = 0
168
+ while (true) {
169
+ page += 1
170
+ if (this.hasStatusTarget) this.statusTarget.textContent = this.loadingLabel(page, total)
171
+ if (this.hasFractionTarget) this.fractionTarget.textContent = `${page} / ${total}`
172
+ if (this.hasBarTarget) {
173
+ this.barTarget.value = Math.min(99, Math.round((100 * page) / Math.max(total, page)))
112
174
  }
113
175
 
114
- const collectedJsonRows = []
115
- const xmlFragments = []
116
-
117
- for (let page = 1; page <= total; page += 1) {
118
- if (this.hasStatusTarget) this.statusTarget.textContent = this.loadingLabel(page, total)
119
- if (this.hasFractionTarget) this.fractionTarget.textContent = `${page} / ${total}`
120
-
121
- const batchUrl = new URL(batchBaseUrl, window.location.origin)
122
- batchUrl.searchParams.set("batch_page", String(page))
123
-
124
- const batchRes = await fetch(batchUrl.toString(), {
125
- credentials: "same-origin",
126
- headers: { Accept: "*/*", "X-Requested-With": "XMLHttpRequest" },
127
- })
128
- if (!batchRes.ok) {
129
- const msg = this.failedBatchTemplateValue
176
+ const batchUrl = new URL(batchBaseUrl, window.location.origin)
177
+ batchUrl.searchParams.delete("export_cursor")
178
+ batchUrl.searchParams.delete("export_snapshot")
179
+ if (cursor) batchUrl.searchParams.set("export_cursor", cursor)
180
+ if (snapshotToken) batchUrl.searchParams.set("export_snapshot", snapshotToken)
181
+
182
+ const batchRes = await fetch(batchUrl.toString(), {
183
+ credentials: "same-origin",
184
+ signal,
185
+ headers: { Accept: "*/*", "X-Requested-With": "XMLHttpRequest" },
186
+ })
187
+ if (!batchRes.ok) {
188
+ throw new Error(
189
+ this.failedBatchTemplateValue
130
190
  .replace("%{page}", String(page))
131
- .replace("%{message}", `${batchRes.status} ${batchRes.statusText}`)
132
- throw new Error(msg)
133
- }
134
- const text = await batchRes.text()
135
-
136
- if (exportFmt === "csv") {
137
- this.parts.push(text)
138
- } else if (exportFmt === "json") {
139
- let chunk
140
- try {
141
- chunk = JSON.parse(text)
142
- } catch (parseErr) {
143
- throw new Error(
144
- this.failedBatchTemplateValue
145
- .replace("%{page}", String(page))
146
- .replace("%{message}", parseErr.message || "invalid JSON"),
147
- )
148
- }
149
- collectedJsonRows.push(...chunk)
150
- } else if (exportFmt === "xml") {
151
- xmlFragments.push(text.trim())
152
- }
153
-
154
- if (this.hasBarTarget) this.barTarget.value = Math.round((100 * page) / total)
191
+ .replace("%{message}", "stopped"),
192
+ )
155
193
  }
194
+ const text = await batchRes.text()
195
+ this.parts.push(this.exportFmt === "xml" ? text.trim() : text)
156
196
 
157
- if (exportFmt === "json") {
158
- this.parts = [JSON.stringify(collectedJsonRows)]
159
- } else if (exportFmt === "xml") {
160
- this.parts = [
161
- `<?xml version="1.0" encoding="UTF-8"?>\n<export>\n${xmlFragments.join("\n")}\n</export>\n`,
162
- ]
163
- }
197
+ const nextSnapshot = batchRes.headers.get("X-Batched-Export-Snapshot")
198
+ if (nextSnapshot) snapshotToken = nextSnapshot
199
+ cursor = batchRes.headers.get("X-Batched-Export-Next")
200
+ if (!cursor) break
201
+ }
164
202
 
165
- this.readyBlob = new Blob(this.parts, { type: this.mime })
166
- if (this.hasSaveTarget) {
167
- this.saveTarget.classList.remove("hidden")
168
- this.saveTarget.disabled = false
169
- }
170
- if (this.hasStatusTarget) this.statusTarget.textContent = this.readyMessageValue
171
- } catch (err) {
172
- this.showError(err.message || String(err))
173
- } finally {
174
- if (this.hasStartTarget) this.startTarget.disabled = false
203
+ if (this.hasBarTarget) this.barTarget.value = 100
204
+ this.offerSave(this.baseFilename)
205
+ if (this.hasStatusTarget) this.statusTarget.textContent = this.readyMessageValue
206
+ }
207
+
208
+ keepPartialFile(err) {
209
+ const cancelled = err && err.name === "AbortError"
210
+ if (this.parts.length > 0) {
211
+ const base = this.baseFilename || "export"
212
+ this.offerSave(`incomplete-${base}`)
213
+ if (this.hasStatusTarget) this.statusTarget.textContent = this.incompleteMessageValue
175
214
  }
215
+ this.showError(cancelled ? this.cancelledMessageValue : (err.message || String(err)))
176
216
  }
177
217
 
178
218
  save() {
@@ -0,0 +1,30 @@
1
+ export function concatJsonArrayChunks(texts) {
2
+ const inners = []
3
+ for (const text of texts) {
4
+ const trimmed = text.trim()
5
+ if (trimmed === "" || trimmed === "[]") continue
6
+ inners.push(trimmed.slice(1, -1))
7
+ }
8
+ if (inners.length === 0) return "[]"
9
+ return `[${inners.join(",")}]`
10
+ }
11
+
12
+ export function wrapXmlChunks(parts) {
13
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<export>\n${parts.join("\n")}\n</export>\n`
14
+ }
15
+
16
+ export function assembleExportParts(exportFmt, parts) {
17
+ if (exportFmt === "json") return [concatJsonArrayChunks(parts)]
18
+ if (exportFmt === "xml") return [wrapXmlChunks(parts)]
19
+ return parts
20
+ }
21
+
22
+ export function appendExportColumnParams(urlString, columnValues, origin) {
23
+ const parsed = new URL(urlString, origin)
24
+ parsed.searchParams.delete("export_columns[]")
25
+ parsed.searchParams.delete("export_columns")
26
+ for (const value of columnValues) {
27
+ parsed.searchParams.append("export_columns[]", value)
28
+ }
29
+ return parsed.toString()
30
+ }
@@ -7,6 +7,15 @@
7
7
  >
8
8
  <%= t("active_admin.batched_export_page.start") %>
9
9
  </button>
10
+ <button
11
+ type="button"
12
+ class="<%= styles[:cancel_button] %> hidden"
13
+ data-<%= stimulus_controller %>-target="cancel"
14
+ data-action="<%= stimulus_controller %>#cancel"
15
+ disabled
16
+ >
17
+ <%= t("active_admin.batched_export_page.cancel") %>
18
+ </button>
10
19
  <button
11
20
  type="button"
12
21
  class="<%= styles[:secondary_button] %>"
@@ -19,7 +28,9 @@
19
28
  <%= link_to t("active_admin.batched_export_page.back_to_list"),
20
29
  url_for(
21
30
  action: :index,
22
- params: request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta)
31
+ params: request.query_parameters.except(
32
+ :format, :commit, :page, :batch_page, :export_meta, :export_cursor, :export_snapshot
33
+ )
23
34
  ),
24
35
  class: styles[:back_link] %>
25
36
  </div>
@@ -11,4 +11,4 @@
11
11
  ></progress>
12
12
  </div>
13
13
 
14
- <p class="<%= styles[:error] %>" data-<%= stimulus_controller %>-target="error"></p>
14
+ <p class="<%= styles[:error] %>" role="alert" data-<%= stimulus_controller %>-target="error"></p>
@@ -13,6 +13,9 @@
13
13
  data-<%= stimulus_controller %>-failed-batch-template-value="<%= t("active_admin.batched_export_page.failed_batch") %>"
14
14
  data-<%= stimulus_controller %>-ready-message-value="<%= t("active_admin.batched_export_page.ready") %>"
15
15
  data-<%= stimulus_controller %>-needs-column-message-value="<%= t("active_admin.batched_export_page.select_at_least_one_column") %>"
16
+ data-<%= stimulus_controller %>-cancelled-message-value="<%= t("active_admin.batched_export_page.cancelled") %>"
17
+ data-<%= stimulus_controller %>-incomplete-message-value="<%= t("active_admin.batched_export_page.incomplete") %>"
18
+ data-<%= stimulus_controller %>-over-max-message-value="<%= t("active_admin.batched_export_page.over_max_rows") %>"
16
19
  >
17
20
  <%= render "active_admin/batched_export/summary", styles: styles %>
18
21
  <%= render "active_admin/batched_export/columns", styles: styles, stimulus_controller: stimulus_controller %>
@@ -1,4 +1,4 @@
1
- <% export_base_params = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta) %>
1
+ <% export_base_params = request.query_parameters.except(:format, :commit, :page, :batch_page, :export_meta, :export_cursor, :export_snapshot) %>
2
2
  <div>
3
3
  <span><%= I18n.t("active_admin.download") %></span>
4
4
  <% if active_admin_config.batched_export_enabled? %>
@@ -5,15 +5,19 @@ en:
5
5
  back_to_list: Back to list
6
6
  batch_size_label: Rows per batch
7
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.
8
+ blurb: The export loads in separate batches. Rows are not shown on this page. When loading finishes, use Save file to store the export on your computer.
9
+ cancel: Cancel
10
+ cancelled: Loading stopped. You can save what loaded.
9
11
  columns_hint: All columns are included by default. Uncheck any you do not need.
10
12
  columns_title: Columns to include
11
13
  empty: There is nothing to export for this filter.
12
14
  failed_batch: "Could not load batch %{page}: %{message}"
15
+ incomplete: Incomplete. You can still save what loaded.
16
+ over_max_rows: This export is larger than the allowed maximum.
13
17
  filename_label: File name
14
18
  format_label: File format
15
19
  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}
20
+ loading_batch: Loading batch %{current} of about %{total}
17
21
  preparing: Preparing…
18
22
  ready: Ready. You can save the file now.
19
23
  resource_label: Resource
@@ -26,4 +30,5 @@ en:
26
30
  start: Load export
27
31
  summary_title: What you are exporting
28
32
  total_rows_label: Rows to export
33
+ unavailable_session: This export session is no longer available.
29
34
  unknown_macro: Unknown export macro
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "builder"
4
+ require "csv"
5
+ require "activeadmin/batched_export/row_sanitizer"
6
+
7
+ module ActiveAdmin
8
+ module BatchedExport
9
+ module ChunkRenderer
10
+ def batched_export_batch_body(export_format, records, first_page:)
11
+ case export_format
12
+ when :csv then batched_csv_chunk(records, first_page: first_page)
13
+ when :json then batched_json_chunk(records)
14
+ when :xml then batched_xml_chunk(records)
15
+ else ""
16
+ end
17
+ end
18
+
19
+ def batched_csv_chunk(records, first_page:)
20
+ builder = export_builder
21
+ options = builder.options.dup
22
+ csv_options = options.except(:encoding_options, :humanize_name, :byte_order_mark)
23
+ columns = export_columns_for(builder)
24
+ buffer = csv_opening(builder, columns, options, csv_options, first_page)
25
+ each_export_row(records) do |resource|
26
+ row = decorated_export_row(builder, columns, options, resource)
27
+ buffer << CSV.generate_line(row, **csv_options)
28
+ end
29
+ buffer
30
+ end
31
+
32
+ def batched_json_chunk(records)
33
+ builder = export_builder
34
+ options = builder.options
35
+ columns = export_columns_for(builder)
36
+ names = columns.map(&:name)
37
+ rows = []
38
+ each_export_row(records) do |resource|
39
+ row = decorated_export_row(builder, columns, options, resource)
40
+ rows << names.zip(row).to_h
41
+ end
42
+ rows.to_json
43
+ end
44
+
45
+ def batched_xml_chunk(records)
46
+ builder = export_builder
47
+ options = builder.options
48
+ columns = export_columns_for(builder)
49
+ xml = Builder::XmlMarkup.new(indent: 0)
50
+ each_export_row(records) do |resource|
51
+ append_xml_record(xml, columns, decorated_export_row(builder, columns, options, resource))
52
+ end
53
+ xml.target!
54
+ end
55
+
56
+ def sanitize_macro_row(row, columns, resource)
57
+ RowSanitizer.apply(apply_export_macros(row, columns, resource))
58
+ end
59
+
60
+ def each_export_row(records)
61
+ records.each { |resource| yield apply_decorator(resource) }
62
+ end
63
+
64
+ def apply_export_macros(row, columns, resource)
65
+ ExportMacroResolver.apply(
66
+ row: row,
67
+ columns: columns,
68
+ resource: resource,
69
+ resource_settings: active_admin_config.batched_export_settings,
70
+ registry: merged_macro_registry
71
+ )
72
+ end
73
+
74
+ def merged_macro_registry
75
+ BatchedExport.config.registered_macros.merge(ExportMacroCatalog.global_registry)
76
+ end
77
+
78
+ def export_builder
79
+ active_admin_config.csv_builder
80
+ end
81
+
82
+ def export_columns_for(builder)
83
+ batched_export_filter_columns(builder.exec_columns(view_context))
84
+ end
85
+
86
+ def decorated_export_row(builder, columns, options, resource)
87
+ sanitize_macro_row(builder.build_row(resource, columns, options), columns, resource)
88
+ end
89
+
90
+ def csv_opening(builder, columns, options, csv_options, first_page)
91
+ buffer = +""
92
+ mark = options[:byte_order_mark]
93
+ buffer << mark if first_page && mark
94
+ return buffer unless first_page && options.fetch(:column_names, true)
95
+
96
+ headers = columns.map do |column|
97
+ ActiveAdmin::Sanitizer.sanitize(builder.send(:encode, column.name, options))
98
+ end
99
+ buffer << CSV.generate_line(headers, **csv_options)
100
+ end
101
+
102
+ def append_xml_record(xml, columns, row)
103
+ xml.batch do
104
+ xml.record do
105
+ columns.each_with_index do |column, index|
106
+ xml.field("name" => column.name) { xml.text!(row[index].to_s) }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -6,14 +6,16 @@ require "activeadmin/batched_export/export_macro_catalog"
6
6
  module ActiveAdmin
7
7
  module BatchedExport
8
8
  class Configuration
9
- attr_accessor :styles, :batch_size, :max_batch_size, :large_export_row_threshold, :stimulus_controller,
10
- :default_enabled, :default_column_selection
9
+ attr_accessor :styles, :batch_size, :max_batch_size, :large_export_row_threshold, :max_export_rows,
10
+ :snapshot_ttl, :stimulus_controller, :default_enabled, :default_column_selection
11
11
 
12
12
  def initialize
13
13
  @styles = Styles.new
14
14
  @batch_size = 1000
15
15
  @max_batch_size = 10_000
16
16
  @large_export_row_threshold = 25_000
17
+ @max_export_rows = nil
18
+ @snapshot_ttl = 86_400
17
19
  @stimulus_controller = "activeadmin-batched-export--batched-export"
18
20
  @default_enabled = false
19
21
  @default_column_selection = true