bulkrax 9.3.4 → 9.4.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -1
  3. data/app/assets/javascripts/bulkrax/application.js +2 -1
  4. data/app/assets/javascripts/bulkrax/bulkrax.js +13 -4
  5. data/app/assets/javascripts/bulkrax/bulkrax_utils.js +96 -0
  6. data/app/assets/javascripts/bulkrax/datatables.js +1 -0
  7. data/app/assets/javascripts/bulkrax/entries.js +17 -10
  8. data/app/assets/javascripts/bulkrax/importers.js.erb +9 -2
  9. data/app/assets/javascripts/bulkrax/importers_stepper.js +2420 -0
  10. data/app/assets/stylesheets/bulkrax/application.css +1 -1
  11. data/app/assets/stylesheets/bulkrax/import_export.scss +9 -2
  12. data/app/assets/stylesheets/bulkrax/stepper/_header.scss +83 -0
  13. data/app/assets/stylesheets/bulkrax/stepper/_mixins.scss +26 -0
  14. data/app/assets/stylesheets/bulkrax/stepper/_navigation.scss +103 -0
  15. data/app/assets/stylesheets/bulkrax/stepper/_responsive.scss +46 -0
  16. data/app/assets/stylesheets/bulkrax/stepper/_review.scss +92 -0
  17. data/app/assets/stylesheets/bulkrax/stepper/_settings.scss +106 -0
  18. data/app/assets/stylesheets/bulkrax/stepper/_success.scss +26 -0
  19. data/app/assets/stylesheets/bulkrax/stepper/_summary.scss +171 -0
  20. data/app/assets/stylesheets/bulkrax/stepper/_upload.scss +339 -0
  21. data/app/assets/stylesheets/bulkrax/stepper/_validation.scss +237 -0
  22. data/app/assets/stylesheets/bulkrax/stepper/_variables.scss +46 -0
  23. data/app/assets/stylesheets/bulkrax/stepper.scss +32 -0
  24. data/app/controllers/bulkrax/guided_imports_controller.rb +175 -0
  25. data/app/controllers/bulkrax/importers_controller.rb +34 -28
  26. data/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb +201 -0
  27. data/app/controllers/concerns/bulkrax/importer_file_handler.rb +217 -0
  28. data/app/factories/bulkrax/object_factory.rb +3 -2
  29. data/app/factories/bulkrax/valkyrie_object_factory.rb +61 -17
  30. data/app/jobs/bulkrax/export_work_job.rb +1 -3
  31. data/app/jobs/bulkrax/importer_job.rb +11 -4
  32. data/app/models/bulkrax/csv_entry.rb +27 -7
  33. data/app/models/bulkrax/entry.rb +4 -0
  34. data/app/models/bulkrax/importer.rb +31 -1
  35. data/app/models/concerns/bulkrax/has_matchers.rb +2 -2
  36. data/app/models/concerns/bulkrax/importer_exporter_behavior.rb +6 -5
  37. data/app/parsers/bulkrax/application_parser.rb +31 -5
  38. data/app/parsers/bulkrax/csv_parser.rb +42 -10
  39. data/app/parsers/concerns/bulkrax/csv_parser/csv_template_generation.rb +73 -0
  40. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb +133 -0
  41. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb +282 -0
  42. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_hierarchy.rb +96 -0
  43. data/app/services/bulkrax/csv_template/column_builder.rb +60 -0
  44. data/app/services/bulkrax/csv_template/column_descriptor.rb +58 -0
  45. data/app/services/bulkrax/csv_template/csv_builder.rb +83 -0
  46. data/app/services/bulkrax/csv_template/explanation_builder.rb +57 -0
  47. data/app/services/bulkrax/csv_template/field_analyzer.rb +56 -0
  48. data/app/services/bulkrax/csv_template/file_path_generator.rb +47 -0
  49. data/app/services/bulkrax/csv_template/file_validator.rb +68 -0
  50. data/app/services/bulkrax/csv_template/mapping_manager.rb +55 -0
  51. data/app/services/bulkrax/csv_template/model_loader.rb +50 -0
  52. data/app/services/bulkrax/csv_template/row_builder.rb +35 -0
  53. data/app/services/bulkrax/csv_template/schema_analyzer.rb +70 -0
  54. data/app/services/bulkrax/csv_template/split_formatter.rb +44 -0
  55. data/app/services/bulkrax/csv_template/value_determiner.rb +68 -0
  56. data/app/services/bulkrax/stepper_response_formatter.rb +347 -0
  57. data/app/services/bulkrax/validation_error_csv_builder.rb +99 -0
  58. data/app/validators/bulkrax/csv_row/child_reference.rb +56 -0
  59. data/app/validators/bulkrax/csv_row/circular_reference.rb +71 -0
  60. data/app/validators/bulkrax/csv_row/controlled_vocabulary.rb +74 -0
  61. data/app/validators/bulkrax/csv_row/duplicate_identifier.rb +63 -0
  62. data/app/validators/bulkrax/csv_row/missing_source_identifier.rb +31 -0
  63. data/app/validators/bulkrax/csv_row/parent_reference.rb +59 -0
  64. data/app/validators/bulkrax/csv_row/required_values.rb +64 -0
  65. data/app/views/bulkrax/entries/_parsed_metadata.html.erb +1 -1
  66. data/app/views/bulkrax/entries/_raw_metadata.html.erb +1 -1
  67. data/app/views/bulkrax/entries/show.html.erb +6 -6
  68. data/app/views/bulkrax/exporters/_form.html.erb +19 -43
  69. data/app/views/bulkrax/exporters/edit.html.erb +2 -2
  70. data/app/views/bulkrax/exporters/index.html.erb +5 -5
  71. data/app/views/bulkrax/exporters/new.html.erb +3 -5
  72. data/app/views/bulkrax/exporters/show.html.erb +3 -3
  73. data/app/views/bulkrax/guided_imports/new.html.erb +567 -0
  74. data/app/views/bulkrax/importers/_bagit_fields.html.erb +9 -9
  75. data/app/views/bulkrax/importers/_browse_everything.html.erb +1 -1
  76. data/app/views/bulkrax/importers/_csv_fields.html.erb +11 -11
  77. data/app/views/bulkrax/importers/_edit_form_buttons.html.erb +23 -23
  78. data/app/views/bulkrax/importers/_edit_item_buttons.html.erb +2 -2
  79. data/app/views/bulkrax/importers/_file_uploader.html.erb +3 -3
  80. data/app/views/bulkrax/importers/_form.html.erb +4 -5
  81. data/app/views/bulkrax/importers/_oai_fields.html.erb +8 -18
  82. data/app/views/bulkrax/importers/_xml_fields.html.erb +13 -13
  83. data/app/views/bulkrax/importers/edit.html.erb +2 -2
  84. data/app/views/bulkrax/importers/index.html.erb +19 -14
  85. data/app/views/bulkrax/importers/new.html.erb +10 -9
  86. data/app/views/bulkrax/importers/show.html.erb +23 -7
  87. data/app/views/bulkrax/importers/upload_corrected_entries.html.erb +6 -6
  88. data/app/views/bulkrax/shared/_bulkrax_errors.html.erb +11 -11
  89. data/app/views/bulkrax/shared/_bulkrax_field_mapping.html.erb +3 -3
  90. data/config/i18n-tasks.yml +195 -0
  91. data/config/locales/bulkrax.de.yml +504 -0
  92. data/config/locales/bulkrax.en.yml +487 -28
  93. data/config/locales/bulkrax.es.yml +504 -0
  94. data/config/locales/bulkrax.fr.yml +504 -0
  95. data/config/locales/bulkrax.it.yml +504 -0
  96. data/config/locales/bulkrax.pt-BR.yml +504 -0
  97. data/config/locales/bulkrax.zh.yml +503 -0
  98. data/config/routes.rb +10 -0
  99. data/lib/bulkrax/data/demo_scenarios.json +2235 -0
  100. data/lib/bulkrax/version.rb +1 -1
  101. data/lib/bulkrax.rb +31 -3
  102. data/lib/tasks/bulkrax_tasks.rake +0 -102
  103. metadata +55 -3
  104. /data/{app/services → lib}/wings/custom_queries/find_by_source_identifier.rb +0 -0
