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.
- checksums.yaml +4 -4
- data/README.md +11 -1
- data/app/assets/javascripts/bulkrax/application.js +2 -1
- data/app/assets/javascripts/bulkrax/bulkrax.js +13 -4
- data/app/assets/javascripts/bulkrax/bulkrax_utils.js +96 -0
- data/app/assets/javascripts/bulkrax/datatables.js +1 -0
- data/app/assets/javascripts/bulkrax/entries.js +17 -10
- data/app/assets/javascripts/bulkrax/importers.js.erb +9 -2
- data/app/assets/javascripts/bulkrax/importers_stepper.js +2420 -0
- data/app/assets/stylesheets/bulkrax/application.css +1 -1
- data/app/assets/stylesheets/bulkrax/import_export.scss +9 -2
- data/app/assets/stylesheets/bulkrax/stepper/_header.scss +83 -0
- data/app/assets/stylesheets/bulkrax/stepper/_mixins.scss +26 -0
- data/app/assets/stylesheets/bulkrax/stepper/_navigation.scss +103 -0
- data/app/assets/stylesheets/bulkrax/stepper/_responsive.scss +46 -0
- data/app/assets/stylesheets/bulkrax/stepper/_review.scss +92 -0
- data/app/assets/stylesheets/bulkrax/stepper/_settings.scss +106 -0
- data/app/assets/stylesheets/bulkrax/stepper/_success.scss +26 -0
- data/app/assets/stylesheets/bulkrax/stepper/_summary.scss +171 -0
- data/app/assets/stylesheets/bulkrax/stepper/_upload.scss +339 -0
- data/app/assets/stylesheets/bulkrax/stepper/_validation.scss +237 -0
- data/app/assets/stylesheets/bulkrax/stepper/_variables.scss +46 -0
- data/app/assets/stylesheets/bulkrax/stepper.scss +32 -0
- data/app/controllers/bulkrax/guided_imports_controller.rb +175 -0
- data/app/controllers/bulkrax/importers_controller.rb +34 -28
- data/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb +201 -0
- data/app/controllers/concerns/bulkrax/importer_file_handler.rb +217 -0
- data/app/factories/bulkrax/object_factory.rb +3 -2
- data/app/factories/bulkrax/valkyrie_object_factory.rb +61 -17
- data/app/jobs/bulkrax/export_work_job.rb +1 -3
- data/app/jobs/bulkrax/importer_job.rb +11 -4
- data/app/models/bulkrax/csv_entry.rb +27 -7
- data/app/models/bulkrax/entry.rb +4 -0
- data/app/models/bulkrax/importer.rb +31 -1
- data/app/models/concerns/bulkrax/has_matchers.rb +2 -2
- data/app/models/concerns/bulkrax/importer_exporter_behavior.rb +6 -5
- data/app/parsers/bulkrax/application_parser.rb +31 -5
- data/app/parsers/bulkrax/csv_parser.rb +42 -10
- data/app/parsers/concerns/bulkrax/csv_parser/csv_template_generation.rb +73 -0
- data/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb +133 -0
- data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb +282 -0
- data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_hierarchy.rb +96 -0
- data/app/services/bulkrax/csv_template/column_builder.rb +60 -0
- data/app/services/bulkrax/csv_template/column_descriptor.rb +58 -0
- data/app/services/bulkrax/csv_template/csv_builder.rb +83 -0
- data/app/services/bulkrax/csv_template/explanation_builder.rb +57 -0
- data/app/services/bulkrax/csv_template/field_analyzer.rb +56 -0
- data/app/services/bulkrax/csv_template/file_path_generator.rb +47 -0
- data/app/services/bulkrax/csv_template/file_validator.rb +68 -0
- data/app/services/bulkrax/csv_template/mapping_manager.rb +55 -0
- data/app/services/bulkrax/csv_template/model_loader.rb +50 -0
- data/app/services/bulkrax/csv_template/row_builder.rb +35 -0
- data/app/services/bulkrax/csv_template/schema_analyzer.rb +70 -0
- data/app/services/bulkrax/csv_template/split_formatter.rb +44 -0
- data/app/services/bulkrax/csv_template/value_determiner.rb +68 -0
- data/app/services/bulkrax/stepper_response_formatter.rb +347 -0
- data/app/services/bulkrax/validation_error_csv_builder.rb +99 -0
- data/app/validators/bulkrax/csv_row/child_reference.rb +56 -0
- data/app/validators/bulkrax/csv_row/circular_reference.rb +71 -0
- data/app/validators/bulkrax/csv_row/controlled_vocabulary.rb +74 -0
- data/app/validators/bulkrax/csv_row/duplicate_identifier.rb +63 -0
- data/app/validators/bulkrax/csv_row/missing_source_identifier.rb +31 -0
- data/app/validators/bulkrax/csv_row/parent_reference.rb +59 -0
- data/app/validators/bulkrax/csv_row/required_values.rb +64 -0
- data/app/views/bulkrax/entries/_parsed_metadata.html.erb +1 -1
- data/app/views/bulkrax/entries/_raw_metadata.html.erb +1 -1
- data/app/views/bulkrax/entries/show.html.erb +6 -6
- data/app/views/bulkrax/exporters/_form.html.erb +19 -43
- data/app/views/bulkrax/exporters/edit.html.erb +2 -2
- data/app/views/bulkrax/exporters/index.html.erb +5 -5
- data/app/views/bulkrax/exporters/new.html.erb +3 -5
- data/app/views/bulkrax/exporters/show.html.erb +3 -3
- data/app/views/bulkrax/guided_imports/new.html.erb +567 -0
- data/app/views/bulkrax/importers/_bagit_fields.html.erb +9 -9
- data/app/views/bulkrax/importers/_browse_everything.html.erb +1 -1
- data/app/views/bulkrax/importers/_csv_fields.html.erb +11 -11
- data/app/views/bulkrax/importers/_edit_form_buttons.html.erb +23 -23
- data/app/views/bulkrax/importers/_edit_item_buttons.html.erb +2 -2
- data/app/views/bulkrax/importers/_file_uploader.html.erb +3 -3
- data/app/views/bulkrax/importers/_form.html.erb +4 -5
- data/app/views/bulkrax/importers/_oai_fields.html.erb +8 -18
- data/app/views/bulkrax/importers/_xml_fields.html.erb +13 -13
- data/app/views/bulkrax/importers/edit.html.erb +2 -2
- data/app/views/bulkrax/importers/index.html.erb +19 -14
- data/app/views/bulkrax/importers/new.html.erb +10 -9
- data/app/views/bulkrax/importers/show.html.erb +23 -7
- data/app/views/bulkrax/importers/upload_corrected_entries.html.erb +6 -6
- data/app/views/bulkrax/shared/_bulkrax_errors.html.erb +11 -11
- data/app/views/bulkrax/shared/_bulkrax_field_mapping.html.erb +3 -3
- data/config/i18n-tasks.yml +195 -0
- data/config/locales/bulkrax.de.yml +504 -0
- data/config/locales/bulkrax.en.yml +487 -28
- data/config/locales/bulkrax.es.yml +504 -0
- data/config/locales/bulkrax.fr.yml +504 -0
- data/config/locales/bulkrax.it.yml +504 -0
- data/config/locales/bulkrax.pt-BR.yml +504 -0
- data/config/locales/bulkrax.zh.yml +503 -0
- data/config/routes.rb +10 -0
- data/lib/bulkrax/data/demo_scenarios.json +2235 -0
- data/lib/bulkrax/version.rb +1 -1
- data/lib/bulkrax.rb +31 -3
- data/lib/tasks/bulkrax_tasks.rake +0 -102
- metadata +55 -3
- /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">×</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 || {})
|