bulkrax 9.3.4 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -1
  3. data/app/assets/javascripts/bulkrax/application.js +2 -1
  4. data/app/assets/javascripts/bulkrax/bulkrax.js +13 -4
  5. data/app/assets/javascripts/bulkrax/bulkrax_utils.js +96 -0
  6. data/app/assets/javascripts/bulkrax/datatables.js +1 -0
  7. data/app/assets/javascripts/bulkrax/entries.js +17 -10
  8. data/app/assets/javascripts/bulkrax/importers.js.erb +9 -2
  9. data/app/assets/javascripts/bulkrax/importers_stepper.js +2420 -0
  10. data/app/assets/stylesheets/bulkrax/application.css +1 -1
  11. data/app/assets/stylesheets/bulkrax/import_export.scss +9 -2
  12. data/app/assets/stylesheets/bulkrax/stepper/_header.scss +83 -0
  13. data/app/assets/stylesheets/bulkrax/stepper/_mixins.scss +26 -0
  14. data/app/assets/stylesheets/bulkrax/stepper/_navigation.scss +103 -0
  15. data/app/assets/stylesheets/bulkrax/stepper/_responsive.scss +46 -0
  16. data/app/assets/stylesheets/bulkrax/stepper/_review.scss +92 -0
  17. data/app/assets/stylesheets/bulkrax/stepper/_settings.scss +106 -0
  18. data/app/assets/stylesheets/bulkrax/stepper/_success.scss +26 -0
  19. data/app/assets/stylesheets/bulkrax/stepper/_summary.scss +171 -0
  20. data/app/assets/stylesheets/bulkrax/stepper/_upload.scss +339 -0
  21. data/app/assets/stylesheets/bulkrax/stepper/_validation.scss +237 -0
  22. data/app/assets/stylesheets/bulkrax/stepper/_variables.scss +46 -0
  23. data/app/assets/stylesheets/bulkrax/stepper.scss +32 -0
  24. data/app/controllers/bulkrax/guided_imports_controller.rb +175 -0
  25. data/app/controllers/bulkrax/importers_controller.rb +34 -28
  26. data/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb +201 -0
  27. data/app/controllers/concerns/bulkrax/importer_file_handler.rb +217 -0
  28. data/app/factories/bulkrax/object_factory.rb +3 -2
  29. data/app/factories/bulkrax/valkyrie_object_factory.rb +61 -17
  30. data/app/jobs/bulkrax/export_work_job.rb +1 -3
  31. data/app/jobs/bulkrax/importer_job.rb +11 -4
  32. data/app/models/bulkrax/csv_entry.rb +27 -7
  33. data/app/models/bulkrax/entry.rb +4 -0
  34. data/app/models/bulkrax/importer.rb +31 -1
  35. data/app/models/concerns/bulkrax/has_matchers.rb +2 -2
  36. data/app/models/concerns/bulkrax/importer_exporter_behavior.rb +6 -5
  37. data/app/parsers/bulkrax/application_parser.rb +31 -5
  38. data/app/parsers/bulkrax/csv_parser.rb +42 -10
  39. data/app/parsers/concerns/bulkrax/csv_parser/csv_template_generation.rb +73 -0
  40. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb +133 -0
  41. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb +282 -0
  42. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_hierarchy.rb +96 -0
  43. data/app/services/bulkrax/csv_template/column_builder.rb +60 -0
  44. data/app/services/bulkrax/csv_template/column_descriptor.rb +58 -0
  45. data/app/services/bulkrax/csv_template/csv_builder.rb +83 -0
  46. data/app/services/bulkrax/csv_template/explanation_builder.rb +57 -0
  47. data/app/services/bulkrax/csv_template/field_analyzer.rb +56 -0
  48. data/app/services/bulkrax/csv_template/file_path_generator.rb +47 -0
  49. data/app/services/bulkrax/csv_template/file_validator.rb +68 -0
  50. data/app/services/bulkrax/csv_template/mapping_manager.rb +55 -0
  51. data/app/services/bulkrax/csv_template/model_loader.rb +50 -0
  52. data/app/services/bulkrax/csv_template/row_builder.rb +35 -0
  53. data/app/services/bulkrax/csv_template/schema_analyzer.rb +70 -0
  54. data/app/services/bulkrax/csv_template/split_formatter.rb +44 -0
  55. data/app/services/bulkrax/csv_template/value_determiner.rb +68 -0
  56. data/app/services/bulkrax/stepper_response_formatter.rb +347 -0
  57. data/app/services/bulkrax/validation_error_csv_builder.rb +99 -0
  58. data/app/validators/bulkrax/csv_row/child_reference.rb +56 -0
  59. data/app/validators/bulkrax/csv_row/circular_reference.rb +71 -0
  60. data/app/validators/bulkrax/csv_row/controlled_vocabulary.rb +74 -0
  61. data/app/validators/bulkrax/csv_row/duplicate_identifier.rb +63 -0
  62. data/app/validators/bulkrax/csv_row/missing_source_identifier.rb +31 -0
  63. data/app/validators/bulkrax/csv_row/parent_reference.rb +59 -0
  64. data/app/validators/bulkrax/csv_row/required_values.rb +64 -0
  65. data/app/views/bulkrax/entries/_parsed_metadata.html.erb +1 -1
  66. data/app/views/bulkrax/entries/_raw_metadata.html.erb +1 -1
  67. data/app/views/bulkrax/entries/show.html.erb +6 -6
  68. data/app/views/bulkrax/exporters/_form.html.erb +19 -43
  69. data/app/views/bulkrax/exporters/edit.html.erb +2 -2
  70. data/app/views/bulkrax/exporters/index.html.erb +5 -5
  71. data/app/views/bulkrax/exporters/new.html.erb +3 -5
  72. data/app/views/bulkrax/exporters/show.html.erb +3 -3
  73. data/app/views/bulkrax/guided_imports/new.html.erb +567 -0
  74. data/app/views/bulkrax/importers/_bagit_fields.html.erb +9 -9
  75. data/app/views/bulkrax/importers/_browse_everything.html.erb +1 -1
  76. data/app/views/bulkrax/importers/_csv_fields.html.erb +11 -11
  77. data/app/views/bulkrax/importers/_edit_form_buttons.html.erb +23 -23
  78. data/app/views/bulkrax/importers/_edit_item_buttons.html.erb +2 -2
  79. data/app/views/bulkrax/importers/_file_uploader.html.erb +3 -3
  80. data/app/views/bulkrax/importers/_form.html.erb +4 -5
  81. data/app/views/bulkrax/importers/_oai_fields.html.erb +8 -18
  82. data/app/views/bulkrax/importers/_xml_fields.html.erb +13 -13
  83. data/app/views/bulkrax/importers/edit.html.erb +2 -2
  84. data/app/views/bulkrax/importers/index.html.erb +19 -14
  85. data/app/views/bulkrax/importers/new.html.erb +10 -9
  86. data/app/views/bulkrax/importers/show.html.erb +23 -7
  87. data/app/views/bulkrax/importers/upload_corrected_entries.html.erb +6 -6
  88. data/app/views/bulkrax/shared/_bulkrax_errors.html.erb +11 -11
  89. data/app/views/bulkrax/shared/_bulkrax_field_mapping.html.erb +3 -3
  90. data/config/i18n-tasks.yml +195 -0
  91. data/config/locales/bulkrax.de.yml +504 -0
  92. data/config/locales/bulkrax.en.yml +487 -28
  93. data/config/locales/bulkrax.es.yml +504 -0
  94. data/config/locales/bulkrax.fr.yml +504 -0
  95. data/config/locales/bulkrax.it.yml +504 -0
  96. data/config/locales/bulkrax.pt-BR.yml +504 -0
  97. data/config/locales/bulkrax.zh.yml +503 -0
  98. data/config/routes.rb +10 -0
  99. data/lib/bulkrax/data/demo_scenarios.json +2235 -0
  100. data/lib/bulkrax/version.rb +1 -1
  101. data/lib/bulkrax.rb +31 -3
  102. data/lib/tasks/bulkrax_tasks.rake +0 -102
  103. metadata +55 -3
  104. /data/{app/services → lib}/wings/custom_queries/find_by_source_identifier.rb +0 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulkrax
4
+ class GuidedImportsController < ::Bulkrax::ApplicationController
5
+ include Hyrax::ThemedLayoutController if defined?(::Hyrax)
6
+ include Bulkrax::GuidedImportDemoScenarios if Bulkrax.config.guided_import_demo_scenarios_enabled
7
+ include Bulkrax::ImporterFileHandler
8
+ helper Bulkrax::ImportersHelper
9
+
10
+ before_action :authenticate_user!
11
+ before_action :check_permissions
12
+ with_themed_layout 'dashboard' if defined?(::Hyrax)
13
+
14
+ # trigger form to allow upload
15
+ def new
16
+ @importer = Importer.new
17
+ return unless defined?(::Hyrax)
18
+ add_importer_breadcrumbs
19
+ add_breadcrumb I18n.t('bulkrax.importer.guided_import.breadcrumb')
20
+ end
21
+
22
+ # AJAX endpoint to validate uploaded files
23
+ def validate
24
+ set_locale_from_params
25
+
26
+ files, error = resolve_validation_files
27
+ return render json: error, status: :ok if error
28
+ return render json: StepperResponseFormatter.error(message: I18n.t('bulkrax.importer.guided_import.validation.no_files_uploaded')), status: :ok unless files.any?
29
+
30
+ csv_file, zip_file = select_csv_and_zip(files)
31
+
32
+ unless csv_file
33
+ return render json: StepperResponseFormatter.error(message: I18n.t('bulkrax.importer.guided_import.validation.no_csv_uploaded')), status: :ok unless zip_file
34
+
35
+ csv_file, error = extract_csv_from_zip(zip_file)
36
+ return render json: error, status: :ok if error
37
+ end
38
+
39
+ admin_set_id = params[:importer]&.[](:admin_set_id)
40
+ validation_result = run_validation(csv_file, zip_file, admin_set_id: admin_set_id)
41
+ raw_csv_data = validation_result.delete(:raw_csv_data)
42
+ cache_key = cache_validation_errors(validation_result, raw_csv_data, csv_file)
43
+ formatted = StepperResponseFormatter.format(validation_result)
44
+ formatted[:validationErrorsCacheKey] = cache_key
45
+ render json: formatted, status: :ok
46
+ ensure
47
+ close_file_handles(files)
48
+ end
49
+
50
+ def download_validation_errors
51
+ cache_key = params[:key].to_s
52
+ expected_prefix = "guided_import_errors:#{session.id}:"
53
+ return head :not_found unless cache_key.start_with?(expected_prefix)
54
+
55
+ cached = Rails.cache.read(cache_key)
56
+ return head :not_found unless cached
57
+
58
+ csv = ValidationErrorCsvBuilder.build(
59
+ headers: cached[:headers],
60
+ csv_data: cached[:csv_data],
61
+ row_errors: cached[:row_errors],
62
+ file_errors: cached[:file_errors]
63
+ )
64
+ send_data csv, filename: error_csv_filename(cached[:original_filename]), type: 'text/csv', disposition: 'attachment'
65
+ end
66
+
67
+ def create
68
+ files = nil
69
+ files = resolve_create_files
70
+ return render_invalid_uploaded_files_response if params[:uploaded_files].present? && files.empty?
71
+
72
+ @importer = Importer.new(importer_params)
73
+ @importer.parser_klass = 'Bulkrax::CsvParser'
74
+ @importer.user = current_user if respond_to?(:current_user) && current_user.present?
75
+ apply_field_mapping
76
+
77
+ if @importer.save
78
+ write_files(files)
79
+ Bulkrax::ImporterJob.perform_later(@importer.id)
80
+
81
+ respond_to do |format|
82
+ format.html { redirect_to bulkrax.importers_path, notice: I18n.t('bulkrax.importer.guided_import.flash.import_started') }
83
+ format.json { render json: { success: true, importer_id: @importer.id }, status: :created }
84
+ end
85
+ else
86
+ respond_to do |format|
87
+ format.html { render :new, status: :unprocessable_entity }
88
+ format.json { render json: { errors: @importer.errors.full_messages }, status: :unprocessable_entity }
89
+ end
90
+ end
91
+ ensure
92
+ close_file_handles(files)
93
+ end
94
+
95
+ private
96
+
97
+ def render_invalid_uploaded_files_response
98
+ respond_to do |format|
99
+ format.html { render :new, status: :unprocessable_entity }
100
+ format.json { render json: { errors: ['No valid uploaded files found'] }, status: :unprocessable_entity }
101
+ end
102
+ end
103
+
104
+ # Runs validation via the real service.
105
+ # @param csv_file [File, StringIO] the CSV to validate
106
+ # @param zip_file [File, nil] an optional ZIP containing file attachments
107
+ # @param admin_set_id [String, nil] optional admin set ID for validation context
108
+ # @return [Hash] validation result data
109
+ def cache_validation_errors(validation_result, raw_csv_data, csv_file)
110
+ has_errors = validation_result[:rowErrors]&.any? ||
111
+ validation_result[:missingRequired]&.any? ||
112
+ validation_result[:unrecognized]&.any? ||
113
+ validation_result[:emptyColumns]&.any? ||
114
+ validation_result[:missingFiles]&.any?
115
+ return nil unless has_errors
116
+
117
+ key = "guided_import_errors:#{session.id}:#{Time.now.to_i}"
118
+ Rails.cache.write(
119
+ key,
120
+ {
121
+ headers: validation_result[:headers],
122
+ csv_data: raw_csv_data,
123
+ row_errors: validation_result[:rowErrors] || [],
124
+ file_errors: {
125
+ missing_required: validation_result[:missingRequired] || [],
126
+ unrecognized: validation_result[:unrecognized] || {},
127
+ empty_columns: validation_result[:emptyColumns] || [],
128
+ missing_files: validation_result[:missingFiles] || []
129
+ },
130
+ original_filename: filename_for(csv_file)
131
+ },
132
+ expires_in: 1.hour
133
+ )
134
+ key
135
+ end
136
+
137
+ def run_validation(csv_file, zip_file, admin_set_id: nil)
138
+ CsvParser.validate_csv(csv_file: csv_file, zip_file: zip_file, admin_set_id: admin_set_id)
139
+ end
140
+
141
+ def importer_params
142
+ params.require(:importer).permit(
143
+ :name,
144
+ :admin_set_id,
145
+ :limit,
146
+ parser_fields: [:visibility, :rights_statement, :override_rights_statement, :import_file_path, :file_style]
147
+ )
148
+ end
149
+
150
+ def apply_field_mapping
151
+ @importer.field_mapping = Bulkrax.field_mappings['Bulkrax::CsvParser']
152
+ end
153
+
154
+ def error_csv_filename(original_filename)
155
+ return 'import_errors.csv' if original_filename.blank?
156
+
157
+ base = File.basename(original_filename, '.*')
158
+ "#{base}_errors.csv"
159
+ end
160
+
161
+ def set_locale_from_params
162
+ I18n.locale = params[:locale] if params[:locale].present? && I18n.available_locales.include?(params[:locale].to_sym)
163
+ end
164
+
165
+ def add_importer_breadcrumbs
166
+ add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
167
+ add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
168
+ add_breadcrumb 'Importers', bulkrax.importers_path
169
+ end
170
+
171
+ def check_permissions
172
+ raise CanCan::AccessDenied unless current_ability.can_import_works?
173
+ end
174
+ end
175
+ end
@@ -8,6 +8,7 @@ module Bulkrax
8
8
  include Bulkrax::API
