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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +14 -26
- data/CONTRIBUTING.md +11 -17
- data/GOVERNANCE.md +11 -18
- data/README.md +44 -11
- data/SECURITY.md +10 -13
- data/activeadmin_batched_export.gemspec +2 -2
- data/app/assets/controllers/activeadmin_batched_export/batched_export_controller.js +136 -96
- data/app/assets/javascripts/activeadmin_batched_export/chunk_assembly.mjs +30 -0
- data/app/views/active_admin/batched_export/_actions.html.erb +12 -1
- data/app/views/active_admin/batched_export/_progress.html.erb +1 -1
- data/app/views/active_admin/batched_export/workspace.html.erb +3 -0
- data/app/views/active_admin/shared/_download_format_links.html.erb +1 -1
- data/config/locales/activeadmin_batched_export.en.yml +7 -2
- data/lib/activeadmin/batched_export/chunk_renderer.rb +113 -0
- data/lib/activeadmin/batched_export/configuration.rb +4 -2
- data/lib/activeadmin/batched_export/controller_methods.rb +115 -113
- data/lib/activeadmin/batched_export/engine.rb +13 -1
- data/lib/activeadmin/batched_export/errors.rb +9 -0
- data/lib/activeadmin/batched_export/export_cursor.rb +56 -0
- data/lib/activeadmin/batched_export/keyset_page.rb +84 -0
- data/lib/activeadmin/batched_export/row_sanitizer.rb +11 -0
- data/lib/activeadmin/batched_export/snapshot_page.rb +140 -0
- data/lib/activeadmin/batched_export/snapshot_row.rb +41 -0
- data/lib/activeadmin/batched_export/styles.rb +1 -0
- data/lib/activeadmin/batched_export/version.rb +1 -1
- metadata +12 -4
|
@@ -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
|
|
7
|
+
// Progressive ActiveAdmin export: sequential cursor fetches + single client-side save.
|
|
4
8
|
export default class extends Controller {
|
|
5
|
-
static targets = [
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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}",
|
|
132
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(
|
|
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>
|
|
@@ -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
|
|
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, :
|
|
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
|