@@ -0,0 +1,2420 @@
1
+ // Bulk Import Stepper - Multi-step wizard for CSV/ZIP imports
2
+ // Handles file uploads, validation, settings, and review steps
3
+ //
4
+ // DEPENDENCIES:
5
+ // - jQuery (global)
6
+ // - BulkraxUtils (from bulkrax_utils.js - must load first)
7
+ //
8
+ // TABLE OF CONTENTS:
9
+ // - Utility Functions
10
+ // - Constants & Configuration
11
+ // - State Management
12
+ // - Initialization
13
+ // - Event Binding
14
+ // - File Validation & Utilities
15
+ // - File Upload Handlers
16
+ // - Demo Scenarios
17
+ // - Upload State Management
18
+ // - Validation
19
+ // - Validation Results Rendering
20
+ // - Import Summary & Hierarchy
21
+ // - Settings & Navigation
22
+ // - Form Submission & Success State
23
+ // - Notification Functions
24
+
25
+ ; (function ($, Utils) {
26
+ 'use strict'
27
+
28
+ // Import utilities from BulkraxUtils
29
+ var escapeHtml = Utils.escapeHtml
30
+ var formatFileSize = Utils.formatFileSize
31
+ var normalizeBoolean = Utils.normalizeBoolean
32
+ var t = Utils.t
33
+
34
+ // ============================================================================
35
+ // UTILITY FUNCTIONS
36
+ // ============================================================================
37
+
38
+ // Delays function execution until after 'wait' milliseconds have passed
39
+ // since the last time it was invoked
40
+ function debounce(func, wait) {
41
+ var timeout
42
+ return function debounced() {
43
+ var context = this
44
+ var args = arguments
45
+ clearTimeout(timeout)
46
+ timeout = setTimeout(function () {
47
+ func.apply(context, args)
48
+ }, wait)
49
+ }
50
+ }
51
+
52
+ // ============================================================================
53
+ // CONSTANTS & CONFIGURATION
54
+ // ============================================================================
55
+
56
+ var CONSTANTS = {
57
+ // File upload limits
58
+ MAX_FILES: 2,
59
+ // Import size thresholds
60
+ IMPORT_SIZE_OPTIMAL: 100,
61
+ IMPORT_SIZE_MODERATE: 500,
62
+ IMPORT_SIZE_LARGE: 1000,
63
+
64
+ // File types
65
+ ALLOWED_EXTENSIONS: ['.csv', '.zip'],
66
+
67
+ // Upload states
68
+ UPLOAD_STATES: {
69
+ EMPTY: 'empty',
70
+ CSV_ONLY: 'csv_only',
71
+ ZIP_FILES_ONLY: 'zip_files_only',
72
+ ZIP_WITH_CSV: 'zip_with_csv',
73
+ CSV_AND_ZIP: 'csv_and_zip'
74
+ },
75
+
76
+ // Animation timings
77
+ ANIMATION_SPEED: 200,
78
+ SCROLL_SPEED: 300,
79
+ VALIDATION_DELAY: 2000,
80
+ NOTIFICATION_FADE_SPEED: 300,
81
+ DEBOUNCE_DELAY: 300,
82
+
83
+ // AJAX timeouts (in milliseconds)
84
+ AJAX_TIMEOUT_SHORT: 10000, // 10 seconds for simple requests
85
+ AJAX_TIMEOUT_LONG: 120000, // 2 minutes for validation
86
+
87
+ // Chunked upload settings (matches Hyrax v1 uploader)
88
+ CHUNK_SIZE: 10000000, // 10 MB per chunk
89
+ UPLOAD_URL: '/uploads/',
90
+
91
+ // Hierarchy rendering limits
92
+ MAX_TREE_DEPTH: 50, // Prevent stack overflow on deeply nested hierarchies
93
+
94
+ // API endpoints
95
+ ENDPOINTS: {
96
+ DEMO_SCENARIOS: '/importers/guided_import/demo_scenarios',
97
+ VALIDATE: '/importers/guided_import/validate',
98
+ DOWNLOAD_VALIDATION_ERRORS: '/importers/guided_import/download_validation_errors'
99
+ }
100
+ }
101
+
102
+ // ============================================================================
103
+ // STATE MANAGEMENT
104
+ // ============================================================================
105
+
106
+ var StepperState = {
107
+ currentStep: 1,
108
+ uploadedFiles: [],
109
+ uploadState: CONSTANTS.UPLOAD_STATES.EMPTY,
110
+ uploadMode: 'upload', // 'upload' or 'file_path'
111
+ validated: false,
112
+ validationData: null,
113
+ warningsAcked: false,
114
+ skipValidation: false, // Flag to skip validation step
115
+ isAddingFiles: false, // Flag to track if we're adding files vs replacing
116
+ demoScenario: null, // Track which demo scenario is loaded
117
+ demoScenariosData: null, // Cached demo scenarios JSON from server
118
+ uploadsInProgress: 0,
119
+ adminSetId: '',
120
+ adminSetName: '',
121
+ settings: {
122
+ name: '',
123
+ visibility: 'open',
124
+ rightsStatement: '',
125
+ limit: ''
126
+ }
127
+ }
128
+
129
+ // Guard flag to prevent rebinding events on every page load (Turbolinks)
130
+ var eventsInitialized = false
131
+
132
+ // ============================================================================
133
+ // INITIALIZATION
134
+ // ============================================================================
135
+
136
+ // Initialize on page load
137
+ function initBulkImportStepper() {
138
+ if ($('#bulk-import-stepper-form').length === 0) {
139
+ return
140
+ }
141
+
142
+ eventsInitialized = false
143
+
144
+ bindEvents()
145
+ initAdminSetState()
146
+ updateDownloadTemplateLink()
147
+ updateStepperUI()
148
+ initVisibilityCards()
149
+ setDefaultImportName()
150
+ }
151
+
152
+ // ============================================================================
153
+ // EVENT BINDING
154
+ // ============================================================================
155
+
156
+ // Bind all event handlers
157
+ function bindEvents() {
158
+ // Only bind events once, even if initBulkImportStepper runs multiple times
159
+ if (eventsInitialized) {
160
+ return // Events already bound, skip rebinding
161
+ }
162
+
163
+ // File upload - main dropzone
164
+ // <label for="file-input"> is needed for accessibility but also opens the file selector when clicked
165
+ // this will prevent that behavior while maintaining accessibility
166
+ $('.upload-dropzone').on('click', function (e) {
167
+ if (e.target.id === 'file-input') return
168
+ e.preventDefault()
169
+ openMainFilePicker()
170
+ })
171
+ // role="button" in WCAG 2.1 AA requires keyboard interaction for enter and space key
172
+ // this allows user to open the file picker with the space key when the dropzone is focused
173
+ $('.upload-dropzone').on('keydown', function (e) {
174
+ if (e.key === ' ') {
175
+ e.preventDefault()
176
+ openMainFilePicker()
177
+ }
178
+ })
179
+
180
+ // Delegated event handlers for dynamic content
181
+ // These only need to be bound once since they listen on stable parent containers
182
+ bindDelegatedEvents()
183
+
184
+ // File upload - add another dropzone
185
+ // <label for="file-input-additional"> is needed for accessibility but also opens the file selector when clicked
186
+ // this will prevent that behavior while maintaining accessibility
187
+ $('.upload-dropzone-small').on('click', function (e) {
188
+ e.preventDefault()
189
+ openAdditionalFilePicker()
190
+ })
191
+ // role="button" in WCAG 2.1 AA requires keyboard interaction for enter and space key
192
+ // this allows user to open the file picker with the space key when the dropzone is focused
193
+ $('.upload-dropzone-small').on('keydown', function (e) {
194
+ if (e.key === ' ') {
195
+ e.preventDefault()
196
+ openAdditionalFilePicker()
197
+ }
198
+ })
199
+
200
+ $('#file-input').on('change', function () {
201
+ handleFileSelect(StepperState.isAddingFiles)
202
+ StepperState.isAddingFiles = false // Reset flag after handling
203
+ })
204
+
205
+ // Drag and drop - main dropzone
206
+ $('.upload-dropzone').on('dragover', function (e) {
207
+ e.preventDefault()
208
+ $(this).addClass('dragover')
209
+ })
210
+
211
+ $('.upload-dropzone').on('dragleave', function (e) {
212
+ e.preventDefault()
213
+ $(this).removeClass('dragover')
214
+ })
215
+
216
+ $('.upload-dropzone').on('drop', function (e) {
217
+ e.preventDefault()
218
+ $(this).removeClass('dragover')
219
+ var droppedFiles = e.originalEvent.dataTransfer.files
220
+ if (droppedFiles.length > 0) {
221
+ // Prepare files array (up to MAX_FILES total)
222
+ var maxFiles = Math.min(droppedFiles.length, CONSTANTS.MAX_FILES)
223
+ var filesToAdd = []
224
+ for (var i = 0; i < maxFiles; i++) {
225
+ filesToAdd.push(droppedFiles[i])
226
+ }
227
+
228
+ // Try to set files on input element (with browser compatibility fallback)
229
+ setInputFiles($('#file-input')[0], filesToAdd)
230
+
231
+ handleFileSelect(false) // Drag and drop replaces files
232
+
233
+ // Show warning if more than MAX_FILES were dropped
234
+ if (droppedFiles.length > CONSTANTS.MAX_FILES) {
235
+ showNotification(
236
+ t('only_first_files', { count: CONSTANTS.MAX_FILES, max: CONSTANTS.MAX_FILES })
237
+ )
238
+ }
239
+ }
240
+ })
241
+
242
+ // Drag and drop - small "add another" dropzone
243
+ $('.upload-dropzone-small').on('dragover', function (e) {
244
+ e.preventDefault()
245
+ $(this).addClass('dragover')
246
+ })
247
+
248
+ $('.upload-dropzone-small').on('dragleave', function (e) {
249
+ e.preventDefault()
250
+ $(this).removeClass('dragover')
251
+ })
252
+
253
+ $('.upload-dropzone-small').on('drop', function (e) {
254
+ e.preventDefault()
255
+ $(this).removeClass('dragover')
256
+ var droppedFiles = e.originalEvent.dataTransfer.files
257
+ if (droppedFiles.length > 0) {
258
+ // Add only 1 file since we're adding to existing
259
+ var filesToAdd = [droppedFiles[0]]
260
+
261
+ // Try to set files on input element (with browser compatibility fallback)
262
+ setInputFiles($('#file-input')[0], filesToAdd)
263
+
264
+ handleFileSelect(true)
265
+ StepperState.isAddingFiles = false // reset after handling
266
+
267
+ // Show warning if more than 1 file was dropped
268
+ if (droppedFiles.length > 1) {
269
+ showNotification(
270
+ t('only_one_additional')
271
+ )
272
+ }
273
+ }
274
+ })
275
+
276
+ // Upload mode tabs
277
+ $('.upload-mode-tab').on('click', function () {
278
+ var mode = $(this).data('upload-mode')
279
+ switchUploadMode(mode)
280
+ })
281
+
282
+ // File path input
283
+ $('#import-file-path').on('input', debounce(function () {
284
+ resetValidationState()
285
+ updateValidateButtonState()
286
+ }, CONSTANTS.DEBOUNCE_DELAY))
287
+
288
+ // Demo scenarios (for testing)
289
+ if ($('#stepper-state').data('demo-scenarios-enabled')) {
290
+ $('.step-circle').on('dblclick', function () {
291
+ var $panel = $('.demo-scenarios')
292
+ $panel.toggle()
293
+ if ($panel.is(':visible')) {
294
+ // Prefetch scenarios data
295
+ loadDemoScenariosData().catch(function () {
296
+ // Error already handled in loadDemoScenariosData
297
+ })
298
+ }
299
+ })
300
+
301
+ $('.scenario-btn').on('click', function () {
302
+ var scenario = $(this).data('scenario')
303
+ loadDemoScenario(scenario)
304
+ $('.demo-scenarios').hide()
305
+ })
306
+ }
307
+
308
+ // Start over
309
+ $('.start-over-nav-btn').on('click', function () {
310
+ startOver()
311
+ })
312
+
313
+ // Validate button (one per tab; only the active one is visible)
314
+ $('#validate-upload-btn, #validate-path-btn').on('click', function () {
315
+ validateFiles()
316
+ })
317
+
318
+ // Warnings acknowledgment
319
+ $('#warnings-acked').on('change', function () {
320
+ StepperState.warningsAcked = $(this).is(':checked')
321
+ updateStepNavigation()
322
+ })
323
+
324
+ // Step navigation - next step
325
+ $('.step-next-btn').on('click', function () {
326
+ var nextStep = parseInt($(this).data('next-step'))
327
+ goToStep(nextStep)
328
+ })
329
+
330
+ // Step navigation - previous step
331
+ $('.step-prev-btn').on('click', function () {
332
+ var prevStep = parseInt($(this).data('prev-step'))
333
+ goToStep(prevStep)
334
+ })
335
+
336
+ // Form submission
337
+ $('#bulk-import-stepper-form').on('submit', function (e) {
338
+ e.preventDefault()
339
+ handleImportSubmit()
340
+ })
341
+
342
+ // Start another import
343
+ $('#start-another-import').on('click', function () {
344
+ location.reload()
345
+ })
346
+
347
+ // Settings form changes
348
+ $('#importer_name').on('input', debounce(function () {
349
+ StepperState.settings.name = $(this).val()
350
+ updateStepNavigation()
351
+ }, CONSTANTS.DEBOUNCE_DELAY))
352
+
353
+ // Admin set selection change
354
+ $('#importer-admin-set').on('change', function () {
355
+ StepperState.adminSetId = $(this).val()
356
+ StepperState.settings.adminSetName = $(this).find('option:selected').text()
357
+ resetValidationState()
358
+ updateStepNavigation()
359
+ updateDownloadTemplateLink()
360
+ // Update validate button state since admin set is required for validation
361
+ if (StepperState.uploadMode === 'file_path' || StepperState.uploadedFiles.length > 0) {
362
+ updateValidateButtonState()
363
+ }
364
+ })
365
+
366
+ // Rights statement selection change
367
+ $('select[name="importer[parser_fields][rights_statement]"]').on('change', function () {
368
+ StepperState.settings.rightsStatement = $(this).find('option:selected').text().trim()
369
+ // Clear if "None" was selected
370
+ if (!$(this).val()) {
371
+ StepperState.settings.rightsStatement = ''
372
+ }
373
+ updateStepNavigation()
374
+ })
375
+
376
+ $('#bulkrax_importer_limit').on('input', debounce(function () {
377
+ StepperState.settings.limit = $(this).val()
378
+ }, CONSTANTS.DEBOUNCE_DELAY))
379
+
380
+ // Remove file button (delegated to parent since rows are dynamic)
381
+ $('.uploaded-files-container').on('click', '.file-remove-btn', function () {
382
+ var $row = $(this).closest('.file-row')
383
+ var fileId = $row.data('file-id')
384
+
385
+ var fileEntry = StepperState.uploadedFiles.find(function (f) { return f.id === fileId })
386
+ if (fileEntry) {
387
+ if (fileEntry.uploadXhr) {
388
+ fileEntry.uploadAbortedByUser = true
389
+ fileEntry.uploadXhr.abort()
390
+ StepperState.uploadsInProgress--
391
+ }
392
+ if (fileEntry.uploadId) {
393
+ $.ajax({ url: CONSTANTS.UPLOAD_URL + fileEntry.uploadId, method: 'DELETE', timeout: CONSTANTS.AJAX_TIMEOUT_SHORT })
394
+ }
395
+ }
396
+
397
+ // Remove from uploadedFiles array
398
+ StepperState.uploadedFiles = StepperState.uploadedFiles.filter(
399
+ function (file) { return file.id !== fileId }
400
+ )
401
+
402
+ // Remove the row
403
+ $row.remove()
404
+ $('#file-input').val('')
405
+
406
+ // Reset validation since files changed
407
+ resetValidationState()
408
+ if (StepperState.uploadedFiles.length === 0) {
409
+ StepperState.skipValidation = false
410
+ $('#skip-validation-checkbox').prop('checked', false)
411
+ }
412
+
413
+ // Update upload state and re-render
414
+ updateUploadState()
415
+ renderUploadedFiles()
416
+ updateStepNavigation()
417
+ })
418
+
419
+ // Skip validation checkbox
420
+ $('#skip-validation-checkbox').on('change', function () {
421
+ StepperState.skipValidation = $(this).is(':checked')
422
+ updateStepNavigation()
423
+ })
424
+
425
+ // Mark events as initialized to prevent rebinding on subsequent page loads
426
+ eventsInitialized = true
427
+ }
428
+
429
+ // Bind delegated event handlers for dynamically rendered content
430
+ // These handlers are attached to stable parent containers and use event delegation
431
+ // to handle clicks on child elements. This is more efficient than rebinding handlers
432
+ // every time content is re-rendered.
433
+ function bindDelegatedEvents() {
434
+ // Accordion toggle events - delegated to validation results container
435
+ $('.validation-results').on('click.accordion', '.accordion-header', function () {
436
+ var $item = $(this).closest('.accordion-item')
437
+ var $content = $item.find('.accordion-content')
438
+ var $chevron = $item.find('.accordion-chevron')
439
+
440
+ if ($item.hasClass('accordion-open')) {
441
+ $content.slideUp(CONSTANTS.ANIMATION_SPEED)
442
+ $chevron.removeClass('fa-chevron-down').addClass('fa-chevron-right')
443
+ $item.removeClass('accordion-open')
444
+ } else {
445
+ $content.slideDown(CONSTANTS.ANIMATION_SPEED)
446
+ $chevron.removeClass('fa-chevron-right').addClass('fa-chevron-down')
447
+ $item.addClass('accordion-open')
448
+ }
449
+ })
450
+
451
+ // Tree toggle events - delegated to import summary container
452
+ function toggleTreeItem($item) {
453
+ var $children = $item.next('.tree-children')
454
+ var $chevron = $item.find('.tree-chevron')
455
+
456
+ if ($children.length > 0) {
457
+ var isExpanded = $children.is(':visible')
458
+ if (isExpanded) {
459
+ $children.slideUp(CONSTANTS.ANIMATION_SPEED)
460
+ $chevron.removeClass('fa-chevron-down').addClass('fa-chevron-right')
461
+ } else {
462
+ $children.slideDown(CONSTANTS.ANIMATION_SPEED)
463
+ $chevron.removeClass('fa-chevron-right').addClass('fa-chevron-down')
464
+ }
465
+ $item.attr('aria-expanded', String(!isExpanded))
466
+ }
467
+ }
468
+
469
+ $('.import-summary').on('click.tree', '.tree-item', function (e) {
470
+ e.stopPropagation()
471
+ toggleTreeItem($(this))
472
+ })
473
+
474
+ // Keyboard support for tree items (Enter/Space to toggle)
475
+ $('.import-summary').on('keydown.tree', '.tree-item[tabindex]', function (e) {
476
+ if (e.key === 'Enter' || e.key === ' ') {
477
+ e.preventDefault()
478
+ e.stopPropagation()
479
+ toggleTreeItem($(this))
480
+ }
481
+ })
482
+ }
483
+
484
+ // ============================================================================
485
+ // FILE VALIDATION & UTILITIES
486
+ // ============================================================================
487
+
488
+ // Check if DataTransfer constructor is available
489
+ function isDataTransferSupported() {
490
+ try {
491
+ return typeof DataTransfer !== 'undefined' && typeof DataTransfer === 'function'
492
+ } catch (e) {
493
+ return false
494
+ }
495
+ }
496
+
497
+ // Helper to set files on input element with browser compatibility
498
+ function setInputFiles(inputElement, files) {
499
+ if (isDataTransferSupported()) {
500
+ var dataTransfer = new DataTransfer()
501
+ for (var i = 0; i < files.length; i++) {
502
+ dataTransfer.items.add(files[i])
503
+ }
504
+ inputElement.files = dataTransfer.files
505
+ return true
506
+ } else {
507
+ // Fallback for older browsers: can't set files property
508
+ // Return false to indicate files weren't set on input
509
+ console.warn('DataTransfer not supported - files will be processed from memory')
510
+ return false
511
+ }
512
+ }
513
+
514
+ // Get file extension (lowercase)
515
+ function getFileExtension(filename) {
516
+ var lastDot = filename.lastIndexOf('.')
517
+ if (lastDot === -1) return ''
518
+ return filename.substring(lastDot).toLowerCase()
519
+ }
520
+
521
+ // Validate file extension
522
+ function isValidFileType(filename) {
523
+ var ext = getFileExtension(filename)
524
+ return CONSTANTS.ALLOWED_EXTENSIONS.indexOf(ext) !== -1
525
+ }
526
+
527
+ // Show inline error messages
528
+ function showFileUploadError(messages) {
529
+ var errorContainer = $('#file-upload-errors')
530
+ if (messages && messages.length > 0) {
531
+ var parts = [
532
+ '<div class="alert alert-danger alert-dismissible" role="alert">',
533
+ '<button type="button" class="close" data-dismiss="alert" aria-label="Close">',
534
+ '<span aria-hidden="true">&times;</span>',
535
+ '</button>',
536
+ '<strong><span class="fa fa-exclamation-circle"></span> ' + t('file_upload_error') + '</strong>',
537
+ '<ul class="mb-0 mt-2">'
538
+ ]
539
+
540
+ messages.forEach(function (msg) {
541
+ // Escape the message but allow intentional <br> tags for newlines
542
+ var escapedMsg = escapeHtml(msg).replace(/\n/g, '<br>')
543
+ parts.push('<li>' + escapedMsg + '</li>')
544
+ })
545
+
546
+ parts.push('</ul>', '</div>')
547
+ errorContainer.html(parts.join('')).show()
548
+ } else {
549
+ errorContainer.hide().html('')
550
+ }
551
+ }
552
+
553
+ // Clear file upload errors
554
+ function clearFileUploadError() {
555
+ $('#file-upload-errors').hide().html('')
556
+ }
557
+
558
+ // ============================================================================
559
+ // FILE UPLOAD HANDLERS
560
+ // ============================================================================
561
+
562
+ // Get tenant/account max file size (bytes) from the page, same source as v1 uploader
563
+ function getMaxFileSize() {
564
+ var val = $('.bulk-import-stepper-container').data('max-file-size')
565
+ if (val == null || val === '') return null
566
+ return parseInt(val, 10) || null
567
+ }
568
+
569
+ // Ensures the file picker opens in the correct mode (replace vs add)
570
+ // Ensures an invalid file is not added
571
+ function openMainFilePicker() {
572
+ StepperState.isAddingFiles = false
573
+ $('#file-input').val('')
574
+ $('#file-input').trigger('click')
575
+ }
576
+
577
+ // Open the file picker in "add" mode (appends to existing files)
578
+ function openAdditionalFilePicker() {
579
+ StepperState.isAddingFiles = true
580
+ $('#file-input').val('')
581
+ $('#file-input').trigger('click')
582
+ }
583
+
584
+ // Handle file selection
585
+ function handleFileSelect(isAddingMore) {
586
+ var files = $('#file-input')[0].files
587
+ if (files.length === 0) return
588
+
589
+ var maxFileSizeBytes = getMaxFileSize()
590
+
591
+ // If not adding more, abort any in-progress uploads and clean up server-side records
592
+ // before replacing the file list so we don't orphan uploads or desync uploadsInProgress.
593
+ if (!isAddingMore) {
594
+ StepperState.uploadedFiles.forEach(function (f) {
595
+ if (f.uploadXhr) {
596
+ f.uploadAbortedByUser = true
597
+ f.uploadXhr.abort()
598
+ }
599
+ if (f.uploadId) {
600
+ $.ajax({ url: CONSTANTS.UPLOAD_URL + f.uploadId, method: 'DELETE', timeout: CONSTANTS.AJAX_TIMEOUT_SHORT })
601
+ }
602
+ })
603
+ StepperState.uploadsInProgress = 0
604
+ StepperState.uploadedFiles = []
605
+ }
606
+
607
+ // Count existing file types
608
+ var existingCounts = StepperState.uploadedFiles.reduce(function (counts, f) {
609
+ if (f.fileType === 'csv' && !f.fromZip) counts.csv++
610
+ if (f.fileType === 'zip') counts.zip++
611
+ return counts
612
+ }, { csv: 0, zip: 0 })
613
+ var existingCsvCount = existingCounts.csv
614
+ var existingZipCount = existingCounts.zip
615
+
616
+ var addedFiles = []
617
+ var rejectedFiles = []
618
+ var newEntries = []
619
+
620
+ // Process selected files with validation
621
+ for (
622
+ var i = 0;
623
+ i < files.length && StepperState.uploadedFiles.length < CONSTANTS.MAX_FILES;
624
+ i++
625
+ ) {
626
+ var file = files[i]
627
+ var fileName = file.name
628
+ var fileSize = formatFileSize(file.size)
629
+
630
+ // Validate file extension first
631
+ if (!isValidFileType(fileName)) {
632
+ rejectedFiles.push({
633
+ name: fileName,
634
+ reason: 'invalid_type',
635
+ extension: getFileExtension(fileName)
636
+ })
637
+ continue
638
+ }
639
+
640
+ // Reject empty/zero-byte files to avoid server-side errors
641
+ if (file.size === 0) {
642
+ rejectedFiles.push({ name: fileName, reason: 'file_empty' })
643
+ continue
644
+ }
645
+
646
+ // Respect tenant/account file size limit (same as v1 and Hyrax uploader)
647
+ if (maxFileSizeBytes != null && file.size > maxFileSizeBytes) {
648
+ rejectedFiles.push({
649
+ name: fileName,
650
+ reason: 'file_too_large',
651
+ size: file.size,
652
+ limit: maxFileSizeBytes
653
+ })
654
+ continue
655
+ }
656
+
657
+ var fileType = fileName.endsWith('.csv') ? 'csv' : 'zip'
658
+
659
+ // Check for duplicates
660
+ var isDuplicate = StepperState.uploadedFiles.some(function (f) {
661
+ return f.name === fileName
662
+ })
663
+
664
+ if (isDuplicate) {
665
+ rejectedFiles.push({ name: fileName, reason: 'duplicate' })
666
+ continue
667
+ }
668
+
669
+ // Validate file type constraints (max 1 CSV, max 1 ZIP)
670
+ if (fileType === 'csv' && existingCsvCount >= 1) {
671
+ rejectedFiles.push({ name: fileName, reason: 'duplicate CSV' })
672
+ continue
673
+ }
674
+
675
+ if (fileType === 'zip' && existingZipCount >= 1) {
676
+ rejectedFiles.push({ name: fileName, reason: 'duplicate ZIP' })
677
+ continue
678
+ }
679
+
680
+ // Add the file with upload tracking properties
681
+ var fileEntry = {
682
+ id: Date.now() + i,
683
+ name: fileName,
684
+ size: fileSize,
685
+ fileType: fileType,
686
+ fromZip: false,
687
+ file: file,
688
+ uploadId: null,
689
+ uploadProgress: 0,
690
+ uploadComplete: false,
691
+ uploadXhr: null
692
+ }
693
+ StepperState.uploadedFiles.push(fileEntry)
694
+ newEntries.push(fileEntry)
695
+
696
+ addedFiles.push(fileName)
697
+
698
+ // Update counts
699
+ if (fileType === 'csv') existingCsvCount++
700
+ if (fileType === 'zip') existingZipCount++
701
+ }
702
+
703
+ // Show appropriate warnings
704
+ if (rejectedFiles.length > 0) {
705
+ var messages = []
706
+
707
+ var categorized = rejectedFiles.reduce(function (acc, f) {
708
+ if (f.reason === 'invalid_type') acc.invalidTypes.push(f)
709
+ else if (f.reason === 'file_empty') acc.fileEmpty.push(f)
710
+ else if (f.reason === 'file_too_large') acc.fileTooLarge.push(f)
711
+ else if (f.reason === 'duplicate CSV') acc.duplicateCsv.push(f)
712
+ else if (f.reason === 'duplicate ZIP') acc.duplicateZip.push(f)
713
+ else if (f.reason === 'duplicate') acc.duplicates.push(f)
714
+ return acc
715
+ }, { invalidTypes: [], fileEmpty: [], fileTooLarge: [], duplicateCsv: [], duplicateZip: [], duplicates: [] })
716
+
717
+ // Handle invalid file types FIRST
718
+ if (categorized.invalidTypes.length > 0) {
719
+ messages.push(
720
+ t('invalid_format') + '\n' +
721
+ t('rejected_files') + '\n• ' +
722
+ categorized.invalidTypes.map(function (f) {
723
+ return f.name + ' (' + (f.extension || t('no_extension')) + ')'
724
+ }).join('\n• ')
725
+ )
726
+ }
727
+
728
+ if (categorized.fileEmpty.length > 0) {
729
+ messages.push(
730
+ 'Empty files (0 bytes) are not allowed.\n' +
731
+ 'The following files were rejected:\n• ' +
732
+ categorized.fileEmpty.map(function (f) { return f.name }).join('\n• ')
733
+ )
734
+ }
735
+ if (categorized.fileTooLarge.length > 0) {
736
+ var limitMb = categorized.fileTooLarge[0].limit / (1024 * 1024)
737
+ messages.push(
738
+ 'File size exceeds the maximum allowed (' + Math.round(limitMb) + ' MB per file).\n' +
739
+ 'The following files were rejected:\n• ' +
740
+ categorized.fileTooLarge.map(function (f) {
741
+ return f.name + ' (' + formatFileSize(f.size) + ')'
742
+ }).join('\n• ')
743
+ )
744
+ }
745
+
746
+ if (categorized.duplicateCsv.length > 0) {
747
+ messages.push(
748
+ t('csv_limit') + '\n• ' +
749
+ categorized.duplicateCsv
750
+ .map(function (f) {
751
+ return f.name
752
+ })
753
+ .join('\n• ')
754
+ )
755
+ }
756
+ if (categorized.duplicateZip.length > 0) {
757
+ messages.push(
758
+ t('zip_limit') + '\n• ' +
759
+ categorized.duplicateZip
760
+ .map(function (f) {
761
+ return f.name
762
+ })
763
+ .join('\n• ')
764
+ )
765
+ }
766
+ if (categorized.duplicates.length > 0) {
767
+ messages.push(
768
+ t('already_uploaded') + '\n• ' +
769
+ categorized.duplicates
770
+ .map(function (f) {
771
+ return f.name
772
+ })
773
+ .join('\n• ')
774
+ )
775
+ }
776
+ if (
777
+ StepperState.uploadedFiles.length >= CONSTANTS.MAX_FILES &&
778
+ files.length > addedFiles.length + rejectedFiles.length
779
+ ) {
780
+ messages.push(t('max_files', { count: CONSTANTS.MAX_FILES }))
781
+ }
782
+
783
+ showFileUploadError(messages)
784
+ } else if (files.length > addedFiles.length) {
785
+ showFileUploadError([
786
+ t('max_files_added', { count: CONSTANTS.MAX_FILES, added: addedFiles.length })
787
+ ])
788
+ } else {
789
+ clearFileUploadError()
790
+ }
791
+
792
+ // Reset validation since files changed
793
+ if (addedFiles.length > 0) {
794
+ resetValidationState()
795
+ }
796
+
797
+ updateUploadState()
798
+ renderUploadedFiles()
799
+
800
+ // Start chunked uploads for newly added files
801
+ newEntries.forEach(function (entry) {
802
+ if (entry.file) {
803
+ uploadFileChunked(entry)
804
+ }
805
+ })
806
+ }
807
+
808
+ // ============================================================================
809
+ // DEMO SCENARIOS
810
+ // ============================================================================
811
+
812
+ var demoScenariosRequest = null
813
+
814
+ // Fetch and cache demo scenarios JSON from server
815
+ function loadDemoScenariosData() {
816
+ // Return cached data as resolved promise
817
+ if (StepperState.demoScenariosData) {
818
+ return Promise.resolve(StepperState.demoScenariosData)
819
+ }
820
+
821
+ // Return existing promise if request is already in-flight
822
+ if (demoScenariosRequest) {
823
+ return demoScenariosRequest
824
+ }
825
+
826
+ demoScenariosRequest = $.ajax({
827
+ url: CONSTANTS.ENDPOINTS.DEMO_SCENARIOS,
828
+ method: 'GET',
829
+ dataType: 'json',
830
+ timeout: CONSTANTS.AJAX_TIMEOUT_SHORT
831
+ })
832
+ .then(function (data) {
833
+ StepperState.demoScenariosData = data
834
+ demoScenariosRequest = null // Clear in-flight tracker
835
+ return data
836
+ })
837
+ .catch(function (xhr) {
838
+ demoScenariosRequest = null // Clear in-flight tracker on error
839
+
840
+ var status = xhr.statusText || 'error'
841
+ var errorMsg = t('demo_load_failed')
842
+
843
+ if (status === 'timeout') {
844
+ errorMsg = t('demo_timeout')
845
+ } else if (xhr.status === 0) {
846
+ errorMsg = t('demo_network_error')
847
+ } else if (xhr.status >= 500) {
848
+ errorMsg = t('demo_server_error')
849
+ }
850
+
851
+ console.warn(errorMsg, {
852
+ status: status,
853
+ error: xhr.statusText,
854
+ statusCode: xhr.status
855
+ })
856
+ showNotification(errorMsg, 'error')
857
+
858
+ // Re-throw to allow caller to handle
859
+ throw new Error(errorMsg)
860
+ })
861
+
862
+ return demoScenariosRequest
863
+ }
864
+
865
+ // Load demo scenario from cached JSON
866
+ function loadDemoScenario(scenario) {
867
+ resetUploadState()
868
+
869
+ loadDemoScenariosData()
870
+ .then(function (data) {
871
+ if (!data || !data.scenarios || !data.scenarios[scenario]) {
872
+ console.warn('Demo scenario not found:', scenario)
873
+ return
874
+ }
875
+
876
+ StepperState.uploadedFiles = data.scenarios[scenario].files
877
+ StepperState.demoScenario = scenario
878
+ updateUploadState()
879
+ renderUploadedFiles()
880
+ })
881
+ .catch(function (error) {
882
+ // Error already handled and displayed in loadDemoScenariosData
883
+ console.error('Failed to load demo scenario:', error)
884
+ })
885
+ }
886
+
887
+ // ============================================================================
888
+ // CHUNKED FILE UPLOAD (to Hyrax /uploads/ endpoint)
889
+ // ============================================================================
890
+
891
+ function uploadFileChunked(fileEntry) {
892
+ var file = fileEntry.file
893
+ if (!file) return
894
+
895
+ StepperState.uploadsInProgress++
896
+ fileEntry.uploadProgress = 0
897
+ fileEntry.uploadComplete = false
898
+ fileEntry.uploadId = null
899
+ fileEntry.uploadAbortedByUser = false
900
+
901
+ updateValidateButtonState()
902
+ renderUploadedFiles()
903
+
904
+ var chunkSize = CONSTANTS.CHUNK_SIZE
905
+ var totalSize = file.size
906
+ var offset = 0
907
+
908
+ function sendNextChunk() {
909
+ if (offset >= totalSize) {
910
+ fileEntry.uploadComplete = true
911
+ fileEntry.uploadProgress = 100
912
+ StepperState.uploadsInProgress--
913
+ updateValidateButtonState()
914
+ renderUploadedFiles()
915
+ return Promise.resolve()
916
+ }
917
+
918
+ var end = Math.min(offset + chunkSize, totalSize)
919
+ var isFirstChunk = (offset === 0)
920
+ var chunk = file.slice(offset, end)
921
+
922
+ var formData = new FormData()
923
+ formData.append('files[]', chunk, file.name)
924
+
925
+ var headers = {}
926
+ if (!isFirstChunk) {
927
+ formData.append('id', fileEntry.uploadId)
928
+ headers['Content-Range'] = 'bytes ' + offset + '-' + (end - 1) + '/' + totalSize
929
+ }
930
+
931
+ var currentOffset = offset
932
+
933
+ return new Promise(function (resolve, reject) {
934
+ var ajaxOptions = {
935
+ url: CONSTANTS.UPLOAD_URL,
936
+ method: 'POST',
937
+ data: formData,
938
+ processData: false,
939
+ contentType: false,
940
+ dataType: 'json',
941
+ timeout: 0,
942
+ xhr: function () {
943
+ var xhr = new XMLHttpRequest()
944
+ xhr.upload.addEventListener('progress', function (e) {
945
+ if (e.lengthComputable) {
946
+ var chunkLoaded = currentOffset + e.loaded
947
+ var percent = Math.round((chunkLoaded / totalSize) * 100)
948
+ fileEntry.uploadProgress = Math.min(percent, 99)
949
+ renderUploadProgress(fileEntry)
950
+ }
951
+ })
952
+ return xhr
953
+ }
954
+ }
955
+
956
+ if (Object.keys(headers).length > 0) {
957
+ ajaxOptions.headers = headers
958
+ }
959
+
960
+ var jqXhr = $.ajax(ajaxOptions)
961
+ fileEntry.uploadXhr = jqXhr
962
+ jqXhr
963
+ .then(function (result) {
964
+ if (isFirstChunk && result.files && result.files[0]) {
965
+ fileEntry.uploadId = result.files[0].id
966
+ }
967
+ offset = end
968
+ fileEntry.uploadXhr = null
969
+ resolve()
970
+ })
971
+ .catch(function (xhr) {
972
+ fileEntry.uploadXhr = null
973
+ reject(new Error(xhr.statusText || 'Upload failed'))
974
+ })
975
+ }).then(sendNextChunk)
976
+ }
977
+
978
+ sendNextChunk().catch(function (error) {
979
+ if (!fileEntry.uploadAbortedByUser) {
980
+ StepperState.uploadsInProgress--
981
+ }
982
+ fileEntry.uploadAbortedByUser = false
983
+ if (fileEntry.uploadId) {
984
+ $.ajax({ url: CONSTANTS.UPLOAD_URL + fileEntry.uploadId, method: 'DELETE', timeout: CONSTANTS.AJAX_TIMEOUT_SHORT })
985
+ }
986
+ StepperState.uploadedFiles = StepperState.uploadedFiles.filter(function (f) {
987
+ return f !== fileEntry
988
+ })
989
+ updateUploadState()
990
+ updateValidateButtonState()
991
+ renderUploadedFiles()
992
+ if (error.message !== 'abort') {
993
+ showNotification('Upload failed for ' + file.name + ': ' + (error.message || 'Unknown error'), 'error')
994
+ }
995
+ })
996
+ }
997
+
998
+ function renderUploadProgress(fileEntry) {
999
+ var $row = $('.file-row[data-file-id="' + fileEntry.id + '"]')
1000
+ if ($row.length) {
1001
+ var pct = fileEntry.uploadProgress || 0
1002
+ $row.find('.upload-progress-bar').css('width', pct + '%')
1003
+ $row.find('.upload-progress-label').text('Uploading… ' + pct + '%')
1004
+ }
1005
+ }
1006
+
1007
+ // ============================================================================
1008
+ // UPLOAD STATE MANAGEMENT
1009
+ // ============================================================================
1010
+
1011
+ // Update upload state based on files
1012
+ function updateUploadState() {
1013
+ var files = StepperState.uploadedFiles
1014
+ if (files.length === 0) {
1015
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.EMPTY
1016
+ return
1017
+ }
1018
+
1019
+ var fileFlags = files.reduce(function (flags, f) {
1020
+ if (f.fileType === 'csv' && !f.fromZip) flags.hasStandaloneCsv = true
1021
+ if (f.fileType === 'zip') flags.hasZip = true
1022
+ if (f.fileType === 'csv' && f.fromZip) flags.hasCsvInZip = true
1023
+ return flags
1024
+ }, { hasStandaloneCsv: false, hasZip: false, hasCsvInZip: false })
1025
+
1026
+ var hasStandaloneCsv = fileFlags.hasStandaloneCsv
1027
+ var hasZip = fileFlags.hasZip
1028
+ var hasCsvInZip = fileFlags.hasCsvInZip
1029
+
1030
+ if (hasZip && hasCsvInZip && !hasStandaloneCsv) {
1031
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.ZIP_WITH_CSV
1032
+ } else if (hasZip && !hasCsvInZip && !hasStandaloneCsv) {
1033
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.ZIP_FILES_ONLY
1034
+ } else if (hasStandaloneCsv && hasZip) {
1035
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.CSV_AND_ZIP
1036
+ } else if (hasStandaloneCsv && !hasZip) {
1037
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.CSV_ONLY
1038
+ } else {
1039
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.EMPTY
1040
+ }
1041
+ }
1042
+
1043
+ // Switch between upload and file path modes
1044
+ function switchUploadMode(mode) {
1045
+ if (mode === StepperState.uploadMode) return
1046
+
1047
+ StepperState.uploadMode = mode
1048
+
1049
+ // Toggle active tab
1050
+ $('.upload-mode-tab').removeClass('active')
1051
+ $('.upload-mode-tab[data-upload-mode="' + mode + '"]').addClass('active')
1052
+ $('.upload-mode-tab').attr('aria-selected', 'false')
1053
+ $('.upload-mode-tab[data-upload-mode="' + mode + '"]').attr('aria-selected', 'true')
1054
+
1055
+ if (mode === 'file_path') {
1056
+ // Hide upload-related elements, show file path panel
1057
+ $('.upload-zone-empty').hide()
1058
+ $('.uploaded-files-container').hide()
1059
+ $('.add-another-dropzone').hide()
1060
+ $('.file-path-panel').show()
1061
+ $('#validate-upload-btn').hide()
1062
+ $('#validate-path-btn').show()
1063
+ } else {
1064
+ // Hide file path panel, restore upload state
1065
+ $('.file-path-panel').hide()
1066
+ $('#validate-path-btn').hide()
1067
+ $('#validate-upload-btn').show()
1068
+ renderUploadedFiles()
1069
+ }
1070
+
1071
+ // Reset validation when switching modes
1072
+ resetValidationState()
1073
+ updateValidateButtonState()
1074
+ }
1075
+
1076
+ // Reset validation state and restore button text (called when inputs change)
1077
+ function resetValidationState() {
1078
+ if (!StepperState.validated) return
1079
+
1080
+ StepperState.validated = false
1081
+ StepperState.validationData = null
1082
+ StepperState.warningsAcked = false
1083
+ $('#warnings-acked').prop('checked', false)
1084
+ $('.validation-results').hide()
1085
+ $('.warning-acknowledgment').hide()
1086
+ $('#validate-upload-btn').html('<span class="fa fa-file-text"></span> ' + t('validate_upload'))
1087
+ $('#validate-path-btn').html('<span class="fa fa-file-text"></span> ' + t('validate_path'))
1088
+ renderUploadedFiles()
1089
+ updateStepNavigation()
1090
+ }
1091
+
1092
+ // Update validate button enabled state based on current upload mode
1093
+ function updateValidateButtonState() {
1094
+ var $adminSetSelect = $('#importer-admin-set')
1095
+ var adminSetValue = $adminSetSelect.val() || StepperState.adminSetId
1096
+ var hasAdminSet = adminSetValue && adminSetValue.length > 0
1097
+
1098
+ var canValidate = false
1099
+
1100
+ if (StepperState.uploadMode === 'file_path') {
1101
+ var filePath = $('#import-file-path').val() || ''
1102
+ canValidate = filePath.trim().length > 0 && hasAdminSet
1103
+ } else {
1104
+ var fileCheck = StepperState.uploadedFiles.reduce(function (check, f) {
1105
+ if (f.fileType === 'csv') check.hasCsv = true
1106
+ if (f.fileType === 'zip') check.hasZip = true
1107
+ return check
1108
+ }, { hasCsv: false, hasZip: false })
1109
+ canValidate = (fileCheck.hasCsv || fileCheck.hasZip) && hasAdminSet && !StepperState.validated && StepperState.uploadsInProgress === 0
1110
+ }
1111
+
1112
+ var $validateBtn = StepperState.uploadMode === 'file_path' ? $('#validate-path-btn') : $('#validate-upload-btn')
1113
+ $validateBtn.prop('disabled', !canValidate)
1114
+ if (canValidate || StepperState.skipValidation) {
1115
+ $('.skip-validation-label').show()
1116
+ } else {
1117
+ $('.skip-validation-label').hide()
1118
+ }
1119
+ }
1120
+
1121
+ // Render uploaded files
1122
+ function renderUploadedFiles() {
1123
+ // Ensure admin set state is captured (handles timing issues)
1124
+ // Always refresh from DOM to ensure we have the current value
1125
+ var $adminSetSelect = $('#importer-admin-set')
1126
+ if ($adminSetSelect.length && $adminSetSelect.val()) {
1127
+ StepperState.adminSetId = $adminSetSelect.val()
1128
+ StepperState.adminSetName = $adminSetSelect.find('option:selected').text()
1129
+ }
1130
+
1131
+ var state = StepperState.uploadState
1132
+ var files = StepperState.uploadedFiles
1133
+
1134
+ // Don't manipulate upload DOM elements when in file path mode
1135
+ if (StepperState.uploadMode === 'file_path') return
1136
+
1137
+ if (state === CONSTANTS.UPLOAD_STATES.EMPTY) {
1138
+ $('.upload-zone-empty').show()
1139
+ $('.uploaded-files-container').hide()
1140
+ $('.add-another-dropzone').hide()
1141
+ updateValidateButtonState()
1142
+ return
1143
+ }
1144
+
1145
+ $('.upload-zone-empty').hide()
1146
+ $('.uploaded-files-container').show()
1147
+
1148
+ var $list = $('.uploaded-files-list')
1149
+ $list.empty()
1150
+
1151
+ // Render all uploaded files — only show status icon after validation
1152
+ var validationStatus = null
1153
+ if (StepperState.validated && StepperState.validationData) {
1154
+ var vd = StepperState.validationData
1155
+ if (!vd.isValid) {
1156
+ validationStatus = 'error'
1157
+ } else if (vd.hasWarnings) {
1158
+ validationStatus = 'warning'
1159
+ } else {
1160
+ validationStatus = 'success'
1161
+ }
1162
+ }
1163
+ var fileRows = files.map(function (file) {
1164
+ return renderFileRow(file)
1165
+ })
1166
+ $list.append(fileRows.join(''))
1167
+
1168
+ // Show appropriate info message based on state
1169
+ var infoMessage = ''
1170
+ if (state === CONSTANTS.UPLOAD_STATES.ZIP_WITH_CSV) {
1171
+ infoMessage =
1172
+ '<div class="upload-info"><span class="fa fa-info-circle"></span> ' + t('upload_single_package') + '</div>'
1173
+ } else if (state === CONSTANTS.UPLOAD_STATES.CSV_ONLY) {
1174
+ infoMessage =
1175
+ '<div class="upload-info"><span class="fa fa-info-circle"></span> ' + t('upload_csv_only') + '</div>'
1176
+ } else if (state === CONSTANTS.UPLOAD_STATES.ZIP_FILES_ONLY) {
1177
+ infoMessage =
1178
+ '<div class="upload-info"><span class="fa fa-info-circle"></span> ' + t('upload_zip_only') + '</div>'
1179
+ } else if (state === CONSTANTS.UPLOAD_STATES.CSV_AND_ZIP) {
1180
+ infoMessage =
1181
+ '<div class="upload-info"><span class="fa fa-info-circle"></span> ' + t('upload_csv_and_zip') + '</div>'
1182
+ }
1183
+
1184
+ $('.upload-info-message').html(infoMessage)
1185
+
1186
+ // Show file count if multiple files
1187
+ if (files.length > 1) {
1188
+ $('.uploaded-files-header strong').text(
1189
+ t('uploaded_files', { count: files.length })
1190
+ )
1191
+ } else {
1192
+ $('.uploaded-files-header strong').text(t('uploaded_file'))
1193
+ }
1194
+
1195
+ // Show/hide "Add another file" dropzone based on file count
1196
+ if (files.length === 1) {
1197
+ $('.add-another-dropzone').show()
1198
+ } else {
1199
+ $('.add-another-dropzone').hide()
1200
+ }
1201
+
1202
+ updateValidateButtonState()
1203
+ }
1204
+
1205
+ // Render a single file row
1206
+ function renderFileRow(file) {
1207
+ var type = file.fileType
1208
+ var name = file.name
1209
+ var subtitle = file.subtitle || file.size
1210
+ // Show progress until all chunks are done (uploadComplete); uploadId is set after the first
1211
+ // chunk so it cannot be used here — the progress bar would vanish mid-upload otherwise.
1212
+ // Demo entries (no .file) are treated as already complete.
1213
+ var isUploading = file.file && !file.uploadComplete
1214
+ var verified = !isUploading
1215
+
1216
+ var icon = type === 'csv' ? 'fa-file-text' : 'fa-file-archive-o'
1217
+ var iconBg = type === 'csv' ? 'file-icon-csv' : 'file-icon-zip'
1218
+
1219
+ var progress = file.uploadProgress || 0
1220
+ var progressBlock = ''
1221
+ if (isUploading) {
1222
+ progressBlock =
1223
+ '<div class="upload-progress-block">' +
1224
+ '<div class="upload-progress-label">Uploading… ' + progress + '%</div>' +
1225
+ '<div class="upload-progress-bar-container" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="' + progress + '">' +
1226
+ '<div class="upload-progress-bar" style="width:' + progress + '%;"></div>' +
1227
+ '</div>' +
1228
+ '</div>'
1229
+ }
1230
+
1231
+ var statusHtml = verified
1232
+ ? '<span class="fa fa-check-circle file-verified file-status-success"></span>'
1233
+ : ''
1234
+
1235
+ var safeName = escapeHtml(name)
1236
+ var safeSubtitle = escapeHtml(subtitle)
1237
+
1238
+ return (
1239
+ '<div class="file-row" data-file-id="' + file.id + '">' +
1240
+ '<div class="file-row-main">' +
1241
+ '<div class="file-info">' +
1242
+ '<div class="file-icon ' + iconBg + '"><span class="fa ' + icon + '"></span></div>' +
1243
+ '<div class="file-details">' +
1244
+ '<div class="file-name">' + safeName + '</div>' +
1245
+ '<div class="file-subtitle">' + safeSubtitle + '</div>' +
1246
+ '</div>' +
1247
+ '</div>' +
1248
+ '<div class="file-actions">' +
1249
+ statusHtml +
1250
+ '<button type="button" class="file-remove-btn" aria-label="' + t('remove_file') + '">' +
1251
+ '<span class="fa fa-times"></span>' +
1252
+ '</button>' +
1253
+ '</div>' +
1254
+ '</div>' +
1255
+ progressBlock +
1256
+ '</div>'
1257
+ )
1258
+ }
1259
+
1260
+ // Reset upload state
1261
+ function resetUploadState() {
1262
+ // Abort in-progress uploads and delete server-side files.
1263
+ // Mark as user-aborted first so the catch handler doesn't double-decrement uploadsInProgress.
1264
+ StepperState.uploadedFiles.forEach(function (f) {
1265
+ if (f.uploadXhr) {
1266
+ f.uploadAbortedByUser = true
1267
+ f.uploadXhr.abort()
1268
+ }
1269
+ if (f.uploadId) {
1270
+ $.ajax({ url: CONSTANTS.UPLOAD_URL + f.uploadId, method: 'DELETE', timeout: CONSTANTS.AJAX_TIMEOUT_SHORT })
1271
+ }
1272
+ })
1273
+ StepperState.uploadsInProgress = 0
1274
+
1275
+ StepperState.uploadedFiles = []
1276
+ StepperState.uploadState = CONSTANTS.UPLOAD_STATES.EMPTY
1277
+ StepperState.validated = false
1278
+ StepperState.validationData = null
1279
+ StepperState.warningsAcked = false
1280
+ StepperState.skipValidation = false
1281
+ StepperState.demoScenario = null
1282
+ $('#file-input').val('')
1283
+ $('#import-file-path').val('')
1284
+ $('.validation-results').hide()
1285
+ $('.warning-acknowledgment').hide()
1286
+ $('#warnings-acked').prop('checked', false)
1287
+ clearFileUploadError()
1288
+
1289
+ // Clear all notifications
1290
+ $('#upload-notifications').empty()
1291
+
1292
+ // Reset skip validation checkbox and label
1293
+ $('#skip-validation-checkbox').prop('checked', false)
1294
+ // Visibility is controlled by updateValidateButtonState
1295
+
1296
+ // Reset both validate buttons to original state
1297
+ $('#validate-upload-btn')
1298
+ .prop('disabled', true)
1299
+ .html('<span class="fa fa-file-text"></span> ' + t('validate_upload'))
1300
+ $('#validate-path-btn')
1301
+ .prop('disabled', true)
1302
+ .html('<span class="fa fa-file-text"></span> ' + t('validate_path'))
1303
+
1304
+ renderUploadedFiles()
1305
+ updateStepNavigation()
1306
+ }
1307
+
1308
+ // Full reset: clear everything and return to step 1
1309
+ function startOver() {
1310
+ // Reset files and validation
1311
+ resetUploadState()
1312
+
1313
+ // Reset upload mode to default "upload" tab
1314
+ StepperState.uploadMode = 'upload'
1315
+ $('.upload-mode-tab').removeClass('active')
1316
+ $('.upload-mode-tab[data-upload-mode="upload"]').addClass('active')
1317
+ $('.uploaded-files-container').hide()
1318
+ $('.file-path-panel').hide()
1319
+ $('#validate-path-btn').hide()
1320
+ $('#validate-upload-btn').show()
1321
+
1322
+ // Reset admin set to default
1323
+ var $adminSetSelect = $('#importer-admin-set')
1324
+ var defaultAdminSet = $adminSetSelect.find('option').filter(function () {
1325
+ return $(this).text().indexOf('Default') !== -1
1326
+ }).val() || ''
1327
+ $adminSetSelect.val(defaultAdminSet)
1328
+ StepperState.adminSetId = defaultAdminSet
1329
+ StepperState.adminSetName = $adminSetSelect.find('option:selected').text()
1330
+
1331
+ // Reset settings
1332
+ setDefaultImportName()
1333
+ StepperState.settings.visibility = 'open'
1334
+ $('.visibility-card').removeClass('active')
1335
+ $('.visibility-card[data-visibility="open"]').addClass('active')
1336
+ $('input[name="importer[parser_fields][visibility]"][value="open"]').prop('checked', true)
1337
+ $('select[name="importer[parser_fields][rights_statement]"]').val('')
1338
+ StepperState.settings.rightsStatement = ''
1339
+ $('#bulkrax_importer_limit').val('')
1340
+ StepperState.settings.limit = ''
1341
+ $('input[name="importer[parser_fields][override_rights_statement]"]').prop('checked', false)
1342
+
1343
+ // Clear review step warnings from previous run
1344
+ $('.review-warnings-list').empty()
1345
+ $('.review-warnings').hide()
1346
+ $('.large-import-warning').hide()
1347
+
1348
+ // Navigate to step 1
1349
+ goToStep(1)
1350
+ }
1351
+
1352
+ // ============================================================================
1353
+ // VALIDATION
1354
+ // ============================================================================
1355
+
1356
+ // Perform validation API call with uploaded file IDs
1357
+ function performValidation(data) {
1358
+ return $.ajax({
1359
+ url: CONSTANTS.ENDPOINTS.VALIDATE,
1360
+ method: 'POST',
1361
+ data: data,
1362
+ timeout: CONSTANTS.AJAX_TIMEOUT_LONG
1363
+ })
1364
+ }
1365
+
1366
+ function performFilePathValidation(filePath) {
1367
+ return $.ajax({
1368
+ url: CONSTANTS.ENDPOINTS.VALIDATE,
1369
+ method: 'POST',
1370
+ data: {
1371
+ importer: {
1372
+ parser_fields: {
1373
+ import_file_path: filePath
1374
+ },
1375
+ admin_set_id: StepperState.adminSetId
1376
+ },
1377
+ locale: $('input[name="locale"]').val()
1378
+ },
1379
+ timeout: CONSTANTS.AJAX_TIMEOUT_LONG
1380
+ })
1381
+ }
1382
+
1383
+ // Simulate validation for demo scenarios
1384
+ function performMockValidation() {
1385
+ return new Promise(function (resolve, reject) {
1386
+ setTimeout(function () {
1387
+ var mockData = getMockValidationData()
1388
+ if (!mockData) {
1389
+ reject(new Error(t('demo_data_not_loaded')))
1390
+ return
1391
+ }
1392
+ resolve(mockData)
1393
+ }, CONSTANTS.VALIDATION_DELAY)
1394
+ })
1395
+ }
1396
+
1397
+ // Update UI after successful validation
1398
+ function handleValidationSuccess(data, $btn) {
1399
+ var normalized = normalizeValidationData(data)
1400
+ StepperState.validated = true
1401
+ StepperState.validationData = normalized
1402
+
1403
+ try {
1404
+ renderValidationResults(normalized)
1405
+ renderUploadedFiles()
1406
+ $('.skip-validation-label').hide()
1407
+ $btn.html('<span class="fa fa-check-circle"></span> ' + t('validated'))
1408
+ updateStepNavigation()
1409
+ } catch (e) {
1410
+ console.error('Validation results render issue:', e)
1411
+ throw new Error(t('render_error'))
1412
+ }
1413
+ }
1414
+
1415
+ // Update UI after validation error
1416
+ function handleValidationError(error, $btn) {
1417
+ var xhr = error
1418
+ var status = xhr.statusText || 'error'
1419
+ var errorMsg = t('validation_failed')
1420
+
1421
+ // Handle specific error cases
1422
+ if (status === 'timeout') {
1423
+ errorMsg = t('validation_timeout')
1424
+ } else if (xhr.status === 0) {
1425
+ errorMsg = t('network_error')
1426
+ } else if (xhr.status === 413) {
1427
+ errorMsg = t('files_too_large')
1428
+ } else if (xhr.status === 422) {
1429
+ errorMsg = xhr.responseJSON && xhr.responseJSON.error
1430
+ ? xhr.responseJSON.error
1431
+ : t('invalid_file_format')
1432
+ } else if (xhr.status >= 500) {
1433
+ errorMsg = t('server_error')
1434
+ } else if (xhr.responseJSON && xhr.responseJSON.error) {
1435
+ errorMsg = xhr.responseJSON.error
1436
+ }
1437
+
1438
+ console.error('Validation error:', {
1439
+ status: status,
1440
+ error: xhr.statusText,
1441
+ statusCode: xhr.status,
1442
+ response: xhr.responseJSON
1443
+ })
1444
+
1445
+ showNotification(errorMsg, 'error')
1446
+
1447
+ // Reset button state
1448
+ var resetLabel = $btn.attr('id') === 'validate-path-btn'
1449
+ ? '<span class="fa fa-file-text"></span> ' + t('validate_path')
1450
+ : '<span class="fa fa-file-text"></span> ' + t('validate_upload')
1451
+ $btn
1452
+ .prop('disabled', false)
1453
+ .html(resetLabel)
1454
+
1455
+ // Reset validation state on error
1456
+ StepperState.validated = false
1457
+ StepperState.validationData = null
1458
+ }
1459
+
1460
+ // Validate files (AJAX call to backend)
1461
+ function validateFiles() {
1462
+ var $btn = StepperState.uploadMode === 'file_path' ? $('#validate-path-btn') : $('#validate-upload-btn')
1463
+ $btn
1464
+ .prop('disabled', true)
1465
+ .html('<span class="fa fa-spinner fa-spin"></span> ' + t('validating'))
1466
+
1467
+ // Uncheck "Skip validation" since the user is actively validating
1468
+ if (StepperState.skipValidation) {
1469
+ StepperState.skipValidation = false
1470
+ $('#skip-validation-checkbox').prop('checked', false)
1471
+ }
1472
+
1473
+ // Check if we're in demo mode (no real uploaded files on server)
1474
+ var hasRealFiles = StepperState.uploadedFiles.some(function (f) { return f.uploadId })
1475
+ var filePathValue = $('#import-file-path').val().trim()
1476
+ var hasFilePath = filePathValue.length > 0
1477
+ var useMockData = !hasRealFiles && !hasFilePath
1478
+
1479
+ // Send uploaded file IDs instead of raw file bytes
1480
+ var validationData
1481
+ if (!useMockData && !hasFilePath) {
1482
+ validationData = {
1483
+ uploaded_files: StepperState.uploadedFiles
1484
+ .filter(function (f) { return f.uploadId })
1485
+ .map(function (f) { return f.uploadId }),
1486
+ importer: {
1487
+ admin_set_id: StepperState.adminSetId
1488
+ },
1489
+ locale: $('input[name="locale"]').val()
1490
+ }
1491
+ }
1492
+
1493
+ // Choose validation method based on the active upload mode tab.
1494
+ // Each tab is validated independently — no cross-tab priority.
1495
+ var validationPromise
1496
+ if (StepperState.uploadMode === 'file_path') {
1497
+ var filePathValue = $('#import-file-path').val().trim()
1498
+ validationPromise = performFilePathValidation(filePathValue)
1499
+ } else {
1500
+ if (useMockData) {
1501
+ validationPromise = performMockValidation()
1502
+ } else if (validationData) {
1503
+ validationPromise = performValidation(validationData)
1504
+ } else {
1505
+ showNotification('No files to validate. Upload files or enter an import path.', 'warning')
1506
+ $btn.prop('disabled', false).html($btn.attr('id') === 'validate-path-btn'
1507
+ ? '<span class="fa fa-file-text"></span> Validate Files from Import Path'
1508
+ : '<span class="fa fa-file-text"></span> Validate Files from Upload')
1509
+ return
1510
+ }
1511
+ }
1512
+
1513
+ // Handle validation result
1514
+ validationPromise
1515
+ .then(function (data) {
1516
+ handleValidationSuccess(data, $btn)
1517
+ })
1518
+ .catch(function (error) {
1519
+ handleValidationError(error, $btn)
1520
+ })
1521
+ }
1522
+
1523
+ // Helper: Determine if validation data indicates valid state
1524
+ function determineIsValid(data) {
1525
+ // Check both camelCase and snake_case property names
1526
+ var isValidValue = normalizeBoolean(data.isValid != null ? data.isValid : data.is_valid)
1527
+
1528
+ // If explicitly set to true or false, use that value
1529
+ if (isValidValue !== null) {
1530
+ return isValidValue
1531
+ }
1532
+
1533
+ // Fallback: If we have row data but no explicit validity flag,
1534
+ // assume valid (backend processed without marking as invalid)
1535
+ var hasRowData = data.rowCount != null || data.row_count != null
1536
+ return hasRowData
1537
+ }
1538
+
1539
+ // Helper: Determine if validation has warnings
1540
+ function determineHasWarnings(data) {
1541
+ var hasWarningsValue = normalizeBoolean(
1542
+ data.hasWarnings != null ? data.hasWarnings : data.has_warnings
1543
+ )
1544
+ return hasWarningsValue === true
1545
+ }
1546
+
1547
+ function normalizeValidationData(data) {
1548
+ if (!data) return data
1549
+ return {
1550
+ collections: data.collections,
1551
+ works: data.works,
1552
+ fileSets: data.fileSets || data.file_sets,
1553
+ totalItems: data.totalItems != null ? data.totalItems : data.total_items,
1554
+ headers: data.headers,
1555
+ missingRequired: data.missingRequired || data.missing_required,
1556
+ unrecognized: data.unrecognized,
1557
+ rowCount: data.rowCount != null ? data.rowCount : data.row_count,
1558
+ isValid: determineIsValid(data),
1559
+ hasWarnings: determineHasWarnings(data),
1560
+ fileReferences: data.fileReferences != null ? data.fileReferences : data.file_references,
1561
+ missingFiles: data.missingFiles || data.missing_files,
1562
+ foundFiles: data.foundFiles != null ? data.foundFiles : data.found_files,
1563
+ zipIncluded: data.zipIncluded != null ? data.zipIncluded : data.zip_included,
1564
+ messages: data.messages,
1565
+ validationErrorsCacheKey: data.validationErrorsCacheKey || null
1566
+ }
1567
+ }
1568
+
1569
+ // Normalize childrenIds into parentIds and build a hierarchy lookup map.
1570
+ // This converts parent-declares-children relationships into the canonical
1571
+ // child-declares-parent form, then pre-computes a map for O(1) hierarchy lookups.
1572
+
1573
+ function normalizeRelationships(data) {
1574
+ var allItems = data.collections.concat(data.works)
1575
+
1576
+ // Build id -> item lookup
1577
+ var itemMap = {}
1578
+ allItems.forEach(function (item) {
1579
+ if (!item.parentIds) { item.parentIds = [] }
1580
+ itemMap[item.id] = item
1581
+ })
1582
+
1583
+ allItems.forEach(function (item) {
1584
+ if (item.childIds && item.childIds.length > 0) {
1585
+ item.childIds.forEach(function (childId) {
1586
+ var child = itemMap[childId]
1587
+ if (child) {
1588
+ if (child.parentIds.indexOf(item.id) === -1) {
1589
+ child.parentIds.push(item.id)
1590
+ }
1591
+ }
1592
+ })
1593
+ }
1594
+ })
1595
+
1596
+ // Inject stub nodes for existing-record relationships so the tree can
1597
+ // render them as children/parents even though they are not in the CSV.
1598
+ allItems.forEach(function (item) {
1599
+ // existingChildIds → the item in the CSV has a child that lives in the repo
1600
+ ;(item.existingChildIds || []).forEach(function (childId) {
1601
+ if (!itemMap[childId]) {
1602
+ itemMap[childId] = { id: childId, title: childId, type: 'existing', parentIds: [], existing: true }
1603
+ }
1604
+ if (itemMap[childId].parentIds.indexOf(item.id) === -1) {
1605
+ itemMap[childId].parentIds.push(item.id)
1606
+ }
1607
+ })
1608
+ // existingParentIds → the item in the CSV has a parent that lives in the repo
1609
+ ;(item.existingParentIds || []).forEach(function (parentId) {
1610
+ if (!itemMap[parentId]) {
1611
+ itemMap[parentId] = { id: parentId, title: parentId, type: 'existing', parentIds: [], existing: true }
1612
+ }
1613
+ if (item.parentIds.indexOf(parentId) === -1) {
1614
+ item.parentIds.push(parentId)
1615
+ }
1616
+ })
1617
+ })
1618
+
1619
+ // Build hierarchy lookup map from normalized parentIds (including existing stubs)
1620
+ var hierarchyMap = {}
1621
+ Object.keys(itemMap).forEach(function (id) {
1622
+ var item = itemMap[id]
1623
+ item.parentIds.forEach(function (parentId) {
1624
+ if (!hierarchyMap[parentId]) { hierarchyMap[parentId] = [] }
1625
+ if (hierarchyMap[parentId].indexOf(item) === -1) {
1626
+ hierarchyMap[parentId].push(item)
1627
+ }
1628
+ })
1629
+ })
1630
+
1631
+ return hierarchyMap
1632
+ }
1633
+
1634
+ // ============================================================================
1635
+ // VALIDATION RESULTS RENDERING
1636
+ // ============================================================================
1637
+
1638
+ // Render validation results
1639
+ function renderValidationResults(data) {
1640
+ $('.validation-results').show()
1641
+
1642
+ // Normalize childrenIds -> parentIds and build hierarchy lookup map
1643
+ var hierarchyMap = normalizeRelationships(data)
1644
+
1645
+ // Import size gauge
1646
+ renderImportSizeGauge(data.totalItems)
1647
+
1648
+ // Validation status accordion
1649
+ renderValidationAccordions(data)
1650
+
1651
+ // Import summary
1652
+ renderImportSummary(data, hierarchyMap)
1653
+
1654
+ // Warning/error acknowledgment
1655
+ if (data.hasWarnings || !data.isValid) {
1656
+ $('.warning-acknowledgment').show()
1657
+ }
1658
+
1659
+ // Download errors button
1660
+ if (data.validationErrorsCacheKey) {
1661
+ var downloadUrl =
1662
+ CONSTANTS.ENDPOINTS.DOWNLOAD_VALIDATION_ERRORS + '?key=' + encodeURIComponent(data.validationErrorsCacheKey)
1663
+ var $btn = $(
1664
+ '<a class="btn btn-outline-secondary btn-sm mt-2" href="' +
1665
+ downloadUrl +
1666
+ '" download>' +
1667
+ '<i class="fa fa-download"></i> ' +
1668
+ t('download_validation_errors_csv') +
1669
+ '</a>'
1670
+ )
1671
+ $('.validation-results .download-errors-container').remove()
1672
+ $('.validation-results').append($('<div class="download-errors-container mt-2"></div>').append($btn))
1673
+ } else {
1674
+ $('.validation-results .download-errors-container').remove()
1675
+ }
1676
+ }
1677
+
1678
+ // Render import size gauge
1679
+ function renderImportSizeGauge(count) {
1680
+ var pct, color, zone, msg, cardClass
1681
+
1682
+ if (count <= CONSTANTS.IMPORT_SIZE_OPTIMAL) {
1683
+ pct = (count / CONSTANTS.IMPORT_SIZE_OPTIMAL) * 33
1684
+ color = 'gauge-marker-optimal'
1685
+ zone = t('gauge_optimal')
1686
+ msg = t('gauge_optimal_msg')
1687
+ cardClass = 'gauge-card-optimal'
1688
+ } else if (count <= CONSTANTS.IMPORT_SIZE_MODERATE) {
1689
+ pct = 33 + ((count - CONSTANTS.IMPORT_SIZE_OPTIMAL) / (CONSTANTS.IMPORT_SIZE_MODERATE - CONSTANTS.IMPORT_SIZE_OPTIMAL)) * 33
1690
+ color = 'gauge-marker-moderate'
1691
+ zone = t('gauge_moderate')
1692
+ msg = t('gauge_moderate_msg')
1693
+ cardClass = 'gauge-card-moderate'
1694
+ } else {
1695
+ pct = Math.min(66 + ((count - CONSTANTS.IMPORT_SIZE_MODERATE) / CONSTANTS.IMPORT_SIZE_MODERATE) * 34, 100)
1696
+ color = 'gauge-marker-large'
1697
+ zone = t('gauge_large')
1698
+ msg = t('gauge_large_msg', { limit: CONSTANTS.IMPORT_SIZE_OPTIMAL })
1699
+ cardClass = 'gauge-card-large'
1700
+ }
1701
+
1702
+ var html =
1703
+ '<div class="gauge-card ' +
1704
+ cardClass +
1705
+ '">' +
1706
+ '<div class="gauge-header">' +
1707
+ '<span>' +
1708
+ t('gauge_import_size', { count: count }) +
1709
+ '</span>' +
1710
+ '<span class="gauge-zone">' +
1711
+ zone +
1712
+ '</span>' +
1713
+ '</div>' +
1714
+ '<div class="gauge-track">' +
1715
+ '<div class="gauge-segment gauge-segment-optimal"></div>' +
1716
+ '<div class="gauge-segment gauge-segment-moderate"></div>' +
1717
+ '<div class="gauge-segment gauge-segment-large"></div>' +
1718
+ '<div class="gauge-marker ' +
1719
+ color +
1720
+ '" style="left: ' +
1721
+ pct +
1722
+ '%"></div>' +
1723
+ '</div>' +
1724
+ '<div class="gauge-labels">' +
1725
+ '<span>0</span><span>' + CONSTANTS.IMPORT_SIZE_OPTIMAL + '</span><span>' + CONSTANTS.IMPORT_SIZE_MODERATE + '</span><span>' + CONSTANTS.IMPORT_SIZE_LARGE + '+</span>' +
1726
+ '</div>' +
1727
+ '<p class="gauge-message text-muted small">' +
1728
+ msg +
1729
+ '</p>' +
1730
+ '</div>'
1731
+
1732
+ $('.import-size-gauge').html(html)
1733
+ }
1734
+
1735
+ // Group items by model for missing required fields
1736
+ function groupItemsByModel(items) {
1737
+ var grouped = {}
1738
+ items.forEach(function (item) {
1739
+ var modelName = item.model || 'Unknown'
1740
+ if (!grouped[modelName]) {
1741
+ grouped[modelName] = []
1742
+ }
1743
+ grouped[modelName].push(item.field)
1744
+ })
1745
+ return grouped
1746
+ }
1747
+
1748
+ // Render missing required fields grouped by model
1749
+ function renderMissingRequiredFields(items) {
1750
+ var groupedByModel = groupItemsByModel(items)
1751
+ var parts = []
1752
+
1753
+ Object.keys(groupedByModel).forEach(function (modelName) {
1754
+ parts.push('<div class="missing-field-group">')
1755
+ parts.push('<strong class="missing-field-model">' + modelName + '</strong>')
1756
+ parts.push('<ul>')
1757
+
1758
+ // Map fields to list items, then join once
1759
+ var fieldItems = groupedByModel[modelName].map(function (field) {
1760
+ return '<li>• ' + field + '</li>'
1761
+ })
1762
+ parts.push(fieldItems.join(''))
1763
+
1764
+ parts.push('</ul>')
1765
+ parts.push('</div>')
1766
+ })
1767
+
1768
+ return parts.join('')
1769
+ }
1770
+
1771
+ // Render default issue items (unrecognized fields, file references, etc.)
1772
+ function renderDefaultIssueItems(items) {
1773
+ var listItems = items.map(function (item) {
1774
+ var msg = item.message ? ' — ' + item.message : ''
1775
+ return '<li>• ' + item.field + msg + '</li>'
1776
+ })
1777
+
1778
+ return '<ul>' + listItems.join('') + '</ul>'
1779
+ }
1780
+
1781
+ // Render issue items based on issue type
1782
+ function renderIssueItems(issue) {
1783
+ var hasModelField = issue.items.some(function (item) { return item.model })
1784
+
1785
+ if (issue.type === 'missing_required_fields' && hasModelField) {
1786
+ return renderMissingRequiredFields(issue.items)
1787
+ } else {
1788
+ return renderDefaultIssueItems(issue.items)
1789
+ }
1790
+ }
1791
+
1792
+ // Render validation accordions
1793
+ function renderValidationAccordions(data) {
1794
+ var $wrapper = $('.accordion-wrapper')
1795
+ $wrapper.empty()
1796
+
1797
+ // Check if we have the new messages structure
1798
+ if (!data.messages || !data.messages.validationStatus) {
1799
+ console.error('Invalid validation response: missing messages structure')
1800
+ return
1801
+ }
1802
+
1803
+ // Main validation status - FROM BACKEND
1804
+ var status = data.messages.validationStatus
1805
+ var content = '<p>' + status.summary + '</p>'
1806
+ if (status.details) {
1807
+ content += '<p class="text-muted small">' + status.details + '</p>'
1808
+ }
1809
+
1810
+ $wrapper.append(
1811
+ createAccordion(
1812
+ status.title,
1813
+ status.icon,
1814
+ status.severity,
1815
+ null,
1816
+ status.defaultOpen,
1817
+ content
1818
+ )
1819
+ )
1820
+
1821
+ // Render all issues - FROM BACKEND
1822
+ // Each issue uses its own severity for independent coloring
1823
+ if (data.messages.issues && data.messages.issues.length > 0) {
1824
+ data.messages.issues.forEach(function (issue) {
1825
+ var issueContent = ''
1826
+
1827
+ if (issue.description) {
1828
+ issueContent += '<p>' + issue.description + '</p>'
1829
+ }
1830
+
1831
+ if (issue.summary) {
1832
+ issueContent += '<p>' + issue.summary + '</p>'
1833
+ }
1834
+
1835
+ if (issue.items && issue.items.length > 0) {
1836
+ issueContent += renderIssueItems(issue)
1837
+ }
1838
+
1839
+ if (issue.details) {
1840
+ issueContent += '<p class="small">' + issue.details + '</p>'
1841
+ }
1842
+
1843
+ $wrapper.append(
1844
+ createAccordion(
1845
+ issue.title,
1846
+ issue.icon,
1847
+ issue.severity,
1848
+ issue.count,
1849
+ issue.defaultOpen,
1850
+ issueContent
1851
+ )
1852
+ )
1853
+ })
1854
+ }
1855
+
1856
+ }
1857
+
1858
+ // Create accordion HTML
1859
+ function createAccordion(title, icon, variant, count, defaultOpen, content) {
1860
+ var variantClass = 'accordion-' + variant
1861
+ var openClass = defaultOpen ? 'accordion-open' : ''
1862
+ var contentDisplay = defaultOpen ? 'block' : 'none'
1863
+ var chevron = defaultOpen ? 'fa-chevron-down' : 'fa-chevron-right'
1864
+ var countBadge =
1865
+ count !== null ? '<span class="accordion-count">' + count + '</span>' : ''
1866
+
1867
+ return (
1868
+ '<div class="accordion-item ' +
1869
+ variantClass +
1870
+ ' ' +
1871
+ openClass +
1872
+ '">' +
1873
+ '<button type="button" class="accordion-header">' +
1874
+ '<div class="accordion-title-bar">' +
1875
+ '<span class="fa ' +
1876
+ icon +
1877
+ ' accordion-status-icon"></span>' +
1878
+ '<span>' +
1879
+ title +
1880
+ '</span>' +
1881
+ countBadge +
1882
+ '</div>' +
1883
+ '<span class="fa ' +
1884
+ chevron +
1885
+ ' accordion-chevron"></span>' +
1886
+ '</button>' +
1887
+ '<div class="accordion-content" style="display: ' +
1888
+ contentDisplay +
1889
+ '">' +
1890
+ content +
1891
+ '</div>' +
1892
+ '</div>'
1893
+ )
1894
+ }
1895
+
1896
+ // Note: Accordion toggle events are handled via event delegation in bindDelegatedEvents()
1897
+
1898
+ // ============================================================================
1899
+ // IMPORT SUMMARY & HIERARCHY
1900
+ // ============================================================================
1901
+
1902
+ // Render import summary
1903
+ function renderImportSummary(data, hierarchyMap) {
1904
+ $('.summary-card-collections .summary-number').text(data.collections.length)
1905
+ $('.summary-card-works .summary-number').text(data.works.length)
1906
+ $('.summary-card-filesets .summary-number').text(data.fileSets.length)
1907
+
1908
+ // Hierarchy accordions
1909
+ var $container = $('.hierarchy-accordions')
1910
+ $container.empty()
1911
+
1912
+ // Import hierarchy — collections, nested items, and standalone works in one tree
1913
+ var topLevelCollections = data.collections.filter(function (c) {
1914
+ return !c.parentIds || c.parentIds.length === 0
1915
+ })
1916
+ var orphanWorks = data.works.filter(function (w) {
1917
+ return !w.parentIds || w.parentIds.length === 0
1918
+ })
1919
+ // Existing-record stubs that are top-level parents (not themselves children of anything)
1920
+ var existingRoots = Object.keys(hierarchyMap)
1921
+ .filter(function (id) {
1922
+ var allCsvIds = data.collections.concat(data.works).map(function (i) { return i.id })
1923
+ return allCsvIds.indexOf(id) === -1
1924
+ })
1925
+ .map(function (id) {
1926
+ return { id: id, title: id, type: 'existing', parentIds: [], existing: true }
1927
+ })
1928
+ var hierarchyContent =
1929
+ '<div class="hierarchy-tree">' +
1930
+ existingRoots
1931
+ .map(function (e) {
1932
+ return renderTreeItem(e, hierarchyMap, 0, new Set())
1933
+ })
1934
+ .join('') +
1935
+ topLevelCollections
1936
+ .map(function (c) {
1937
+ return renderTreeItem(c, hierarchyMap, 0, new Set())
1938
+ })
1939
+ .join('') +
1940
+ orphanWorks
1941
+ .map(function (w) {
1942
+ return renderTreeItem(w, hierarchyMap, 0, new Set())
1943
+ })
1944
+ .join('') +
1945
+ '</div>'
1946
+ var itemCount = data.collections.length + data.works.length
1947
+ $container.append(
1948
+ createAccordion(
1949
+ t('import_hierarchy'),
1950
+ 'fa-sitemap',
1951
+ 'info',
1952
+ itemCount,
1953
+ false,
1954
+ hierarchyContent
1955
+ )
1956
+ )
1957
+
1958
+ }
1959
+
1960
+ // Render tree item recursively using pre-computed hierarchyMap
1961
+ // Guarded with depth limit and circular reference detection
1962
+ function renderTreeItem(item, hierarchyMap, depth, visited) {
1963
+ depth = depth || 0
1964
+
1965
+ // Limit tree depth to prevent stack overflow
1966
+ if (depth >= CONSTANTS.MAX_TREE_DEPTH) {
1967
+ console.warn('Max tree depth reached for item:', item.id)
1968
+ return '<div class="tree-item tree-truncated" style="padding-left: ' + (depth * 20) + 'px">' +
1969
+ '<span class="fa fa-ellipsis-h text-muted"></span>' +
1970
+ '<span class="tree-label text-muted"><em>' + t('hierarchy_too_deep', { max: CONSTANTS.MAX_TREE_DEPTH }) + '</em></span>' +
1971
+ '</div>'
1972
+ }
1973
+
1974
+ // Detect circular references
1975
+ if (visited.has(item.id)) {
1976
+ console.error('Circular reference detected for item:', item.id)
1977
+ return '<div class="tree-item tree-error" style="padding-left: ' + (depth * 20) + 'px">' +
1978
+ '<span class="fa fa-exclamation-triangle text-danger"></span>' +
1979
+ '<span class="tree-label text-danger"><em>' + t('circular_reference') + '</em></span>' +
1980
+ '</div>'
1981
+ }
1982
+
1983
+ visited.add(item.id)
1984
+
1985
+ var children = hierarchyMap[item.id] || []
1986
+ var hasChildren = children.length > 0
1987
+ var isExisting = !!item.existing
1988
+ var icon = item.type === 'collection' ? 'fa-folder' : 'fa-file-o'
1989
+ var iconColor = isExisting ? 'text-muted' : (item.type === 'collection' ? 'text-primary' : 'text-muted')
1990
+ // Hidden chevron still takes up space (via fixed width in CSS) to prevent icon shifting
1991
+ var chevronClass = hasChildren ? 'tree-chevron' : 'tree-chevron tree-chevron-hidden'
1992
+ var chevron = '<span class="fa fa-chevron-right ' + chevronClass + '"></span>'
1993
+ var count = hasChildren
1994
+ ? ' <span class="text-muted small">(' + children.length + ')</span>'
1995
+ : ''
1996
+ var paddingLeft = depth * 20
1997
+
1998
+ // Escape user-provided data
1999
+ var safeId = escapeHtml(item.id)
2000
+ var safeTitle = escapeHtml(item.title)
2001
+
2002
+ // Make expandable tree items focusable and semantically interactive
2003
+ var treeItemAttrs = hasChildren
2004
+ ? ' tabindex="0" role="treeitem" aria-expanded="false"'
2005
+ : ''
2006
+
2007
+ var existingBadge = isExisting
2008
+ ? '<span class="tree-existing-badge" title="' + t('existing_record_title') + '">' +
2009
+ t('existing_record_badge') + '</span>'
2010
+ : ''
2011
+
2012
+ var html =
2013
+ '<div class="tree-item' + (isExisting ? ' tree-item-existing' : '') + '" data-item-id="' +
2014
+ safeId +
2015
+ '"' + treeItemAttrs + ' style="padding-left: ' +
2016
+ paddingLeft +
2017
+ 'px">' +
2018
+ chevron +
2019
+ '<span class="fa ' +
2020
+ icon +
2021
+ ' ' +
2022
+ iconColor +
2023
+ '"></span>' +
2024
+ '<span class="tree-label' + (isExisting ? ' tree-label-existing' : '') + '">' +
2025
+ safeTitle +
2026
+ '</span>' +
2027
+ (item.parentIds && item.parentIds.length > 1
2028
+ ? '<span class="tree-shared-badge" title="' +
2029
+ t('appears_in_collections', { count: item.parentIds.length }) + '">' +
2030
+ '<span class="fa fa-link"></span> ' + t('shared_badge') + '</span>'
2031
+ : '') +
2032
+ count +
2033
+ existingBadge +
2034
+ '</div>'
2035
+
2036
+ if (hasChildren) {
2037
+ html +=
2038
+ '<div class="tree-children" style="display: none;">' +
2039
+ children
2040
+ .map(function (c) {
2041
+ return renderTreeItem(c, hierarchyMap, depth + 1, new Set(visited))
2042
+ })
2043
+ .join('') +
2044
+ '</div>'
2045
+ }
2046
+
2047
+ return html
2048
+ }
2049
+
2050
+ // Note: Tree toggle events are handled via event delegation in bindDelegatedEvents()
2051
+
2052
+ // ============================================================================
2053
+ // SETTINGS & NAVIGATION
2054
+ // ============================================================================
2055
+
2056
+ // Initialize visibility cards
2057
+ function initVisibilityCards() {
2058
+ $('.visibility-card').on('click', function () {
2059
+ var visibility = $(this).data('visibility')
2060
+ $('.visibility-card').removeClass('active')
2061
+ $(this).addClass('active')
2062
+ $(this).find('input[type="radio"]').prop('checked', true)
2063
+ StepperState.settings.visibility = visibility
2064
+ })
2065
+
2066
+ // Set default
2067
+ $('.visibility-card[data-visibility="open"]').addClass('active')
2068
+ }
2069
+
2070
+ // Set default import name
2071
+ function setDefaultImportName() {
2072
+ var today = new Date()
2073
+ var dateStr =
2074
+ today.getMonth() + 1 + '/' + today.getDate() + '/' + today.getFullYear()
2075
+ var defaultName = t('import_name_prefix') + dateStr
2076
+ $('importer_name').val(defaultName)
2077
+ StepperState.settings.name = defaultName
2078
+ }
2079
+
2080
+ // Initialize admin set state with pre-selected value
2081
+ function initAdminSetState() {
2082
+ var $adminSetSelect = $('#importer-admin-set')
2083
+ if ($adminSetSelect.length) {
2084
+ var currentVal = $adminSetSelect.val()
2085
+ if (currentVal && currentVal.trim() !== '') {
2086
+ StepperState.adminSetId = currentVal.trim()
2087
+ StepperState.adminSetName = $adminSetSelect.find('option:selected').text().trim()
2088
+ }
2089
+ }
2090
+ }
2091
+
2092
+ function updateDownloadTemplateLink() {
2093
+ var $link = $('#download-csv-template-link')
2094
+ if (!$link.length) return
2095
+ var baseUrl = $link.data('sample-csv-url') || $link.attr('href')
2096
+ var adminSetId = $('#importer-admin-set').val()
2097
+ var href = baseUrl
2098
+ if (adminSetId && adminSetId.trim() !== '') {
2099
+ var sep = baseUrl.indexOf('?') >= 0 ? '&' : '?'
2100
+ href = baseUrl + sep + 'admin_set_id=' + encodeURIComponent(adminSetId.trim())
2101
+ }
2102
+ $link.attr('href', href)
2103
+ }
2104
+
2105
+ // Navigate to step
2106
+ function goToStep(stepNum) {
2107
+ StepperState.currentStep = stepNum
2108
+ updateStepperUI()
2109
+
2110
+ // Scroll to top, then move focus to the new step's heading
2111
+ $('html, body').animate({ scrollTop: 0 }, CONSTANTS.SCROLL_SPEED, function () {
2112
+ var $stepHeading = $('.step-content[data-step="' + stepNum + '"] .step-title h2')
2113
+ if ($stepHeading.length) {
2114
+ $stepHeading.first().focus()
2115
+ }
2116
+ })
2117
+
2118
+ // Update review summary if going to step 3
2119
+ if (stepNum === 3) {
2120
+ updateReviewSummary()
2121
+ }
2122
+ }
2123
+
2124
+ // Update stepper UI based on current step
2125
+ function updateStepperUI() {
2126
+ var step = StepperState.currentStep
2127
+
2128
+ // Update step header
2129
+ $('.step-item').each(function () {
2130
+ var itemStep = parseInt($(this).data('step'))
2131
+ $(this).removeClass('active completed')
2132
+
2133
+ if (itemStep === step) {
2134
+ $(this).addClass('active')
2135
+ } else if (itemStep < step) {
2136
+ $(this).addClass('completed')
2137
+ }
2138
+ })
2139
+
2140
+ // Update step connectors
2141
+ $('.step-connector').each(function (index) {
2142
+ if (index < step - 1) {
2143
+ $(this).addClass('completed')
2144
+ } else {
2145
+ $(this).removeClass('completed')
2146
+ }
2147
+ })
2148
+
2149
+ // Show/hide step content
2150
+ $('.step-content').hide()
2151
+ $('.step-content[data-step="' + step + '"]').show()
2152
+
2153
+ // Update navigation buttons
2154
+ updateStepNavigation()
2155
+ }
2156
+
2157
+ // Cached DOM selectors for updateStepNavigation
2158
+ var cachedSelectors = {
2159
+ nameInput: null,
2160
+ adminSetSelect: null,
2161
+ initialized: false
2162
+ }
2163
+
2164
+ function initCachedSelectors() {
2165
+ if (!cachedSelectors.initialized) {
2166
+ cachedSelectors.nameInput = $('input[name$="[name]"][name*="importer"]').first()
2167
+ cachedSelectors.adminSetSelect = $('#importer-admin-set')
2168
+ cachedSelectors.initialized = true
2169
+ }
2170
+ }
2171
+
2172
+ function isDefaultRightsStatementRequired() {
2173
+ if (StepperState.skipValidation || !StepperState.validationData) return false
2174
+ var missing = StepperState.validationData.missingRequired
2175
+ if (!missing || !Array.isArray(missing)) return false
2176
+ return missing.some(function (item) { return item && item.field === 'rights_statement' })
2177
+ }
2178
+
2179
+ function didSkipValidation() {
2180
+ return StepperState.skipValidation === true
2181
+ }
2182
+
2183
+ function updateStep2RightsStatementUI() {
2184
+ var required = isDefaultRightsStatementRequired()
2185
+ var skipped = didSkipValidation()
2186
+ var $alert = $('#default-rights-required-alert')
2187
+ var $hint = $('#default-rights-skipped-hint')
2188
+ var $label = $('.default-rights-statement-label')
2189
+ var $optionalSettings = $('#optional-settings')
2190
+
2191
+ if ($alert.length) {
2192
+ $alert.toggle(required)
2193
+ }
2194
+ if ($hint.length) {
2195
+ $hint.toggle(skipped && !required)
2196
+ }
2197
+ if ($label.length) {
2198
+ var $asterisk = $label.find('.text-danger.default-rights-required-asterisk')
2199
+ if (required && !$asterisk.length) {
2200
+ $label.append(' <span class="text-danger default-rights-required-asterisk">*</span>')
2201
+ } else if (!required && $asterisk.length) {
2202
+ $asterisk.remove()
2203
+ }
2204
+ }
2205
+ if ((required || skipped) && $optionalSettings.length && !$optionalSettings.hasClass('show')) {
2206
+ $optionalSettings.addClass('show')
2207
+ }
2208
+ }
2209
+
2210
+ // Update step navigation button states
2211
+ function updateStepNavigation() {
2212
+ var step = StepperState.currentStep
2213
+
2214
+ if (step === 1) {
2215
+ var data = StepperState.validationData
2216
+ var isValid = data && data.isValid
2217
+ var hasWarnings = data && data.hasWarnings
2218
+ var canProceed = StepperState.skipValidation ||
2219
+ (StepperState.validated &&
2220
+ (isValid || StepperState.warningsAcked) &&
2221
+ (!hasWarnings || StepperState.warningsAcked))
2222
+
2223
+ $('.step-content[data-step="1"] .step-next-btn').prop('disabled', !canProceed)
2224
+ } else if (step === 2) {
2225
+ initCachedSelectors()
2226
+ var name = (cachedSelectors.nameInput.length ? cachedSelectors.nameInput.val() : '').trim()
2227
+ var adminSetId = (cachedSelectors.adminSetSelect.length ? cachedSelectors.adminSetSelect.val() : '').trim()
2228
+ var rightsRequired = isDefaultRightsStatementRequired()
2229
+ var rightsValue = $('select[name="importer[parser_fields][rights_statement]"]').val()
2230
+ var hasRights = rightsValue && rightsValue.length > 0
2231
+
2232
+ canProceed = name.length > 0 && adminSetId.length > 0 && (!rightsRequired || hasRights)
2233
+ StepperState.settings.name = name || StepperState.settings.name
2234
+ StepperState.adminSetId = adminSetId || StepperState.adminSetId
2235
+ $('.step-content[data-step="2"] .step-next-btn').prop('disabled', !canProceed)
2236
+ updateStep2RightsStatementUI()
2237
+ }
2238
+ }
2239
+
2240
+ // Update review summary
2241
+ function updateReviewSummary() {
2242
+ var data = StepperState.validationData
2243
+ var settings = StepperState.settings
2244
+
2245
+ // Files
2246
+ var filesHtml = StepperState.uploadedFiles
2247
+ .map(function (f) {
2248
+ var type = f.fileType === 'csv' ? 'CSV' : 'ZIP'
2249
+ var fromZip = f.fromZip ? ' — ' + t('detected_in_zip') : ''
2250
+ return (
2251
+ '<p><span class="text-muted small">' + type + ':</span> ' + escapeHtml(f.name) + ' (' + escapeHtml(f.size) + ')' + fromZip + '</p>'
2252
+ )
2253
+ })
2254
+ .join('')
2255
+ $('.review-files').html(filesHtml)
2256
+
2257
+ // Records
2258
+ var totalItems = 0
2259
+ var recordsHtml
2260
+ if (data) {
2261
+ totalItems = data.collections.length + data.works.length + data.fileSets.length
2262
+ recordsHtml =
2263
+ '<p>' +
2264
+ t('review_total', {
2265
+ total: totalItems,
2266
+ collections: data.collections.length,
2267
+ works: data.works.length,
2268
+ file_sets: data.fileSets.length
2269
+ }) +
2270
+ '</p>'
2271
+ } else {
2272
+ recordsHtml = '<p class="text-muted">' + t('review_skipped') + '</p>'
2273
+ }
2274
+ $('.review-records').html(recordsHtml)
2275
+
2276
+ // Settings - get admin set name from DOM first, then fallback to state
2277
+ var $currentAdminSet = $('#importer-admin-set')
2278
+ var adminSetName = t('not_selected')
2279
+ if ($currentAdminSet.length) {
2280
+ var selectedText = $currentAdminSet.find('option:selected').text().trim()
2281
+ var selectedValue = $currentAdminSet.val()
2282
+ if (selectedValue && selectedValue !== '' && selectedText !== t('admin_set_prompt')) {
2283
+ adminSetName = selectedText
2284
+ }
2285
+ }
2286
+ if (adminSetName === t('not_selected') && StepperState.adminSetName) {
2287
+ adminSetName = StepperState.adminSetName
2288
+ }
2289
+ var visibilityLabels = {
2290
+ open: t('visibility_public'),
2291
+ authenticated: t('visibility_institution'),
2292
+ restricted: t('visibility_private')
2293
+ }
2294
+ var visibilityName = visibilityLabels[settings.visibility]
2295
+
2296
+ var settingsHtml =
2297
+ '<p><span class="text-muted small">' + t('review_name') + '</span> ' +
2298
+ escapeHtml(settings.name) +
2299
+ '</p>' +
2300
+ '<p><span class="text-muted small">' + t('review_admin_set') + '</span> ' +
2301
+ adminSetName +
2302
+ '</p>' +
2303
+ '<p><span class="text-muted small">' + t('review_visibility') + '</span> ' +
2304
+ visibilityName +
2305
+ '</p>'
2306
+
2307
+ if (settings.rightsStatement) {
2308
+ settingsHtml += '<p><span class="text-muted small">' + t('review_rights') + '</span> ' + settings.rightsStatement + '</p>'
2309
+ }
2310
+ if (settings.limit) {
2311
+ settingsHtml += '<p><span class="text-muted small">' + t('review_limit') + '</span> ' + t('review_first_n_records', { count: settings.limit }) + '</p>'
2312
+ }
2313
+
2314
+ $('.review-settings').html(settingsHtml)
2315
+
2316
+ // Warnings — derive from messages.issues so all warning types are covered
2317
+ var warningIssues = (data && data.messages && data.messages.issues)
2318
+ ? data.messages.issues.filter(function (issue) { return issue.severity === 'warning' })
2319
+ : []
2320
+ if (warningIssues.length > 0) {
2321
+ var warningsHtml = ''
2322
+ warningIssues.forEach(function (issue) {
2323
+ var label = issue.title
2324
+ if (issue.count) { label += ' (' + issue.count + ')' }
2325
+ var detail = issue.summary || issue.description || ''
2326
+ warningsHtml += '<p>' + label
2327
+ if (detail) { warningsHtml += ' — ' + detail }
2328
+ warningsHtml += '</p>'
2329
+ })
2330
+ $('.review-warnings-list').html(warningsHtml)
2331
+ $('.review-warnings').show()
2332
+ } else {
2333
+ $('.review-warnings-list').empty()
2334
+ $('.review-warnings').hide()
2335
+ }
2336
+
2337
+ // Large import warning
2338
+ $('.total-items-count').text(totalItems)
2339
+ if (data && totalItems > CONSTANTS.IMPORT_SIZE_MODERATE) {
2340
+ $('.large-import-warning').show()
2341
+ } else {
2342
+ $('.large-import-warning').hide()
2343
+ }
2344
+ }
2345
+
2346
+ // ============================================================================
2347
+ // FORM SUBMISSION & SUCCESS STATE
2348
+ // ============================================================================
2349
+
2350
+ // Handle import submission
2351
+ function handleImportSubmit() {
2352
+ var $btn = $('#start-import-btn')
2353
+ var $form = $('#bulk-import-stepper-form')
2354
+ $btn
2355
+ .prop('disabled', true)
2356
+ .html('<span class="fa fa-spinner fa-spin"></span> ' + t('starting'))
2357
+
2358
+ // Disable the file input so raw files aren't sent with the form
2359
+ $('#file-input').prop('disabled', true)
2360
+
2361
+ // Only append uploaded file IDs in upload mode; in file_path mode the import_file_path
2362
+ // param is used and appending IDs would cause GuidedImportsController#create to ignore the path.
2363
+ if (StepperState.uploadMode === 'upload' && Array.isArray(StepperState.uploadedFiles)) {
2364
+ StepperState.uploadedFiles.forEach(function (f) {
2365
+ if (f.uploadId) {
2366
+ var $input = $('<input>', { type: 'hidden', name: 'uploaded_files[]' }).val(f.uploadId)
2367
+ $form.append($input)
2368
+ }
2369
+ })
2370
+ }
2371
+
2372
+ // Submit the form so the request hits GuidedImportsController#create and creates the importer / enqueues job
2373
+ $form[0].submit()
2374
+ }
2375
+
2376
+ // Look up mock validation data from cached demo scenarios JSON
2377
+ function getMockValidationData() {
2378
+ var scenario = StepperState.demoScenario || 'warning_combined'
2379
+ var data = StepperState.demoScenariosData
2380
+ if (!data || !data.scenarios || !data.scenarios[scenario]) return null
2381
+ return data.scenarios[scenario].response
2382
+ }
2383
+
2384
+ // ============================================================================
2385
+ // NOTIFICATION FUNCTIONS
2386
+ // ============================================================================
2387
+
2388
+ function showNotification(message, type) {
2389
+ type = type || 'info' // 'error', 'warning', 'info'
2390
+
2391
+ var icons = {
2392
+ error: 'fa-times-circle',
2393
+ warning: 'fa-exclamation-triangle',
2394
+ info: 'fa-info-circle'
2395
+ }
2396
+
2397
+ // Escape the message to prevent XSS
2398
+ var safeMessage = escapeHtml(message)
2399
+
2400
+ var $notification = $(
2401
+ '<div class="upload-notification notification-' + type + '">' +
2402
+ '<span class="fa ' + icons[type] + ' upload-notification-icon"></span>' +
2403
+ '<div class="upload-notification-content">' + safeMessage + '</div>' +
2404
+ '<span class="fa fa-times upload-notification-close"></span>' +
2405
+ '</div>'
2406
+ )
2407
+
2408
+ $('#upload-notifications').append($notification)
2409
+
2410
+ // Click to dismiss
2411
+ $notification.find('.upload-notification-close').on('click', function () {
2412
+ $notification.fadeOut(CONSTANTS.NOTIFICATION_FADE_SPEED, function () {
2413
+ $(this).remove()
2414
+ })
2415
+ })
2416
+ }
2417
+
2418
+ // Initialize on document ready and turbolinks load
2419
+ $(document).on('turbolinks:load', initBulkImportStepper)
2420
+ })(jQuery, window.BulkraxUtils || {})