9
9
  include Bulkrax::DatatablesBehavior
10
10
  include Bulkrax::ValidationHelper
11
+ include Bulkrax::ImporterFileHandler
11
12
 
12
13
  protect_from_forgery unless: -> { api_request? }
13
14
  before_action :token_authenticate!, if: -> { api_request? }, only: [:create, :update, :delete]
@@ -28,7 +29,8 @@ module Bulkrax
28
29
  end
29
30
 
30
31
  def importer_table
31
- @importers = Importer.order(table_order).page(table_page).per(table_per_page)
32
+ order = table_order.presence || Arel.sql('last_imported_at DESC NULLS LAST')
33
+ @importers = Importer.order(order).page(table_page).per(table_per_page)
32
34
  @importers = @importers.where(importer_table_search) if importer_table_search.present?
33
35
  respond_to do |format|
34
36
  format.json { render json: format_importers(@importers) }
@@ -65,6 +67,16 @@ module Bulkrax
65
67
  end
66
68
  end
67
69
 
70
+ # GET /importers/sample_csv_file
71
+ def sample_csv_file
72
+ admin_set_id = params[:admin_set_id].presence
73
+ sample = Bulkrax::CsvParser.generate_template(models: 'all', output: 'file', admin_set_id: admin_set_id)
74
+ send_file sample, filename: File.basename(sample), type: 'text/csv', disposition: 'attachment'
75
+ rescue StandardError => e
76
+ flash[:error] = "Unable to generate sample CSV file: #{e.message}"
77
+ redirect_back fallback_location: bulkrax.importers_path
78
+ end
79
+
68
80
  # GET /importers/1/edit
69
81
  def edit
70
82
  if api_request?
@@ -84,7 +96,7 @@ module Bulkrax
84
96
  if api_request?
85
97
  return return_json_response unless valid_create_params?
86
98
  end
87
- uploads = Hyrax::UploadedFile.find(params[:uploaded_files]) if params[:uploaded_files].present?
99
+ uploads = uploaded_files_scope
88
100
  file = file_param
89
101
  cloud_files = cloud_params
90
102
 
@@ -123,7 +135,7 @@ module Bulkrax
123
135
  if api_request?
124
136
  return return_json_response unless valid_update_params?
125
137
  end
126
- uploads = Hyrax::UploadedFile.find(params[:uploaded_files]) if params[:uploaded_files].present?
138
+ uploads = uploaded_files_scope
127
139
  file = file_param
128
140
  cloud_files = cloud_params
129
141
 
@@ -205,10 +217,26 @@ module Bulkrax
205
217
  end
206
218
 
207
219
  def original_file
208
- if @importer.original_file?
209
- send_file @importer.original_file
210
- else
220
+ file_type = params[:file_type]&.to_sym
221
+
222
+ files = @importer.original_files
223
+ if files.empty?
211
224
  redirect_to @importer, alert: 'Importer does not support file re-download or the imported file is not found on the server.'
225
+ return
226
+ end
227
+
228
+ # If file_type is specified, find that specific file
229
+ if file_type
230
+ file = files.find { |f| f[:type] == file_type }
231
+ if file
232
+ send_file file[:path], filename: file[:name], disposition: 'attachment'
233
+ else
234
+ redirect_to @importer, alert: "File type '#{file_type}' not found."
235
+ end
236
+ else
237
+ # Default behavior: send the first file (CSV) for backward compatibility
238
+ file = files.first
239
+ send_file file[:path], filename: file[:name], disposition: 'attachment'
212
240
  end
213
241
  end
214
242
 
@@ -221,28 +249,6 @@ module Bulkrax
221
249
 
222
250
  private
223
251
 
224
- def files_for_import(file, cloud_files, uploads)
225
- return if file.blank? && cloud_files.blank? && uploads.blank?
226
-
227
- @importer[:parser_fields]['import_file_path'] = @importer.parser.write_import_file(file) if file.present?
228
- if cloud_files.present?
229
- @importer[:parser_fields]['cloud_file_paths'] = cloud_files
230
- # For BagIt, there will only be one bag, so we get the file_path back and set import_file_path
231
- # For CSV, we expect only file uploads, so we won't get the file_path back
232
- # and we expect the import_file_path to be set already
233
- target = @importer.parser.retrieve_cloud_files(cloud_files, @importer)
234
- @importer[:parser_fields]['import_file_path'] = target if target.present?
235
- end
236
-
237
- if uploads.present?
238
- uploads.each do |upload|
239
- @importer[:parser_fields]['import_file_path'] = @importer.parser.write_import_file(upload.file.file)
240
- end
241
- end
242
-
243
- @importer.save
244
- end
245
-
246
252
  # Use callbacks to share common setup or constraints between actions.
247
253
  def set_importer
248
254
  @importer = Importer.find(params[:id] || params[:importer_id])
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulkrax
4
+ # rubocop:disable Metrics/ModuleLength
5
+ module GuidedImportDemoScenarios
6
+ extend ActiveSupport::Concern
7
+
8
+ # Serve demo scenario fixtures for frontend testing
9
+ def demo_scenarios
10
+ file_path = Bulkrax::Engine.root.join('lib', 'bulkrax', 'data', 'demo_scenarios.json')
11
+ if File.exist?(file_path)
12
+ render json: File.read(file_path), status: :ok
13
+ else
14
+ render json: { error: I18n.t('bulkrax.importer.guided_import.flash.demo_not_available') }, status: :not_found
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def run_validation(csv_file, zip_file, admin_set_id: nil)
21
+ if ENV['DEMO_MODE'] == 'true'
22
+ generate_validation_response(csv_file, zip_file)
23
+ else
24
+ super
25
+ end
26
+ end
27
+
28
+ # rubocop:disable Metrics/MethodLength
29
+ # Hardcoded mock response generator for demo mode
30
+ def generate_validation_response(_csv_file, zip_file)
31
+ # Generate mock collections
32
+ collections = [
33
+ { id: 'col-1', title: 'Historical Photographs Collection', type: 'collection', parentIds: [], childrenIds: ['work-shared-1'] },
34
+ { id: 'col-2', title: 'Manuscripts & Letters', type: 'collection', parentIds: [], childrenIds: [] },
35
+ { id: 'col-3', title: 'Audio Recordings', type: 'collection', parentIds: [], childrenIds: ['work-shared-2'] }
36
+ ]
37
+
38
+ # Generate mock works
39
+ works = []
40
+ 189.times do |i|
41
+ parent_ids = if i < 75
42
+ ['col-1']
43
+ elsif i < 140
44
+ ['col-2']
45
+ elsif i < 189
46
+ ['col-3']
47
+ end
48
+
49
+ works << {
50
+ id: "work-#{i + 1}",
51
+ title: "Work #{i + 1}",
52
+ type: 'work',
53
+ parentIds: parent_ids
54
+ }
55
+ end
56
+
57
+ # Multi-parent examples
58
+ works << { id: 'work-shared-1', title: 'Cross-Collection Photograph', type: 'work', parentIds: ['col-1', 'col-2'] }
59
+ works << { id: 'work-shared-2', title: 'Interdisciplinary Recording', type: 'work', parentIds: ['col-2', 'col-3'] }
60
+
61
+ # Generate mock file sets
62
+ file_sets = []
63
+ 55.times do |i|
64
+ file_sets << {
65
+ id: "fs-#{i + 1}",
66
+ title: "FileSet #{i + 1}",
67
+ type: 'file_set'
68
+ }
69
+ end
70
+
71
+ # Mock headers with one unrecognized field
72
+ headers = ['source_identifier', 'title', 'creator', 'model', 'parents', 'children', 'file', 'description', 'date_created', 'legacy_id', 'subject']
73
+ unrecognized = ['legacy_id']
74
+ missing_required = []
75
+ missing_files = ['photo_087.tiff', 'letter_scan_12.pdf', 'recording_03.wav']
76
+ zip_included = zip_file.present?
77
+
78
+ {
79
+ headers: headers,
80
+ missingRequired: missing_required,
81
+ unrecognized: unrecognized,
82
+ rowCount: 247,
83
+ isValid: true,
84
+ hasWarnings: true,
85
+ collections: collections,
86
+ works: works,
87
+ fileSets: file_sets,
88
+ totalItems: collections.length + works.length + file_sets.length,
89
+ fileReferences: 55,
90
+ missingFiles: missing_files,
91
+ foundFiles: 52,
92
+ zipIncluded: zip_included,
93
+ messages: build_validation_messages(
94
+ headers: headers, unrecognized: unrecognized, missing_required: missing_required,
95
+ missing_files: missing_files, zip_included: zip_included, row_count: 247,
96
+ is_valid: true, has_warnings: true, file_references: 55
97
+ )
98
+ }
99
+ end
100
+ # rubocop:enable Metrics/MethodLength
101
+
102
+ # Builds the structured messages hash from validation results.
103
+ # @param results [Hash] with keys: headers, unrecognized, missing_required,
104
+ # missing_files, zip_included, row_count, is_valid, has_warnings, file_references
105
+ def build_validation_messages(results)
106
+ issues = []
107
+ issues << missing_required_issue(results[:missing_required]) if results[:missing_required]&.any?
108
+ issues << unrecognized_fields_issue(results[:unrecognized]) if results[:unrecognized]&.any?
109
+ issues << file_references_issue(results) if results[:file_references]&.positive?
110
+
111
+ {
112
+ validationStatus: validation_status(results),
113
+ issues: issues.compact
114
+ }
115
+ end
116
+
117
+ def validation_status(results)
118
+ severity, icon, title = validation_status_level(results[:is_valid], results[:has_warnings])
119
+ recognized = results[:headers] - (results[:unrecognized] || [])
120
+
121
+ {
122
+ severity: severity,
123
+ icon: icon,
124
+ title: title,
125
+ summary: I18n.t('bulkrax.importer.guided_import.validation.columns_detected', columns: results[:headers].length, records: results[:row_count]),
126
+ details: results[:is_valid] ? I18n.t('bulkrax.importer.guided_import.validation.recognized_fields', fields: recognized.join(', ')) : I18n.t('bulkrax.importer.guided_import.validation.critical_errors'),
127
+ defaultOpen: true
128
+ }
129
+ end
130
+
131
+ def validation_status_level(is_valid, has_warnings)
132
+ if !is_valid
133
+ ['error', 'fa-times-circle', I18n.t('bulkrax.importer.guided_import.validation.failed')]
134
+ elsif has_warnings
135
+ ['warning', 'fa-exclamation-triangle', I18n.t('bulkrax.importer.guided_import.validation.passed_warnings')]
136
+ else
137
+ ['success', 'fa-check-circle', I18n.t('bulkrax.importer.guided_import.validation.passed')]
138
+ end
139
+ end
140
+
141
+ def missing_required_issue(missing_required)
142
+ {
143
+ type: 'missing_required_fields',
144
+ severity: 'error',
145
+ icon: 'fa-times-circle',
146
+ title: I18n.t('bulkrax.importer.guided_import.validation.missing_required_title'),
147
+ count: missing_required.length,
148
+ description: I18n.t('bulkrax.importer.guided_import.validation.missing_required_desc'),
149
+ items: missing_required.map { |field| { field: field, message: I18n.t('bulkrax.importer.guided_import.validation.missing_required_hint') } },
150
+ defaultOpen: false
151
+ }
152
+ end
153
+
154
+ def unrecognized_fields_issue(unrecognized)
155
+ {
156
+ type: 'unrecognized_fields',
157
+ severity: 'warning',
158
+ icon: 'fa-exclamation-triangle',
159
+ title: I18n.t('bulkrax.importer.guided_import.validation.unrecognized_title'),
160
+ count: unrecognized.length,
161
+ description: I18n.t('bulkrax.importer.guided_import.validation.unrecognized_desc'),
162
+ items: unrecognized.map { |field| { field: field, message: nil } },
163
+ defaultOpen: false
164
+ }
165
+ end
166
+
167
+ # rubocop:disable Metrics/MethodLength
168
+ def file_references_issue(results)
169
+ file_references = results[:file_references]
170
+ missing_files = results[:missing_files] || []
171
+ found_files = file_references - missing_files.length
172
+
173
+ if missing_files.any? && results[:zip_included]
174
+ {
175
+ type: 'file_references',
176
+ severity: 'warning',
177
+ icon: 'fa-info-circle',
178
+ title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'),
179
+ count: file_references,
180
+ summary: I18n.t('bulkrax.importer.guided_import.validation.files_found_in_zip', found: found_files, total: file_references),
181
+ description: I18n.t('bulkrax.importer.guided_import.validation.files_missing_from_zip', count: missing_files.length, files_word: 'file'.pluralize(missing_files.length)),
182
+ items: missing_files.map { |file| { field: file, message: I18n.t('bulkrax.importer.guided_import.validation.missing_from_zip') } },
183
+ defaultOpen: false
184
+ }
185
+ elsif !results[:zip_included]
186
+ {
187
+ type: 'file_references',
188
+ severity: 'warning',
189
+ icon: 'fa-exclamation-triangle',
190
+ title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'),
191
+ count: file_references,
192
+ summary: I18n.t('bulkrax.importer.guided_import.validation.files_referenced', count: file_references),
193
+ description: I18n.t('bulkrax.importer.guided_import.validation.no_zip_desc'),
194
+ items: [],
195
+ defaultOpen: false
196
+ }
197
+ end
198
+ end # rubocop:enable Metrics/MethodLength
199
+ end
200
+ # rubocop:enable Metrics/ModuleLength
201
+ end