From cda20f1505e96dc18d5457b8f07a1b8ea4fc255c Mon Sep 17 00:00:00 2001 From: LaRita Robinson Date: Fri, 24 Apr 2026 22:07:39 -0400 Subject: [PATCH] Retire FileValidator FileValidator produced aggregate file fields (`missingFiles`, `fileReferences`, `foundFiles`, `zipIncluded`) on the validate JSON which drove a dedicated `file_references` UI accordion. This is now functionally redundant, as it has been replaced by the new per-row CsvRow::FileReference that produces the same information with row attribution and path-aware comparison. With this commit, we drop FileValidator and the four aggregate fields. Missing files now appear in the `row_level_warnings` accordion as warnings. The "files referenced but no zip" case becomes a notice shown via the `notices` accordion. This also requires an update in Demo Mode. The JSON scenarios and the DEMO_MODE=true runtime mock both emit the new shape. Consolidated the duplicated spec demo_scenarios.json to the canonical copy under lib/. Obsolete locale keys are removedacross all seven supported languages. --- .../javascripts/bulkrax/importers_stepper.js | 4 - .../bulkrax/guided_imports_controller.rb | 6 +- .../bulkrax/guided_import_demo_scenarios.rb | 105 +- .../bulkrax/csv_parser/csv_validation.rb | 22 +- .../csv_parser/csv_validation_helpers.rb | 35 +- app/services/bulkrax/file_validator.rb | 68 - .../bulkrax/stepper_response_formatter.rb | 58 - .../bulkrax/validation_error_csv_builder.rb | 8 +- .../bulkrax/csv_row/file_reference.rb | 4 +- config/locales/bulkrax.de.yml | 7 - config/locales/bulkrax.en.yml | 10 +- config/locales/bulkrax.es.yml | 7 - config/locales/bulkrax.fr.yml | 7 - config/locales/bulkrax.it.yml | 7 - config/locales/bulkrax.pt-BR.yml | 7 - config/locales/bulkrax.zh.yml | 7 - docs/FILE_VALIDATION_FOLLOWUP.md | 294 --- lib/bulkrax/data/demo_scenarios.json | 144 +- .../bulkrax/guided_imports_controller_spec.rb | 21 +- spec/fixtures/demo_scenarios.json | 2235 ----------------- .../csv_parser/csv_validation_helpers_spec.rb | 12 +- .../csv_template/csv_parser_template_spec.rb | 26 +- spec/services/bulkrax/file_validator_spec.rb | 363 --- .../stepper_response_formatter_spec.rb | 99 +- .../validation_error_csv_builder_spec.rb | 36 - .../bulkrax/csv_row/file_reference_spec.rb | 2 +- 26 files changed, 224 insertions(+), 3370 deletions(-) delete mode 100644 app/services/bulkrax/file_validator.rb delete mode 100644 docs/FILE_VALIDATION_FOLLOWUP.md delete mode 100644 spec/fixtures/demo_scenarios.json delete mode 100644 spec/services/bulkrax/file_validator_spec.rb diff --git a/app/assets/javascripts/bulkrax/importers_stepper.js b/app/assets/javascripts/bulkrax/importers_stepper.js index 9efeb0bc9..86a12ecdc 100644 --- a/app/assets/javascripts/bulkrax/importers_stepper.js +++ b/app/assets/javascripts/bulkrax/importers_stepper.js @@ -1562,10 +1562,6 @@ rowCount: data.rowCount != null ? data.rowCount : data.row_count, isValid: determineIsValid(data), hasWarnings: determineHasWarnings(data), - fileReferences: data.fileReferences != null ? data.fileReferences : data.file_references, - missingFiles: data.missingFiles || data.missing_files, - foundFiles: data.foundFiles != null ? data.foundFiles : data.found_files, - zipIncluded: data.zipIncluded != null ? data.zipIncluded : data.zip_included, messages: data.messages, validationErrorsCacheKey: data.validationErrorsCacheKey || null } diff --git a/app/controllers/bulkrax/guided_imports_controller.rb b/app/controllers/bulkrax/guided_imports_controller.rb index 28eadf8c9..0053d3ae5 100644 --- a/app/controllers/bulkrax/guided_imports_controller.rb +++ b/app/controllers/bulkrax/guided_imports_controller.rb @@ -112,8 +112,7 @@ def cache_validation_errors(validation_result, raw_csv_data, csv_file) has_errors = validation_result[:rowErrors]&.any? || validation_result[:missingRequired]&.any? || validation_result[:unrecognized]&.any? || - validation_result[:emptyColumns]&.any? || - validation_result[:missingFiles]&.any? + validation_result[:emptyColumns]&.any? return nil unless has_errors key = "guided_import_errors:#{session.id}:#{Time.now.to_i}" @@ -126,8 +125,7 @@ def cache_validation_errors(validation_result, raw_csv_data, csv_file) file_errors: { missing_required: validation_result[:missingRequired] || [], unrecognized: validation_result[:unrecognized] || {}, - empty_columns: validation_result[:emptyColumns] || [], - missing_files: validation_result[:missingFiles] || [] + empty_columns: validation_result[:emptyColumns] || [] }, original_filename: filename_for(csv_file) }, diff --git a/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb b/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb index cc64cd438..181401e65 100644 --- a/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb +++ b/app/controllers/concerns/bulkrax/guided_import_demo_scenarios.rb @@ -72,8 +72,33 @@ def generate_validation_response(_csv_file, zip_file) headers = ['source_identifier', 'title', 'creator', 'model', 'parents', 'children', 'file', 'description', 'date_created', 'legacy_id', 'subject'] unrecognized = ['legacy_id'] missing_required = [] - missing_files = ['photo_087.tiff', 'letter_scan_12.pdf', 'recording_03.wav'] zip_included = zip_file.present? + missing_file_paths = ['photo_087.tiff', 'letter_scan_12.pdf', 'recording_03.wav'] + row_warnings = if zip_included + missing_file_paths.each_with_index.map do |path, i| + { + row: 10 + (i * 15), + severity: 'warning', + category: 'missing_file_reference', + column: 'file', + value: path, + message: I18n.t('bulkrax.importer.guided_import.validation.file_reference_validator.errors.missing_file_reference.message', value: path), + suggestion: I18n.t('bulkrax.importer.guided_import.validation.file_reference_validator.errors.missing_file_reference.suggestion') + } + end + else + [] + end + notices = if zip_included + [] + else + [{ + field: 'file', + category: 'files_referenced_no_zip', + message: I18n.t('bulkrax.importer.guided_import.validation.files_referenced_no_zip_notice.message'), + suggestion: I18n.t('bulkrax.importer.guided_import.validation.files_referenced_no_zip_notice.suggestion') + }] + end { headers: headers, @@ -86,14 +111,12 @@ def generate_validation_response(_csv_file, zip_file) works: works, fileSets: file_sets, totalItems: collections.length + works.length + file_sets.length, - fileReferences: 55, - missingFiles: missing_files, - foundFiles: 52, - zipIncluded: zip_included, + rowErrors: row_warnings, + notices: notices, messages: build_validation_messages( headers: headers, unrecognized: unrecognized, missing_required: missing_required, - missing_files: missing_files, zip_included: zip_included, row_count: 247, - is_valid: true, has_warnings: true, file_references: 55 + row_warnings: row_warnings, notices: notices, row_count: 247, + is_valid: true, has_warnings: true ) } end @@ -101,12 +124,13 @@ def generate_validation_response(_csv_file, zip_file) # Builds the structured messages hash from validation results. # @param results [Hash] with keys: headers, unrecognized, missing_required, - # missing_files, zip_included, row_count, is_valid, has_warnings, file_references + # row_warnings, notices, row_count, is_valid, has_warnings def build_validation_messages(results) issues = [] issues << missing_required_issue(results[:missing_required]) if results[:missing_required]&.any? + issues << notices_issue(results[:notices]) if results[:notices]&.any? issues << unrecognized_fields_issue(results[:unrecognized]) if results[:unrecognized]&.any? - issues << file_references_issue(results) if results[:file_references]&.positive? + issues << row_level_warnings_issue(results[:row_warnings]) if results[:row_warnings]&.any? { validationStatus: validation_status(results), @@ -164,38 +188,37 @@ def unrecognized_fields_issue(unrecognized) } end - # rubocop:disable Metrics/MethodLength - def file_references_issue(results) - file_references = results[:file_references] - missing_files = results[:missing_files] || [] - found_files = file_references - missing_files.length - - if missing_files.any? && results[:zip_included] - { - type: 'file_references', - severity: 'warning', - icon: 'fa-info-circle', - title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'), - count: file_references, - summary: I18n.t('bulkrax.importer.guided_import.validation.files_found_in_zip', found: found_files, total: file_references), - description: I18n.t('bulkrax.importer.guided_import.validation.files_missing_from_zip', count: missing_files.length, files_word: 'file'.pluralize(missing_files.length)), - items: missing_files.map { |file| { field: file, message: I18n.t('bulkrax.importer.guided_import.validation.missing_from_zip') } }, - defaultOpen: false - } - elsif !results[:zip_included] - { - type: 'file_references', - severity: 'warning', - icon: 'fa-exclamation-triangle', - title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'), - count: file_references, - summary: I18n.t('bulkrax.importer.guided_import.validation.files_referenced', count: file_references), - description: I18n.t('bulkrax.importer.guided_import.validation.no_zip_desc'), - items: [], - defaultOpen: false - } - end - end # rubocop:enable Metrics/MethodLength + def notices_issue(notices) + { + type: 'notices', + severity: 'warning', + icon: 'fa-info-circle', + title: I18n.t('bulkrax.importer.guided_import.validation.notices_title'), + count: notices.length, + description: I18n.t('bulkrax.importer.guided_import.validation.notices_desc'), + items: notices.map { |n| { field: n[:field], message: [n[:message], n[:suggestion]].compact.join(' ') } }, + defaultOpen: false + } + end + + def row_level_warnings_issue(row_warnings) + { + type: 'row_level_warnings', + severity: 'warning', + icon: 'fa-exclamation-triangle', + title: I18n.t('bulkrax.importer.guided_import.stepper_response_formatter.row_errors_issue.title_warnings'), + count: row_warnings.length, + description: I18n.t('bulkrax.importer.guided_import.stepper_response_formatter.row_errors_issue.description'), + items: row_warnings.map do |error| + { + field: I18n.t('bulkrax.importer.guided_import.stepper_response_formatter.row_errors_issue.row_label', row: error[:row], column: error[:column]), + message: [error[:message], error[:suggestion]].compact.join(' '), + category: error[:category] + } + end, + defaultOpen: false + } + end end # rubocop:enable Metrics/ModuleLength end diff --git a/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb b/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb index f666041b8..8ab819d37 100644 --- a/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb +++ b/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb @@ -27,12 +27,12 @@ def validate_csv(csv_file:, zip_file: nil, admin_set_id: nil) all_ids = csv_data.map { |r| r[:source_identifier] }.compact.to_set header_issues = check_headers(headers, raw_csv, mapping_manager, mappings, field_metadata, field_analyzer) missing_required = header_issues[:missing_required] - notices, row_errors, file_validator, collections, works, file_sets = - run_validations(csv_data, all_ids, headers, source_id_key, mappings, field_metadata, missing_required, zip_file, admin_set_id, mapping_manager: mapping_manager) + notices, row_errors, collections, works, file_sets = + run_validations(csv_data, all_ids, headers, source_id_key, mappings, field_metadata, missing_required, zip_file, mapping_manager: mapping_manager) result = assemble_result( headers: headers, missing_required: missing_required, header_issues: header_issues, - row_errors: row_errors, csv_data: csv_data, file_validator: file_validator, + row_errors: row_errors, csv_data: csv_data, collections: collections, works: works, file_sets: file_sets, notices: notices ) result[:raw_csv_data] = csv_data @@ -68,19 +68,19 @@ def macos_junk_entry?(name) name.start_with?('__MACOSX/') || File.basename(name) == '.DS_Store' || File.basename(name).start_with?('._') end - # Builds notices, runs row validators, file validator, and hierarchy extraction. - # Returns [notices, row_errors, file_validator, collections, works, file_sets]. - def run_validations(csv_data, all_ids, headers, source_id_key, mappings, field_metadata, missing_required, zip_file, admin_set_id, mapping_manager: nil) # rubocop:disable Metrics/ParameterLists + # Builds notices, runs row validators, and hierarchy extraction. + # Returns [notices, row_errors, collections, works, file_sets]. + def run_validations(csv_data, all_ids, headers, source_id_key, mappings, field_metadata, missing_required, zip_file, mapping_manager: nil) # rubocop:disable Metrics/ParameterLists find_record = build_find_record notices = [] append_missing_source_id!(missing_required, headers, source_id_key, csv_data.map { |r| r[:model] }.compact.uniq) append_missing_model_notice!(notices, headers, csv_data) + append_files_referenced_no_zip_notice!(notices, csv_data, zip_file) - zip_plan = build_zip_plan(zip_file) - row_errors = run_row_validators(csv_data, all_ids, source_id_key, mappings, field_metadata, find_record, notices, mapping_manager: mapping_manager, zip_plan: zip_plan) - file_validator = Bulkrax::FileValidator.new(csv_data, zip_file, admin_set_id) - collections, works, file_sets = extract_hierarchy_items(csv_data, all_ids, find_record, mappings) - [notices, row_errors, file_validator, collections, works, file_sets] + zip_plan = build_zip_plan(zip_file) + row_errors = run_row_validators(csv_data, all_ids, source_id_key, mappings, field_metadata, find_record, notices, mapping_manager: mapping_manager, zip_plan: zip_plan) + collections, works, file_sets = extract_hierarchy_items(csv_data, all_ids, find_record, mappings) + [notices, row_errors, collections, works, file_sets] end # Reads the CSV, resolves mappings, parses rows, and builds field metadata. diff --git a/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb b/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb index dc8581bb0..66e87a156 100644 --- a/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb +++ b/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb @@ -140,6 +140,24 @@ def append_missing_source_id!(missing_required, headers, source_id_key, all_mode all_models.each { |model| missing_required << { model: model, field: source_id_key.to_s } } end + # Adds a notice when the CSV references files but no zip was uploaded. + # Per-row file-reference validation can't run without a zip plan, so + # this nudges the user without blocking validation. (Files may still + # exist on the server at import time; this is intentionally a notice, + # not an error.) + def append_files_referenced_no_zip_notice!(notices, csv_data, zip_file) + return if zip_file + return unless csv_data.any? { |r| r[:file].present? } + + base_key = 'bulkrax.importer.guided_import.validation.files_referenced_no_zip_notice' + notices << { + field: 'file', + category: 'files_referenced_no_zip', + message: I18n.t("#{base_key}.message"), + suggestion: I18n.t("#{base_key}.suggestion") + } + end + # Adds a file-level notice when the model column is absent or every row has a blank # model value, indicating that the default work type will be used for all rows. # When this notice is present the per-row default_work_type_used warnings are @@ -164,10 +182,10 @@ def append_missing_model_notice!(notices, headers, csv_data) end # Assembles the final result hash returned to the guided import UI. - def assemble_result(headers:, missing_required:, header_issues:, row_errors:, csv_data:, file_validator:, collections:, works:, file_sets:, notices: []) # rubocop:disable Metrics/ParameterLists + def assemble_result(headers:, missing_required:, header_issues:, row_errors:, csv_data:, collections:, works:, file_sets:, notices: []) # rubocop:disable Metrics/ParameterLists is_valid, has_warnings = determine_validity( headers: headers, missing_required: missing_required, header_issues: header_issues, - row_errors: row_errors, csv_data: csv_data, file_validator: file_validator, notices: notices + row_errors: row_errors, csv_data: csv_data, notices: notices ) { @@ -183,11 +201,7 @@ def assemble_result(headers:, missing_required:, header_issues:, row_errors:, cs collections: collections, works: works, fileSets: file_sets, - totalItems: csv_data.length, - fileReferences: file_validator.count_references, - missingFiles: file_validator.missing_files, - foundFiles: file_validator.found_files_count, - zipIncluded: file_validator.zip_included? + totalItems: csv_data.length } end @@ -195,7 +209,7 @@ def assemble_result(headers:, missing_required:, header_issues:, row_errors:, cs # rights_statement can be supplied on Step 2, so a CSV missing ONLY the # rights_statement column is valid-with-warnings rather than a blocker; # the display formatter styles that case as a warning accordion. - def determine_validity(headers:, missing_required:, header_issues:, row_errors:, csv_data:, file_validator:, notices:) # rubocop:disable Metrics/ParameterLists + def determine_validity(headers:, missing_required:, header_issues:, row_errors:, csv_data:, notices:) # rubocop:disable Metrics/ParameterLists row_error_entries = row_errors.select { |e| e[:severity] == 'error' } row_warning_entries = row_errors.select { |e| e[:severity] == 'warning' } @@ -204,10 +218,9 @@ def determine_validity(headers:, missing_required:, header_issues:, row_errors:, blocking_missing_required = missing_required.any? && !only_rights_missing has_errors = blocking_missing_required || headers.blank? || csv_data.empty? || - file_validator.missing_files.any? || row_error_entries.any? + row_error_entries.any? has_warnings = header_issues[:unrecognized].any? || header_issues[:empty_columns].any? || - file_validator.possible_missing_files? || row_warning_entries.any? || - notices.any? || only_rights_missing + row_warning_entries.any? || notices.any? || only_rights_missing [!has_errors, has_warnings] end diff --git a/app/services/bulkrax/file_validator.rb b/app/services/bulkrax/file_validator.rb deleted file mode 100644 index 1d299ae2e..000000000 --- a/app/services/bulkrax/file_validator.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module Bulkrax - ## - # Validates file references against zip archive contents. - class FileValidator - attr_reader :csv_data, :zip_file - - def initialize(csv_data, zip_file = nil, admin_set_id = nil) - @csv_data = csv_data - @zip_file = zip_file - @admin_set_id = admin_set_id - end - - def count_references - @csv_data.count { |item| item[:file].present? } - end - - def missing_files - return [] unless @zip_file - - referenced_files - zip_file_list - end - - def found_files_count - return 0 unless @zip_file - - (referenced_files & zip_file_list).count - end - - def zip_included? - @zip_file.present? - end - - def possible_missing_files? - return false unless referenced_files.any? - return true if @zip_file.blank? - - false - end - - private - - def referenced_files - @referenced_files ||= @csv_data.flat_map do |item| - next [] if item[:file].blank? - - Array(item[:file]).flat_map do |value| - value.to_s.split(Bulkrax::CsvParser.file_split_pattern).map { |f| File.basename(f.strip) } - end - end.compact - end - - def zip_file_list - @zip_file_list ||= begin - return [] unless @zip_file - - zip_path = @zip_file.respond_to?(:path) ? @zip_file.path : @zip_file - Zip::File.open(zip_path) do |zip| - zip.entries.select(&:file?).map { |entry| File.basename(entry.name) } - end - rescue StandardError => e - Rails.logger.error("Error reading zip file: #{e.message}") - [] - end - end - end -end diff --git a/app/services/bulkrax/stepper_response_formatter.rb b/app/services/bulkrax/stepper_response_formatter.rb index 234bcf6fb..e25983763 100644 --- a/app/services/bulkrax/stepper_response_formatter.rb +++ b/app/services/bulkrax/stepper_response_formatter.rb @@ -33,10 +33,6 @@ class StepperResponseFormatter # - works: Array of work items with id, title, type, parentIds (array), childIds (array) # - fileSets: Array of file set items # - totalItems: Total count of items - # - fileReferences: Count of file references - # - missingFiles: Array of missing file names - # - foundFiles: Count of found files - # - zipIncluded: Boolean indicating if zip was provided # @return [Hash] Formatted response ready for JSON rendering def self.format(data) new(data).format @@ -94,10 +90,6 @@ def format works: @data[:works], fileSets: @data[:fileSets], totalItems: @data[:totalItems], - fileReferences: @data[:fileReferences], - missingFiles: @data[:missingFiles], - foundFiles: @data[:foundFiles], - zipIncluded: @data[:zipIncluded], messages: build_messages } end @@ -121,7 +113,6 @@ def build_messages issues << missing_required_issue if @data[:missingRequired]&.any? issues << notices_issue if @data[:notices]&.any? issues << unrecognized_fields_issue if @data[:unrecognized]&.any? || @data[:emptyColumns]&.any? - issues << file_references_issue if @data[:fileReferences]&.positive? issues << row_errors_issue if @data[:rowErrors]&.any? { |e| e[:severity] == 'error' } issues << row_warnings_issue if @data[:rowErrors]&.any? { |e| e[:severity] == 'warning' } @@ -231,55 +222,6 @@ def unrecognized_fields_issue_items named + empty end - # Format file references issue - # - # @return [Hash, nil] File references issue structure or nil if not applicable - def file_references_issue - missing_files = @data[:missingFiles] || [] - - if missing_files.any? && @data[:zipIncluded] - missing_files_issue - elsif !@data[:zipIncluded] - no_zip_issue - end - end - - # Format issue for missing files in ZIP - # - # @return [Hash] Missing files issue structure - def missing_files_issue - missing_files = @data[:missingFiles] - - { - type: 'file_references', - severity: 'warning', - icon: 'fa-info-circle', - title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'), - count: @data[:fileReferences], - summary: I18n.t('bulkrax.importer.guided_import.validation.files_found_in_zip', found: @data[:foundFiles], total: @data[:fileReferences]), - description: I18n.t('bulkrax.importer.guided_import.validation.files_missing_from_zip', count: missing_files.length, files_word: 'file'.pluralize(missing_files.length)), - items: missing_files.map { |file| { field: file, message: I18n.t('bulkrax.importer.guided_import.validation.missing_from_zip') } }, - defaultOpen: false - } - end - - # Format issue for no ZIP uploaded - # - # @return [Hash] No ZIP issue structure - def no_zip_issue - { - type: 'file_references', - severity: 'warning', - icon: 'fa-exclamation-triangle', - title: I18n.t('bulkrax.importer.guided_import.validation.file_references_title'), - count: @data[:fileReferences], - summary: I18n.t('bulkrax.importer.guided_import.validation.files_referenced', count: @data[:fileReferences]), - description: I18n.t('bulkrax.importer.guided_import.validation.no_zip_desc'), - items: [], - defaultOpen: false - } - end - def row_errors_issue entries = filtered_row_errors.select { |e| e[:severity] == 'error' } return nil if entries.empty? diff --git a/app/services/bulkrax/validation_error_csv_builder.rb b/app/services/bulkrax/validation_error_csv_builder.rb index ce4aec5d2..a7cbca164 100644 --- a/app/services/bulkrax/validation_error_csv_builder.rb +++ b/app/services/bulkrax/validation_error_csv_builder.rb @@ -25,8 +25,7 @@ module Bulkrax # file_errors: { # missing_required: result[:missingRequired], # unrecognized: result[:unrecognized], - # empty_columns: result[:emptyColumns], - # missing_files: result[:missingFiles] + # empty_columns: result[:emptyColumns] # } # ) class ValidationErrorCsvBuilder @@ -49,7 +48,6 @@ class ValidationErrorCsvBuilder # - :missing_required [Array] each hash has :model and :field # - :unrecognized [Hash] column_name => suggestion_or_nil # - :empty_columns [Array] 1-based column positions with no header - # - :missing_files [Array] filenames referenced but not found # @return [String] CSV content def self.build(headers:, csv_data:, row_errors:, file_errors: {}) new(headers: headers, csv_data: csv_data, row_errors: row_errors, file_errors: file_errors).build @@ -113,10 +111,6 @@ def file_level_error_rows messages << I18n.t("#{I18N_BASE}.empty_column", column: pos + 2) end - Array(@file_errors[:missing_files]).each do |filename| - messages << I18n.t("#{I18N_BASE}.missing_file", filename: filename) - end - messages end end diff --git a/app/validators/bulkrax/csv_row/file_reference.rb b/app/validators/bulkrax/csv_row/file_reference.rb index cd0b6b398..5a6366a55 100644 --- a/app/validators/bulkrax/csv_row/file_reference.rb +++ b/app/validators/bulkrax/csv_row/file_reference.rb @@ -52,7 +52,9 @@ def self.error_hash(record, row_index, path) { row: row_index, source_identifier: record[:source_identifier], - severity: 'error', + # A referenced file missing from the ZIP is a warning, not an + # error — the file may still exist on the server at import time. + severity: 'warning', category: 'missing_file_reference', column: 'file', value: path, diff --git a/config/locales/bulkrax.de.yml b/config/locales/bulkrax.de.yml index b8d20aede..e6f6a4aa9 100644 --- a/config/locales/bulkrax.de.yml +++ b/config/locales/bulkrax.de.yml @@ -352,11 +352,6 @@ de: suggestion: "Falls Sie keinen bestehenden Datensatz aktualisieren wollten, ändern Sie den Wert für %{field}." failed: Validierung fehlgeschlagen file_path_not_exist: Der Dateipfad existiert nicht. - file_references_title: Dateiverweise - files_found_in_zip: "%{found} von %{total} Dateien im ZIP-Archiv gefunden." - files_missing_from_zip: "%{count} %{files_word} werden in Ihrer CSV-Datei referenziert, fehlen aber in der ZIP-Datei:" - files_referenced: Die in der CSV-Datei referenzierten %{count}-Dateien wurden beim Import nicht gefunden. - missing_from_zip: fehlt in der Postleitzahl missing_source_identifier_validator: errors: message: "In der Zeile fehlt ein Wert für '%{field}'." @@ -369,7 +364,6 @@ de: no_csv_in_zip: Es wurden keine CSV-Dateien im ZIP-Archiv gefunden. no_csv_uploaded: Es wurde keine CSV-Metadatendatei hochgeladen. no_files_uploaded: Es wurden keine Dateien hochgeladen. - no_zip_desc: Es wurde keine ZIP-Datei hochgeladen. Stellen Sie sicher, dass die Dateien auf dem Server zugänglich sind, oder laden Sie eine ZIP-Datei hoch. parent_reference_validator: errors: message: "Das referenzierte übergeordnete Element '%{value}' existiert nicht als %{field} in dieser CSV-Datei." @@ -400,7 +394,6 @@ de: suggestion: "Geben Sie einen Wert für '%{field}' ein." validation_error_csv_builder: empty_column: "Spalte %{column} hat keine Überschrift und wird beim Import ignoriert" - missing_file: "Fehlende Datei: %{filename}" missing_required_column: "Fehlende Pflichtspalte '%{field}' (%{model})" unrecognized_column: "Unbekannte Spalte '%{column}'" unrecognized_column_with_suggestion: "Unbekannte Spalte '%{column}' (meinten Sie '%{suggestion}'?)" diff --git a/config/locales/bulkrax.en.yml b/config/locales/bulkrax.en.yml index d3baf34f0..566d3df3f 100644 --- a/config/locales/bulkrax.en.yml +++ b/config/locales/bulkrax.en.yml @@ -352,11 +352,6 @@ en: suggestion: "If you did not intend to update an existing record, change the %{field} value." failed: Validation Failed file_path_not_exist: File path does not exist - file_references_title: File References - files_found_in_zip: "%{found} of %{total} files found in ZIP." - files_missing_from_zip: "%{count} %{files_word} referenced in your CSV but missing from the ZIP:" - files_referenced: "%{count} files referenced in CSV not found in import." - missing_from_zip: missing from ZIP missing_source_identifier_validator: errors: message: "Row is missing a value for '%{field}'." @@ -369,7 +364,6 @@ en: no_csv_in_zip: No CSV files found in ZIP no_csv_uploaded: No CSV metadata file uploaded no_files_uploaded: No files uploaded - no_zip_desc: No ZIP file uploaded. Ensure files are accessible on the server or upload a ZIP. parent_reference_validator: errors: message: "Referenced parent '%{value}' does not exist as a %{field} in this CSV." @@ -395,6 +389,9 @@ en: message_column_empty: "No model provided — all rows will be imported as '%{default_work_type}'." suggestion_column_empty: "Add model values to the 'model' column in your CSV if you want to use a different work type for some rows." suggestion_column_missing: "Add a 'model' column to your CSV if you want to use a different work type for some rows." + files_referenced_no_zip_notice: + message: "Files are referenced in the CSV but no ZIP was uploaded." + suggestion: "Upload a ZIP of the referenced files, or ensure they are accessible on the server at import time." default_work_type_validator: warnings: message: "No model specified — this row will be imported as '%{default_work_type}'." @@ -405,7 +402,6 @@ en: suggestion: "Add a value for '%{field}'." validation_error_csv_builder: empty_column: "Column %{column} has no header and will be ignored during import" - missing_file: "Missing file: %{filename}" missing_required_column: "Missing required column '%{field}' (%{model})" unrecognized_column: "Unrecognized column '%{column}'" unrecognized_column_with_suggestion: "Unrecognized column '%{column}' (did you mean '%{suggestion}'?)" diff --git a/config/locales/bulkrax.es.yml b/config/locales/bulkrax.es.yml index 583ee0d7b..a71443697 100644 --- a/config/locales/bulkrax.es.yml +++ b/config/locales/bulkrax.es.yml @@ -352,11 +352,6 @@ es: suggestion: "Si no tenía intención de actualizar un registro existente, cambie el valor de %{field}." failed: Validación fallida file_path_not_exist: La ruta del archivo no existe - file_references_title: Referencias de archivos - files_found_in_zip: Se encontraron %{found} de %{total} archivos en ZIP. - files_missing_from_zip: "%{count} %{files_word} referenciado en su CSV pero falta en el ZIP:" - files_referenced: Los archivos %{count} referenciados en CSV no se encontraron en la importación. - missing_from_zip: Falta en el código postal missing_source_identifier_validator: errors: message: "Falta un valor para '%{field}' en esta fila." @@ -369,7 +364,6 @@ es: no_csv_in_zip: No se encontraron archivos CSV en ZIP no_csv_uploaded: No se cargó ningún archivo de metadatos CSV no_files_uploaded: No hay archivos subidos - no_zip_desc: No se ha subido ningún archivo ZIP. Asegúrese de que los archivos estén accesibles en el servidor o suba un archivo ZIP. parent_reference_validator: errors: message: "El elemento primario referenciado '%{value}' no existe como %{field} en este CSV." @@ -400,7 +394,6 @@ es: suggestion: "Añada un valor para '%{field}'." validation_error_csv_builder: empty_column: "La columna %{column} no tiene encabezado y se ignorará durante la importación" - missing_file: "Archivo faltante: %{filename}" missing_required_column: "Falta la columna obligatoria '%{field}' (%{model})" unrecognized_column: "Columna no reconocida '%{column}'" unrecognized_column_with_suggestion: "Columna no reconocida '%{column}' (¿quiso decir '%{suggestion}'?)" diff --git a/config/locales/bulkrax.fr.yml b/config/locales/bulkrax.fr.yml index 8c10e41e5..68d33df58 100644 --- a/config/locales/bulkrax.fr.yml +++ b/config/locales/bulkrax.fr.yml @@ -352,11 +352,6 @@ fr: suggestion: "Si vous ne souhaitiez pas mettre à jour un enregistrement existant, modifiez la valeur de %{field}." failed: Échec de la validation file_path_not_exist: Le chemin d'accès au fichier n'existe pas. - file_references_title: Références de fichiers - files_found_in_zip: "%{found} fichiers sur %{total} trouvés dans le fichier ZIP." - files_missing_from_zip: "%{count} %{files_word} référencé dans votre fichier CSV mais absent du fichier ZIP :" - files_referenced: "%{count} fichiers référencés dans le fichier CSV sont introuvables lors de l'importation." - missing_from_zip: manquant dans le fichier ZIP missing_source_identifier_validator: errors: message: "Il manque une valeur pour '%{field}' dans cette ligne." @@ -369,7 +364,6 @@ fr: no_csv_in_zip: Aucun fichier CSV trouvé dans le fichier ZIP no_csv_uploaded: Aucun fichier de métadonnées CSV n'a été téléchargé. no_files_uploaded: Aucun fichier téléchargé - no_zip_desc: Aucun fichier ZIP n'a été téléchargé. Assurez-vous que les fichiers sont accessibles sur le serveur ou téléchargez un fichier ZIP. parent_reference_validator: errors: message: "Le parent référencé « %{value} » n'existe pas en tant que %{field} dans ce CSV." @@ -400,7 +394,6 @@ fr: suggestion: "Ajoutez une valeur pour « %{field} »." validation_error_csv_builder: empty_column: "La colonne %{column} n'a pas d'en-tête et sera ignorée lors de l'importation" - missing_file: "Fichier manquant : %{filename}" missing_required_column: "Colonne obligatoire manquante « %{field} » (%{model})" unrecognized_column: "Colonne non reconnue « %{column} »" unrecognized_column_with_suggestion: "Colonne non reconnue « %{column} » (vouliez-vous dire « %{suggestion} » ?)" diff --git a/config/locales/bulkrax.it.yml b/config/locales/bulkrax.it.yml index 2d0fb2e00..e1453f83d 100644 --- a/config/locales/bulkrax.it.yml +++ b/config/locales/bulkrax.it.yml @@ -352,11 +352,6 @@ it: suggestion: "Se non intendevi aggiornare un record esistente, modifica il valore di %{field}." failed: Convalida fallita file_path_not_exist: Il percorso del file non esiste - file_references_title: Riferimenti ai file - files_found_in_zip: "%{found} di %{total} file trovati in ZIP." - files_missing_from_zip: "%{count} %{files_word} a cui si fa riferimento nel CSV ma che non è presente nel file ZIP:" - files_referenced: "%{count} file referenziati nel CSV non trovati durante l'importazione." - missing_from_zip: mancante dal codice postale missing_source_identifier_validator: errors: message: "Nella riga manca un valore per '%{field}'." @@ -369,7 +364,6 @@ it: no_csv_in_zip: Nessun file CSV trovato nello ZIP no_csv_uploaded: Nessun file di metadati CSV caricato no_files_uploaded: Nessun file caricato - no_zip_desc: Nessun file ZIP caricato. Assicurarsi che i file siano accessibili sul server o caricare un file ZIP. parent_reference_validator: errors: message: "Il genitore referenziato '%{value}' non esiste come %{field} in questo CSV." @@ -400,7 +394,6 @@ it: suggestion: "Aggiungi un valore per '%{field}'." validation_error_csv_builder: empty_column: "La colonna %{column} non ha intestazione e verrà ignorata durante l'importazione" - missing_file: "File mancante: %{filename}" missing_required_column: "Colonna obbligatoria mancante '%{field}' (%{model})" unrecognized_column: "Colonna non riconosciuta '%{column}'" unrecognized_column_with_suggestion: "Colonna non riconosciuta '%{column}' (intendevi '%{suggestion}'?)" diff --git a/config/locales/bulkrax.pt-BR.yml b/config/locales/bulkrax.pt-BR.yml index b8894884c..202abcd7d 100644 --- a/config/locales/bulkrax.pt-BR.yml +++ b/config/locales/bulkrax.pt-BR.yml @@ -352,11 +352,6 @@ pt-BR: suggestion: "Se você não pretendia atualizar um registro existente, altere o valor de %{field}." failed: Validação falhou file_path_not_exist: O caminho do arquivo não existe. - file_references_title: Referências de arquivos - files_found_in_zip: "%{found} de %{total} arquivos encontrados no arquivo ZIP." - files_missing_from_zip: "%{count} %{files_word} referenciado no seu CSV, mas ausente no ZIP:" - files_referenced: "%{count} arquivos referenciados no CSV não foram encontrados na importação." - missing_from_zip: ausente do CEP missing_source_identifier_validator: errors: message: "A linha não tem um valor para '%{field}'." @@ -369,7 +364,6 @@ pt-BR: no_csv_in_zip: Nenhum arquivo CSV encontrado no arquivo ZIP. no_csv_uploaded: Nenhum arquivo de metadados CSV foi carregado. no_files_uploaded: Nenhum arquivo foi enviado. - no_zip_desc: Nenhum arquivo ZIP foi enviado. Certifique-se de que os arquivos estejam acessíveis no servidor ou envie um arquivo ZIP. parent_reference_validator: errors: message: "O pai referenciado '%{value}' não existe como %{field} neste CSV." @@ -400,7 +394,6 @@ pt-BR: suggestion: "Adicione um valor para '%{field}'." validation_error_csv_builder: empty_column: "A coluna %{column} não tem cabeçalho e será ignorada durante a importação" - missing_file: "Arquivo ausente: %{filename}" missing_required_column: "Falta a coluna obrigatória '%{field}' (%{model})" unrecognized_column: "Coluna não reconhecida '%{column}'" unrecognized_column_with_suggestion: "Coluna não reconhecida '%{column}' (você quis dizer '%{suggestion}'?)" diff --git a/config/locales/bulkrax.zh.yml b/config/locales/bulkrax.zh.yml index c00ba485c..972f25e43 100644 --- a/config/locales/bulkrax.zh.yml +++ b/config/locales/bulkrax.zh.yml @@ -352,11 +352,6 @@ zh: suggestion: "如果您不打算更新现有记录,请更改 %{field} 的值。" failed: 验证失败 file_path_not_exist: 文件路径不存在 - file_references_title: 文件引用 - files_found_in_zip: 在 ZIP 文件中找到 %{found} 个文件(共 %{total} 个)。 - files_missing_from_zip: CSV 文件中引用了 %{count} %{files_word},但 ZIP 文件中缺少该 %{files_word}: - files_referenced: 导入时未找到 CSV 文件中引用的 %{count} 文件。 - missing_from_zip: ZIP 文件中缺少 missing_source_identifier_validator: errors: message: "该行缺少 '%{field}' 的值。" @@ -369,7 +364,6 @@ zh: no_csv_in_zip: ZIP 文件中未找到 CSV 文件 no_csv_uploaded: 未上传 CSV 元数据文件。 no_files_uploaded: 未上传任何文件。 - no_zip_desc: 未上传 ZIP 文件。请确保服务器上的文件可访问,或上传 ZIP 文件。 parent_reference_validator: errors: message: "引用的父级 '%{value}' 在此 CSV 中不存在对应的 %{field}。" @@ -400,7 +394,6 @@ zh: suggestion: "请为 '%{field}' 添加一个值。" validation_error_csv_builder: empty_column: "第 %{column} 列没有标题,将在导入时被忽略" - missing_file: "缺少文件:%{filename}" missing_required_column: "缺少必填列 '%{field}' (%{model})" unrecognized_column: "无法识别的列 '%{column}'" unrecognized_column_with_suggestion: "无法识别的列 '%{column}'(是否想输入 '%{suggestion}'?)" diff --git a/docs/FILE_VALIDATION_FOLLOWUP.md b/docs/FILE_VALIDATION_FOLLOWUP.md deleted file mode 100644 index 27c9cf674..000000000 --- a/docs/FILE_VALIDATION_FOLLOWUP.md +++ /dev/null @@ -1,294 +0,0 @@ -# File Reference Validation — Follow-up Plan - -## Context - -Guided-import validation currently uses `Bulkrax::FileValidator` to -check whether files referenced in a CSV exist inside an uploaded zip. The check -compares **basenames only** (`File.basename`), ignoring relative paths. - -During the CSV unzip/extraction fix (issue #609, `i609-typeerror-on-unzip`), we -established that `CsvParser#path_to_files` resolves CSV `file:` column values as -**relative paths** under `files/`. So a CSV row with `file: "subdir/foo.jpg"` -requires `importer_unzip_path/files/subdir/foo.jpg` at import time. The -validator's basename-only comparison misses real errors: - -1. **Subdirectory mismatch** — CSV references `subdir_a/foo.jpg`; zip contains - `subdir_b/foo.jpg`. Basenames match, validator passes, import 404s. -2. **Root/nested mismatch** — CSV references `foo.jpg`; zip contains - `deep/nested/foo.jpg`. Validator passes, import 404s. -3. **Ambiguous basenames** — CSV references `foo.jpg`; zip contains both - `dir_a/foo.jpg` and `dir_b/foo.jpg`. Validator passes silently. -4. **Case sensitivity** — `Foo.jpg` vs `foo.jpg` on case-sensitive filesystems - (most Hyku deployments). - -Validation gives a false-positive "valid" and the job fails later at import -time with an unhelpful missing-file error. - -## Architectural decision - -File validation moves from the standalone `Bulkrax::FileValidator` class -into the existing `Bulkrax::CsvRow::*` row-validator framework. Reasoning: - -- Row validators are pluggable via `Bulkrax.csv_row_validators` — apps can - register custom validators. `FileValidator` is hard-referenced in - `CsvValidation#run_validations` with no extension point. -- Row validators produce uniform errors `{row, source_identifier, severity, - category, column, value, message, suggestion}` that flow through - `StepperResponseFormatter` and `ValidationErrorCsvBuilder` consistently. The - current `FileValidator` has a parallel code path and a different output - shape (flat `missingFiles` basename list with no row attribution). -- Per-row errors are strictly more informative than aggregated basename lists: - users see which row references which missing file, not just the union. -- Path-awareness is natural: each row validator sees `record[:file]` and can - compare full relative paths against a shared plan passed in via `context`. - -## Prerequisites from the extraction fix - -The extraction fix (prior work on this branch) introduces a placement planner -that, given a zip's entry list and a mode (`primary_csv` vs -`attachments_only`), returns a mapping of `zip_entry_name → -post_extraction_relative_path`. The planner is shared by: - -- `CsvParser#unzip_with_primary_csv` / `#unzip_attachments_only` — execute the - plan by extracting each entry to its planned path. -- This follow-up — predicts the set of relative paths that will be available - under `files/`, for validation. - -The planner exposes a read-only method (shape tbd) like: - -```ruby -plan.available_paths # => Set of relative paths under files/ - # e.g. # -``` - -If the extraction fix lands without a named planner class (inline logic in -`CsvParser#unzip_*`), the first step of this follow-up is to extract it. - -## Plan - -### 1. Extract or expose the placement planner - -Ensure the zip-placement logic is accessible from outside the unzip methods. -Expected interface: - -```ruby -plan = Bulkrax::ZipPlacementPlanner.plan(zip_file_path, mode: :primary_csv) -plan.primary_csv_entry # => Zip::Entry or nil -plan.available_paths # => Set relative paths that will exist under files/ -plan.errors # => Array of error codes: :no_csv, :multiple_csv_same_level, ... -``` - -Modes: -- `:primary_csv` — zip contains the CSV. Applies shallowest-CSV rule. -- `:attachments_only` — zip has no CSV. Applies single-top-level-wrapper strip. - -Errors from the planner (e.g. multi-CSV-at-shallowest) become validation -errors in the new flow — they're the same errors `locate_csv_entry_in_zip` -raises today. - -### 2. Build `context[:zip_plan]` - -In `CsvValidation#run_row_validators`, build `zip_plan` once per validation -run and add it to the context hash. The planner runs once; every row uses the -same plan. - -```ruby -context[:zip_plan] = zip_file ? Bulkrax::ZipPlacementPlanner.plan(zip_file.path, mode: inferred_mode) : nil -``` - -Mode is inferred from the upload shape: -- User uploaded CSV + zip → `:attachments_only`. -- User uploaded zip only → `:primary_csv`. - -### 3. Add `Bulkrax::CsvRow::FileReference` row validator - -New file: `app/validators/bulkrax/csv_row/file_reference.rb`. - -```ruby -module Bulkrax - module CsvRow - module FileReference - def self.call(record, row_index, context) - plan = context[:zip_plan] - return if plan.nil? # no zip uploaded - - value = record[:file] - return if value.blank? - - value.split(Bulkrax.multi_value_element_split_on).each do |raw| - path = raw.strip - next if path.blank? - next if plan.available_paths.include?(path) - - context[:errors] << missing_file_error(record, row_index, path, plan) - end - end - - # emits either :missing_file_reference or :ambiguous_basename depending - # on whether `path` is a bare basename that matches multiple entries - def self.missing_file_error(...); end - end - end -end -``` - -Register in defaults at [lib/bulkrax.rb](../lib/bulkrax.rb#L182): - -```ruby -def csv_row_validators - @csv_row_validators ||= [ - Bulkrax::CsvRow::MissingSourceIdentifier, - Bulkrax::CsvRow::DuplicateIdentifier, - Bulkrax::CsvRow::ParentReference, - Bulkrax::CsvRow::ChildReference, - Bulkrax::CsvRow::CircularReference, - Bulkrax::CsvRow::RequiredValues, - Bulkrax::CsvRow::ControlledVocabulary, - Bulkrax::CsvRow::FileReference # ← new - ] -end -``` - -### 4. Handle distinct error categories - -Emit different `category:` values so the UI and error CSV can distinguish: - -- `missing_file_reference` — path not found in plan. -- `ambiguous_basename` — CSV used a bare basename that matches multiple - entries in the plan under different paths. -- `case_mismatch` — plan has a matching path with different casing. - (Optional — costs a second scan; decide based on user demand.) - -Each category gets its own i18n entry under -`bulkrax.importer.guided_import.validation.file_reference_validator.errors.*`. - -### 5. Retire `Bulkrax::FileValidator` - -Delete `app/services/bulkrax/file_validator.rb` or reduce to a -stats helper that answers: - -- `zip_included?` -- `count_references` (how many rows reference files, regardless of missing) - -These are run-level observations, not validation errors. Keep as a small -helper; remove the `missing_files` / `possible_missing_files?` / -`found_files_count` methods, which are superseded by row errors. - -Update [csv_validation.rb:54](../app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb#L54) -to stop instantiating `FileValidator` for correctness purposes. The -`assemble_result` call that currently passes `file_validator:` downstream -either drops the key or retains it for the stats it still produces. - -### 6. Update `StepperResponseFormatter` - -Current special handling at [stepper_response_formatter.rb:238-261](../app/services/bulkrax/stepper_response_formatter.rb#L238-L261): - -```ruby -missing_files = @data[:missingFiles] || [] -if missing_files.any? && @data[:zipIncluded] - missing_files_issue - ... -``` - -This block either goes away (errors flow through normal row-errors pipe) or -becomes a derived summary ("N rows reference missing files") computed from -grouping row errors with `category: 'missing_file_reference'`. Decide based on -UI needs — per-row errors are clearer, but an aggregate "N files missing" -headline may still be wanted for quick scan. - -### 7. Update `ValidationErrorCsvBuilder` - -The builder already handles row errors uniformly -([validation_error_csv_builder.rb:92](../app/services/bulkrax/validation_error_csv_builder.rb#L92)). -Missing-file errors now arrive as row errors, so the CSV output should -automatically include them. Verify via spec. - -Remove the special `missing_files` handling at line 92 if it was reading from -the old flat list. - -### 8. "No zip but files referenced" warning - -Keep this as a run-level **notice**, not a row error. Similar to -`append_missing_model_notice!`: - -```ruby -def append_missing_zip_notice!(notices, csv_data) - return if csv_data.none? { |r| r[:file].present? } - return if zip_present? - notices << { - field: 'file', - category: 'files_referenced_no_zip', - message: I18n.t(...) - } -end -``` - -Called from `run_validations` before the row loop runs. - -### 9. Spec coverage - -New spec files: -- `spec/validators/bulkrax/csv_row/file_reference_spec.rb` — unit coverage for - the new row validator, covering: - - No zip plan → no errors (nothing to validate against). - - Simple basename match in plan → no error. - - Path match in plan → no error. - - Path mismatch → error with category `missing_file_reference`. - - Multi-value cell with one missing → one error for the missing value. - - Ambiguous basename → error with category `ambiguous_basename`. - - Case mismatch (if implemented) → error with category `case_mismatch`. -- `spec/services/bulkrax/zip_placement_planner_spec.rb` — unit coverage for - the planner, assuming it was extracted as a standalone class by the - extraction fix. - -Update existing: -- `spec/parsers/concerns/bulkrax/csv_parser/csv_validation_spec.rb` — end-to-end - flows using the new row validator. -- `spec/services/bulkrax/stepper_response_formatter_spec.rb` — adjust to new - formatter output. -- `spec/services/bulkrax/validation_error_csv_builder_spec.rb` — verify - missing-file errors appear in the CSV output. - -Delete: -- `spec/services/bulkrax/csv_template/file_validator_spec.rb` (or pare down to - match the reduced helper, if kept). - -### 10. Backwards compatibility - -Apps overriding `Bulkrax.csv_row_validators` to a custom array will not pick -up the new validator automatically. Document in the changelog: - -> To get file-reference validation, append -> `Bulkrax::CsvRow::FileReference` to your custom `csv_row_validators` array, -> or call `Bulkrax.register_csv_row_validator(Bulkrax::CsvRow::FileReference)`. - -Apps relying on `result[:missingFiles]` in the validation response need to -migrate to reading row errors with `category: 'missing_file_reference'`. Call -out in release notes. - -## Effort estimate - -- Placement planner extraction (if not done by extraction fix): 2-3 hours. -- Row validator + registration + i18n: 2-3 hours. -- Stepper/CsvBuilder updates: 1-2 hours. -- Spec coverage: 3-4 hours. -- **Total: roughly 1 day**, assuming the extraction fix has already produced - a shareable placement planner. - -## Out of scope for this follow-up - -- Validation of file *contents* (size, checksum, format). -- Validation that the zip itself is a valid archive — already handled - upstream. -- Cloud-files flow — `retrieve_cloud_files` places files directly into - `files/` at upload time, no zip plan involved. If cloud-files need path - validation, that's a separate piece. - -## Open questions to resolve before implementing - -1. Does the placement planner emerge as a named class from the extraction - fix, or does it need to be factored out as step 1 of this follow-up? -2. Does the UI want to keep an aggregate "N files missing" headline, or go - fully row-by-row? -3. Should `case_mismatch` be its own category, or lumped into - `missing_file_reference` with a more specific message? diff --git a/lib/bulkrax/data/demo_scenarios.json b/lib/bulkrax/data/demo_scenarios.json index 19ae1a90f..a37da2440 100644 --- a/lib/bulkrax/data/demo_scenarios.json +++ b/lib/bulkrax/data/demo_scenarios.json @@ -225,10 +225,6 @@ "rowCount": 50, "isValid": true, "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "success", @@ -423,10 +419,6 @@ "rowCount": 25, "isValid": true, "hasWarnings": false, - "fileReferences": 25, - "missingFiles": [], - "foundFiles": 25, - "zipIncluded": true, "messages": { "validationStatus": { "severity": "success", @@ -436,19 +428,7 @@ "details": "Recognized fields: source_identifier, title, creator, model, file", "defaultOpen": true }, - "issues": [ - { - "type": "file_references", - "severity": "info", - "icon": "fa-info-circle", - "title": "File References", - "count": 25, - "summary": "25 of 25 files found in ZIP.", - "description": null, - "items": [], - "defaultOpen": false - } - ] + "issues": [] } } }, @@ -579,10 +559,6 @@ "rowCount": 30, "isValid": true, "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "success", @@ -717,10 +693,6 @@ "rowCount": 20, "isValid": true, "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "success", @@ -846,10 +818,6 @@ "rowCount": 20, "isValid": true, "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "success", @@ -1053,10 +1021,6 @@ "rowCount": 247, "isValid": true, "hasWarnings": true, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "warning", @@ -1272,14 +1236,6 @@ "rowCount": 55, "isValid": true, "hasWarnings": true, - "fileReferences": 55, - "foundFiles": 52, - "zipIncluded": true, - "missingFiles": [ - "photo_087.tiff", - "letter_scan_12.pdf", - "recording_03.wav" - ], "messages": { "validationStatus": { "severity": "warning", @@ -1291,25 +1247,27 @@ }, "issues": [ { - "type": "file_references", + "type": "row_level_warnings", "severity": "warning", - "icon": "fa-info-circle", - "title": "File References", - "count": 55, - "summary": "52 of 55 files found in ZIP.", - "description": "3 files are referenced in your CSV but missing from the ZIP:", + "icon": "fa-exclamation-triangle", + "title": "Row Validation Warnings", + "count": 3, + "description": "The following issues exist with data in your CSV:", "items": [ { - "field": "photo_087.tiff", - "message": "missing from ZIP" + "field": "Row 12 · file", + "message": "Referenced file 'photo_087.tiff' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" }, { - "field": "letter_scan_12.pdf", - "message": "missing from ZIP" + "field": "Row 27 · file", + "message": "Referenced file 'letter_scan_12.pdf' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" }, { - "field": "recording_03.wav", - "message": "missing from ZIP" + "field": "Row 41 · file", + "message": "Referenced file 'recording_03.wav' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" } ], "defaultOpen": false @@ -1491,10 +1449,14 @@ "rowCount": 30, "isValid": true, "hasWarnings": true, - "fileReferences": 30, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, + "notices": [ + { + "field": "file", + "category": "files_referenced_no_zip", + "message": "Files are referenced in the CSV but no ZIP was uploaded.", + "suggestion": "Upload a ZIP of the referenced files, or ensure they are accessible on the server at import time." + } + ], "messages": { "validationStatus": { "severity": "warning", @@ -1506,14 +1468,18 @@ }, "issues": [ { - "type": "file_references", + "type": "notices", "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "File References", - "count": 30, - "summary": "30 files referenced in CSV.", - "description": "No ZIP file uploaded. Ensure files are accessible on the server or upload a ZIP file containing the referenced files.", - "items": [], + "icon": "fa-info-circle", + "title": "Import Notices", + "count": 1, + "description": "These notices may affect how your CSV is imported:", + "items": [ + { + "field": "file", + "message": "Files are referenced in the CSV but no ZIP was uploaded. Upload a ZIP of the referenced files, or ensure they are accessible on the server at import time." + } + ], "defaultOpen": false } ] @@ -1708,14 +1674,6 @@ "rowCount": 247, "isValid": true, "hasWarnings": true, - "fileReferences": 55, - "foundFiles": 52, - "zipIncluded": true, - "missingFiles": [ - "photo_087.tiff", - "letter_scan_12.pdf", - "recording_03.wav" - ], "messages": { "validationStatus": { "severity": "warning", @@ -1742,25 +1700,27 @@ "defaultOpen": false }, { - "type": "file_references", + "type": "row_level_warnings", "severity": "warning", - "icon": "fa-info-circle", - "title": "File References", - "count": 55, - "summary": "52 of 55 files found in ZIP.", - "description": "3 files are referenced in your CSV but missing from the ZIP:", + "icon": "fa-exclamation-triangle", + "title": "Row Validation Warnings", + "count": 3, + "description": "The following issues exist with data in your CSV:", "items": [ { - "field": "photo_087.tiff", - "message": "missing from ZIP" + "field": "Row 12 · file", + "message": "Referenced file 'photo_087.tiff' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" }, { - "field": "letter_scan_12.pdf", - "message": "missing from ZIP" + "field": "Row 27 · file", + "message": "Referenced file 'letter_scan_12.pdf' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" }, { - "field": "recording_03.wav", - "message": "missing from ZIP" + "field": "Row 41 · file", + "message": "Referenced file 'recording_03.wav' is not in the uploaded ZIP. Check the file path and ensure the file exists in the ZIP at the same location.", + "category": "missing_file_reference" } ], "defaultOpen": false @@ -1945,10 +1905,6 @@ "rowCount": 100, "isValid": false, "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "error", @@ -2171,10 +2127,6 @@ "rowCount": 75, "isValid": false, "hasWarnings": true, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, "messages": { "validationStatus": { "severity": "error", diff --git a/spec/controllers/bulkrax/guided_imports_controller_spec.rb b/spec/controllers/bulkrax/guided_imports_controller_spec.rb index b8d0d22bc..8def550da 100644 --- a/spec/controllers/bulkrax/guided_imports_controller_spec.rb +++ b/spec/controllers/bulkrax/guided_imports_controller_spec.rb @@ -169,12 +169,14 @@ def json_response post_validate(importer: { parser_fields: { files: [csv_upload, zip_upload] } }) expect(response).to have_http_status(:ok) - expect(json_response[:zipIncluded]).to eq(true) - expect(json_response[:missingFiles]).to include(missing_name) - - file_issue = json_response.dig(:messages, :issues).find { |i| i[:type] == 'file_references' } - expect(file_issue).to be_present - expect(file_issue[:items]).to include(a_hash_including(field: missing_name)) + row_errors = json_response[:rowErrors] || [] + expect(row_errors).to include( + a_hash_including( + category: 'missing_file_reference', + column: 'file', + value: missing_name + ) + ) end end @@ -287,14 +289,17 @@ def json_response end shared_examples 'per-row error for the referenced path' do |missing_path| - it "emits a missing_file_reference row error for #{missing_path}" do + it "emits a missing_file_reference row warning for #{missing_path}" do post_validate(importer: { parser_fields: { files: [csv_upload, zip_upload] } }) expect(response).to have_http_status(:ok) - expect(json_response[:isValid]).to eq(false) + # Missing files are warnings, not errors — the file may still + # exist on the server at import time. + expect(json_response[:hasWarnings]).to eq(true) row_errors = json_response[:rowErrors] || [] expect(row_errors).to include( a_hash_including( + severity: 'warning', category: 'missing_file_reference', column: 'file', value: missing_path diff --git a/spec/fixtures/demo_scenarios.json b/spec/fixtures/demo_scenarios.json deleted file mode 100644 index 19ae1a90f..000000000 --- a/spec/fixtures/demo_scenarios.json +++ /dev/null @@ -1,2235 +0,0 @@ -{ - "_meta": { - "description": "Demo validation scenarios for the Bulkrax guided import stepper", - "schemaRef": "docs/VALIDATION_RESPONSE_QUICK_REF.json" - }, - "scenarios": { - "success_no_issues": { - "label": "CSV with Nested Collections and Works", - "category": "success", - "files": [ - { - "id": 1, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - }, - { - "id": "work-13", - "title": "Parent Work", - "type": "work", - "parentIds": [] - }, - { - "id": "work-14", - "title": "Child Work", - "type": "work", - "parentIds": [ - "work-13" - ] - }, - { - "id": "work-15", - "title": "Parent Work with Collection Parent", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-16", - "title": "Child Work of Work with Collection Parent", - "type": "work", - "parentIds": [ - "work-15" - ] - }, - { - "id": "work-17", - "title": "Work Shared by a Collection and another Work", - "type": "work", - "parentIds": [ - "col-1", - "work-13" - ] - }, - { - "id": "work-18", - "title": "Child Work of a Child Work", - "type": "work", - "parentIds": [ - "work-16" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 50, - "isValid": true, - "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "success", - "icon": "fa-check-circle", - "title": "Validation Passed", - "summary": "6 columns detected · 50 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, file", - "defaultOpen": true - }, - "issues": [] - } - } - }, - "success_with_files": { - "label": "CSV and ZIP", - "category": "success", - "files": [ - { - "id": 2, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - }, - { - "id": 3, - "name": "files_package.zip", - "size": "1.2 GB", - "fileType": "zip", - "fromZip": false, - "subtitle": "contains files" - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 25, - "isValid": true, - "hasWarnings": false, - "fileReferences": 25, - "missingFiles": [], - "foundFiles": 25, - "zipIncluded": true, - "messages": { - "validationStatus": { - "severity": "success", - "icon": "fa-check-circle", - "title": "Validation Passed", - "summary": "5 columns detected · 25 records found", - "details": "Recognized fields: source_identifier, title, creator, model, file", - "defaultOpen": true - }, - "issues": [ - { - "type": "file_references", - "severity": "info", - "icon": "fa-info-circle", - "title": "File References", - "count": 25, - "summary": "25 of 25 files found in ZIP.", - "description": null, - "items": [], - "defaultOpen": false - } - ] - } - } - }, - "success_multi_parent": { - "label": "Success with multi-parent works", - "category": "success", - "files": [ - { - "id": 10, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-4", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-5", - "title": "Cross-Collection Photograph", - "type": "work", - "parentIds": [ - "col-1", - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interdisciplinary Recording", - "type": "work", - "parentIds": [ - "col-2", - "col-3" - ] - }, - { - "id": "work-7", - "title": "Universal Archive Item", - "type": "work", - "parentIds": [ - "col-1", - "col-2", - "col-3" - ] - }, - { - "id": "work-8", - "title": "Orphan Document", - "type": "work", - "parentIds": [] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - } - ], - "totalItems": 13, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 30, - "isValid": true, - "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "success", - "icon": "fa-check-circle", - "title": "Validation Passed", - "summary": "6 columns detected · 30 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, file", - "defaultOpen": true - }, - "issues": [] - } - } - }, - "success_children_ids": { - "label": "Children IDs define hierarchy", - "category": "success", - "files": [ - { - "id": 11, - "name": "metadata.csv", - "size": "98 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Photographs (uses childrenIds)", - "type": "collection", - "parentIds": [], - "childrenIds": [ - "work-1", - "work-2", - "work-3" - ] - }, - { - "id": "col-2", - "title": "Manuscripts (uses parentIds on children)", - "type": "collection", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "col-3", - "title": "Mixed Collection (both approaches)", - "type": "collection", - "parentIds": [], - "childrenIds": [ - "work-6" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-2", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-3", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-6", - "title": "Cross-Referenced Recording", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-7", - "title": "Orphan Document", - "type": "work", - "parentIds": [], - "childrenIds": [] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset.tiff", - "type": "file_set" - } - ], - "totalItems": 11, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "children", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 20, - "isValid": true, - "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "success", - "icon": "fa-check-circle", - "title": "Validation Passed", - "summary": "7 columns detected · 20 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, children, file", - "defaultOpen": true - }, - "issues": [] - } - } - }, - "success_with_mixed_hierarchy": { - "label": "Each collection defines hierarchy differently", - "category": "success", - "files": [ - { - "id": 11, - "name": "metadata.csv", - "size": "98 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Photographs (uses childrenIds)", - "type": "collection", - "parentIds": [], - "childrenIds": [ - "work-1", - "work-2", - "work-3" - ] - }, - { - "id": "col-2", - "title": "Manuscripts (uses parentIds on children)", - "type": "collection", - "parentIds": [], - "childrenIds": [] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-2", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-3", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [], - "childrenIds": [] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-6", - "title": "Cross-Referenced Recording", - "type": "work", - "parentIds": [ - "col-2" - ], - "childrenIds": [] - }, - { - "id": "work-7", - "title": "Orphan Document", - "type": "work", - "parentIds": [], - "childrenIds": [] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset.tiff", - "type": "file_set" - } - ], - "totalItems": 11, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "children", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 20, - "isValid": true, - "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "success", - "icon": "fa-check-circle", - "title": "Validation Passed", - "summary": "7 columns detected · 20 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, children, file", - "defaultOpen": true - }, - "issues": [] - } - } - }, - "warning_unrecognized": { - "label": "Unrecognized fields", - "category": "warning", - "files": [ - { - "id": 4, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - }, - { - "id": 5, - "name": "Archive.zip", - "size": "7.58 MB", - "fileType": "zip", - "fromZip": false, - "subtitle": "contains files" - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "file", - "description", - "date_created", - "legacy_id", - "internal_notes", - "subject" - ], - "missingRequired": [], - "unrecognized": [ - "legacy_id", - "internal_notes" - ], - "rowCount": 247, - "isValid": true, - "hasWarnings": true, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Validation Passed with Warnings", - "summary": "11 columns detected · 247 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, file, description, date_created, subject", - "defaultOpen": true - }, - "issues": [ - { - "type": "unrecognized_fields", - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Unrecognized Fields", - "count": 2, - "description": "These columns will be ignored during import:", - "items": [ - { - "field": "legacy_id", - "message": null - }, - { - "field": "internal_notes", - "message": null - } - ], - "defaultOpen": false - } - ] - } - } - }, - "warning_missing_files": { - "label": "Missing files from ZIP", - "category": "warning", - "files": [ - { - "id": 4, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - }, - { - "id": 5, - "name": "Archive.zip", - "size": "7.58 MB", - "fileType": "zip", - "fromZip": false, - "subtitle": "contains files" - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 55, - "isValid": true, - "hasWarnings": true, - "fileReferences": 55, - "foundFiles": 52, - "zipIncluded": true, - "missingFiles": [ - "photo_087.tiff", - "letter_scan_12.pdf", - "recording_03.wav" - ], - "messages": { - "validationStatus": { - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Validation Passed with Warnings", - "summary": "6 columns detected · 55 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, file", - "defaultOpen": true - }, - "issues": [ - { - "type": "file_references", - "severity": "warning", - "icon": "fa-info-circle", - "title": "File References", - "count": 55, - "summary": "52 of 55 files found in ZIP.", - "description": "3 files are referenced in your CSV but missing from the ZIP:", - "items": [ - { - "field": "photo_087.tiff", - "message": "missing from ZIP" - }, - { - "field": "letter_scan_12.pdf", - "message": "missing from ZIP" - }, - { - "field": "recording_03.wav", - "message": "missing from ZIP" - } - ], - "defaultOpen": false - } - ] - } - } - }, - "warning_no_zip": { - "label": "Files referenced, no ZIP", - "category": "warning", - "files": [ - { - "id": 6, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "file" - ], - "missingRequired": [], - "unrecognized": [], - "rowCount": 30, - "isValid": true, - "hasWarnings": true, - "fileReferences": 30, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Validation Passed with Warnings", - "summary": "5 columns detected · 30 records found", - "details": "Recognized fields: source_identifier, title, creator, model, file", - "defaultOpen": true - }, - "issues": [ - { - "type": "file_references", - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "File References", - "count": 30, - "summary": "30 files referenced in CSV.", - "description": "No ZIP file uploaded. Ensure files are accessible on the server or upload a ZIP file containing the referenced files.", - "items": [], - "defaultOpen": false - } - ] - } - } - }, - "warning_combined": { - "label": "Combined warnings", - "category": "warning", - "files": [ - { - "id": 4, - "name": "metadata.csv", - "size": "142 KB", - "fileType": "csv", - "fromZip": false - }, - { - "id": 5, - "name": "Archive.zip", - "size": "7.58 MB", - "fileType": "zip", - "fromZip": false, - "subtitle": "contains files" - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "source_identifier", - "title", - "creator", - "model", - "parents", - "file", - "description", - "date_created", - "legacy_id", - "subject" - ], - "missingRequired": [], - "unrecognized": [ - "legacy_id" - ], - "rowCount": 247, - "isValid": true, - "hasWarnings": true, - "fileReferences": 55, - "foundFiles": 52, - "zipIncluded": true, - "missingFiles": [ - "photo_087.tiff", - "letter_scan_12.pdf", - "recording_03.wav" - ], - "messages": { - "validationStatus": { - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Validation Passed with Warnings", - "summary": "10 columns detected · 247 records found", - "details": "Recognized fields: source_identifier, title, creator, model, parents, file, description, date_created, subject", - "defaultOpen": true - }, - "issues": [ - { - "type": "unrecognized_fields", - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Unrecognized Fields", - "count": 1, - "description": "These columns will be ignored during import:", - "items": [ - { - "field": "legacy_id", - "message": null - } - ], - "defaultOpen": false - }, - { - "type": "file_references", - "severity": "warning", - "icon": "fa-info-circle", - "title": "File References", - "count": 55, - "summary": "52 of 55 files found in ZIP.", - "description": "3 files are referenced in your CSV but missing from the ZIP:", - "items": [ - { - "field": "photo_087.tiff", - "message": "missing from ZIP" - }, - { - "field": "letter_scan_12.pdf", - "message": "missing from ZIP" - }, - { - "field": "recording_03.wav", - "message": "missing from ZIP" - } - ], - "defaultOpen": false - } - ] - } - } - }, - "error_missing_required": { - "label": "Missing required fields", - "category": "error", - "files": [ - { - "id": 7, - "name": "incomplete.csv", - "size": "42 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "title", - "creator", - "description", - "date_created" - ], - "missingRequired": [ - "source_identifier", - "rights_statement", - "title" - ], - "unrecognized": [], - "rowCount": 100, - "isValid": false, - "hasWarnings": false, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "error", - "icon": "fa-times-circle", - "title": "Validation Failed", - "summary": "4 columns detected · 100 records found", - "details": "Critical errors must be fixed before import.", - "defaultOpen": true - }, - "issues": [ - { - "type": "missing_required_fields", - "severity": "error", - "icon": "fa-times-circle", - "title": "Missing Required Fields", - "count": 5, - "description": "These required columns must be added to your CSV:", - "items": [ - { - "model": "GenericWork", - "field": "source_identifier" - }, - { - "model": "GenericWork", - "field": "rights_statement" - }, - { - "model": "Collection", - "field": "title" - }, - { - "model": "Collection", - "field": "rights_statement" - }, - { - "model": "FileSet", - "field": "source_identifier" - } - ], - "defaultOpen": false - } - ] - } - } - }, - "error_combined": { - "label": "Errors + warnings", - "category": "error", - "files": [ - { - "id": 7, - "name": "incomplete.csv", - "size": "42 KB", - "fileType": "csv", - "fromZip": false - } - ], - "response": { - "collections": [ - { - "id": "col-1", - "title": "Historical Photographs Collection", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-2", - "title": "Manuscripts & Letters", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-3", - "title": "Audio Recordings", - "type": "collection", - "parentIds": [] - }, - { - "id": "col-1a", - "title": "Landscapes", - "type": "collection", - "parentIds": [ - "col-1" - ] - }, - { - "id": "col-1b", - "title": "Portraits", - "type": "collection", - "parentIds": [ - "col-1" - ] - } - ], - "works": [ - { - "id": "work-1", - "title": "Sunset Over the Valley", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-2", - "title": "Portrait of a Scholar", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-3", - "title": "City Streets, 1920", - "type": "work", - "parentIds": [ - "col-1" - ] - }, - { - "id": "work-4", - "title": "Letter from John Adams", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-5", - "title": "Medieval Manuscript Fragment", - "type": "work", - "parentIds": [ - "col-2" - ] - }, - { - "id": "work-6", - "title": "Interview with Jane Doe", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-7", - "title": "Field Recording, Summer 1985", - "type": "work", - "parentIds": [ - "col-3" - ] - }, - { - "id": "work-8", - "title": "Untitled Photograph", - "type": "work", - "parentIds": [] - }, - { - "id": "work-9", - "title": "Miscellaneous Notes", - "type": "work", - "parentIds": [] - }, - { - "id": "work-10", - "title": "Mountain Vista at Dawn", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-11", - "title": "Coastal Sunset", - "type": "work", - "parentIds": [ - "col-1a" - ] - }, - { - "id": "work-12", - "title": "The Librarian", - "type": "work", - "parentIds": [ - "col-1b" - ] - } - ], - "fileSets": [ - { - "id": "fs-1", - "title": "sunset_valley.tiff", - "type": "file_set" - }, - { - "id": "fs-2", - "title": "portrait_scholar.jpg", - "type": "file_set" - }, - { - "id": "fs-3", - "title": "city_streets.png", - "type": "file_set" - } - ], - "totalItems": 20, - "headers": [ - "title", - "creator", - "description", - "legacy_id" - ], - "missingRequired": [ - "source_identifier", - "model" - ], - "unrecognized": [ - "legacy_id" - ], - "rowCount": 75, - "isValid": false, - "hasWarnings": true, - "fileReferences": 0, - "missingFiles": [], - "foundFiles": 0, - "zipIncluded": false, - "messages": { - "validationStatus": { - "severity": "error", - "icon": "fa-times-circle", - "title": "Validation Failed", - "summary": "4 columns detected · 75 records found", - "details": "Critical errors must be fixed before import.", - "defaultOpen": true - }, - "issues": [ - { - "type": "missing_required_fields", - "severity": "error", - "icon": "fa-times-circle", - "title": "Missing Required Fields", - "count": 4, - "description": "These required columns must be added to your CSV:", - "items": [ - { - "model": "GenericWork", - "field": "source_identifier" - }, - { - "model": "GenericWork", - "field": "model" - }, - { - "model": "Collection", - "field": "source_identifier" - }, - { - "model": "Collection", - "field": "model" - } - ], - "defaultOpen": false - }, - { - "type": "unrecognized_fields", - "severity": "warning", - "icon": "fa-exclamation-triangle", - "title": "Unrecognized Fields", - "count": 1, - "description": "These columns will be ignored during import:", - "items": [ - { - "field": "legacy_id", - "message": null - } - ], - "defaultOpen": false - } - ] - } - } - } - } -} diff --git a/spec/parsers/bulkrax/csv_parser/csv_validation_helpers_spec.rb b/spec/parsers/bulkrax/csv_parser/csv_validation_helpers_spec.rb index 932b10fee..2ac770269 100644 --- a/spec/parsers/bulkrax/csv_parser/csv_validation_helpers_spec.rb +++ b/spec/parsers/bulkrax/csv_parser/csv_validation_helpers_spec.rb @@ -590,16 +590,6 @@ def graph(csv_data) end describe '#assemble_result' do - let(:file_validator) do - instance_double( - 'Bulkrax::FileValidator', - missing_files: [], - possible_missing_files?: false, - count_references: 0, - found_files_count: 0, - zip_included?: false - ) - end let(:header_issues) { { unrecognized: {}, empty_columns: [] } } let(:csv_data) { [{ source_identifier: 'w1' }] } let(:headers) { %w[source_identifier title] } @@ -608,7 +598,7 @@ def assemble(missing_required:, row_errors: [], notices: []) host.send( :assemble_result, headers: headers, missing_required: missing_required, header_issues: header_issues, - row_errors: row_errors, csv_data: csv_data, file_validator: file_validator, + row_errors: row_errors, csv_data: csv_data, collections: [], works: [], file_sets: [], notices: notices ) end diff --git a/spec/services/bulkrax/csv_template/csv_parser_template_spec.rb b/spec/services/bulkrax/csv_template/csv_parser_template_spec.rb index f9da56fe3..67200d79b 100644 --- a/spec/services/bulkrax/csv_template/csv_parser_template_spec.rb +++ b/spec/services/bulkrax/csv_template/csv_parser_template_spec.rb @@ -99,14 +99,11 @@ expect(result).to have_key(:rowCount) expect(result).to have_key(:isValid) expect(result).to have_key(:hasWarnings) + expect(result).to have_key(:rowErrors) expect(result).to have_key(:collections) expect(result).to have_key(:works) expect(result).to have_key(:fileSets) expect(result).to have_key(:totalItems) - expect(result).to have_key(:fileReferences) - expect(result).to have_key(:missingFiles) - expect(result).to have_key(:foundFiles) - expect(result).to have_key(:zipIncluded) end it 'extracts headers from CSV' do @@ -127,19 +124,22 @@ expect(result[:totalItems]).to be_a(Numeric) end - it 'provides file validation information' do + it 'emits file-reference row errors when files referenced in the CSV are absent from the ZIP' do result = described_class.validate_csv(csv_file: csv_file, zip_file: zip_file) - expect(result[:fileReferences]).to be_a(Numeric) - expect(result[:foundFiles]).to be_a(Numeric) - expect(result[:missingFiles]).to be_an(Array) - expect(result[:zipIncluded]).to be true + # The fixture references image1.jpg and document.pdf in rows 1 and 4; + # the zip only contains image1.jpg and document.pdf — both present, so + # no missing-file errors here. (The fixture happens to exercise the + # happy path; per-row missing-file errors are covered in + # spec/validators/bulkrax/csv_row/file_reference_spec.rb.) + expect(result[:rowErrors]).to be_an(Array) + expect(result[:rowErrors]).to all(satisfy { |e| e[:category] != 'missing_file_reference' }) end - it 'handles validation without zip file' do + it 'emits a files_referenced_no_zip notice when files are referenced but no zip is uploaded' do result = described_class.validate_csv(csv_file: csv_file, zip_file: nil) - expect(result[:zipIncluded]).to be false - expect(result[:missingFiles]).to be_empty - expect(result[:foundFiles]).to eq(0) + expect(result[:notices]).to include( + a_hash_including(category: 'files_referenced_no_zip') + ) end context 'when only rights_statement is missing (suppliable on Step 2)' do diff --git a/spec/services/bulkrax/file_validator_spec.rb b/spec/services/bulkrax/file_validator_spec.rb deleted file mode 100644 index ec6409a1c..000000000 --- a/spec/services/bulkrax/file_validator_spec.rb +++ /dev/null @@ -1,363 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Bulkrax::FileValidator do - let(:csv_data) do - [ - { file: 'image1.jpg', source_identifier: 'work1' }, - { file: 'document.pdf', source_identifier: 'work2' }, - { file: nil, source_identifier: 'work3' } - ] - end - - let(:zip_file) do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('image1.jpg') { |f| f.write('fake image data') } - zipfile.get_output_stream('document.pdf') { |f| f.write('fake pdf data') } - end - zip.rewind - zip - end - - after do - zip_file&.close - zip_file&.unlink - end - - describe '#count_references' do - it 'counts total file references in CSV' do - validator = described_class.new(csv_data, zip_file) - expect(validator.count_references).to eq(2) - end - - it 'returns 0 when no files referenced' do - empty_data = [{ file: nil }, { file: '' }] - validator = described_class.new(empty_data, zip_file) - expect(validator.count_references).to eq(0) - end - end - - describe '#missing_files' do - it 'returns empty array when all files are found' do - validator = described_class.new(csv_data, zip_file) - expect(validator.missing_files).to be_empty - end - - it 'identifies missing files' do - data_with_missing = csv_data + [{ file: 'missing.jpg' }] - validator = described_class.new(data_with_missing, zip_file) - expect(validator.missing_files).to include('missing.jpg') - end - - it 'returns empty array when no zip provided' do - validator = described_class.new(csv_data, nil) - expect(validator.missing_files).to be_empty - end - - context 'with delimiter-separated file references in a single cell' do - let(:csv_data_multi) do - [{ file: 'Cornus_drummondii.jpg|ArtThumbnail.JPG', source_identifier: 'work1' }] - end - - let(:zip_with_both) do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('Cornus_drummondii.jpg') { |f| f.write('img') } - zipfile.get_output_stream('ArtThumbnail.JPG') { |f| f.write('thumb') } - end - zip.rewind - zip - end - - after do - zip_with_both.close - zip_with_both.unlink - end - - it 'splits on the delimiter and reports no missing files when both are present' do - validator = described_class.new(csv_data_multi, zip_with_both) - expect(validator.missing_files).to be_empty - end - - it 'counts each individual file reference' do - validator = described_class.new(csv_data_multi, zip_with_both) - expect(validator.found_files_count).to eq(2) - end - - it 'reports only the truly missing file when one is absent from the zip' do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('Cornus_drummondii.jpg') { |f| f.write('img') } - end - zip.rewind - validator = described_class.new(csv_data_multi, zip) - expect(validator.missing_files).to eq(['ArtThumbnail.JPG']) - zip.close - zip.unlink - end - end - - context 'with paths in file references' do - let(:csv_data_with_paths) do - [ - { file: 'images/photo.jpg', source_identifier: 'work1' }, - { file: 'documents/report.pdf', source_identifier: 'work2' } - ] - end - - let(:zip_with_paths) do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('subfolder/photo.jpg') { |f| f.write('fake image') } - zipfile.get_output_stream('other/report.pdf') { |f| f.write('fake pdf') } - end - zip.rewind - zip - end - - after do - zip_with_paths&.close - zip_with_paths&.unlink - end - - it 'strips paths from CSV file references and matches by basename' do - validator = described_class.new(csv_data_with_paths, zip_with_paths) - expect(validator.missing_files).to be_empty - end - - it 'identifies missing files when basenames do not match' do - data_with_missing = csv_data_with_paths + [{ file: 'path/to/missing.jpg' }] - validator = described_class.new(data_with_missing, zip_with_paths) - expect(validator.missing_files).to eq(['missing.jpg']) - end - end - end - - describe '#found_files_count' do - it 'counts files found in zip' do - validator = described_class.new(csv_data, zip_file) - expect(validator.found_files_count).to eq(2) - end - - it 'returns 0 when no zip provided' do - validator = described_class.new(csv_data, nil) - expect(validator.found_files_count).to eq(0) - end - - it 'only counts files that exist in zip' do - data_with_missing = csv_data + [{ file: 'missing.jpg' }] - validator = described_class.new(data_with_missing, zip_file) - expect(validator.found_files_count).to eq(2) - end - - context 'with paths in file references and zip entries' do - let(:csv_data_with_paths) do - [ - { file: 'images/photo.jpg', source_identifier: 'work1' }, - { file: 'documents/report.pdf', source_identifier: 'work2' }, - { file: 'nested/path/to/file.txt', source_identifier: 'work3' } - ] - end - - let(:zip_with_nested_paths) do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('different/path/photo.jpg') { |f| f.write('image') } - zipfile.get_output_stream('report.pdf') { |f| f.write('pdf') } - zipfile.get_output_stream('deeply/nested/file.txt') { |f| f.write('text') } - end - zip.rewind - zip - end - - after do - zip_with_nested_paths&.close - zip_with_nested_paths&.unlink - end - - it 'matches files by basename regardless of paths' do - validator = described_class.new(csv_data_with_paths, zip_with_nested_paths) - expect(validator.found_files_count).to eq(3) - end - - it 'counts only matching basenames even with different paths' do - mixed_data = [ - { file: 'path1/found.jpg', source_identifier: 'work1' }, - { file: 'path2/notfound.jpg', source_identifier: 'work2' } - ] - mixed_zip = Tempfile.new(['test', '.zip']) - Zip::File.open(mixed_zip.path, create: true) do |zipfile| - zipfile.get_output_stream('otherpath/found.jpg') { |f| f.write('image') } - end - mixed_zip.rewind - - validator = described_class.new(mixed_data, mixed_zip) - expect(validator.found_files_count).to eq(1) - - mixed_zip.close - mixed_zip.unlink - end - end - end - - describe '#zip_included?' do - it 'returns true when zip provided' do - validator = described_class.new(csv_data, zip_file) - expect(validator.zip_included?).to be true - end - - it 'returns false when no zip provided' do - validator = described_class.new(csv_data, nil) - expect(validator.zip_included?).to be false - end - end - - describe '#possible_missing_files?' do - it 'returns false when no file references in CSV' do - empty_data = [{ file: nil }, { file: '' }] - validator = described_class.new(empty_data, zip_file) - - expect(validator.possible_missing_files?).to be false - end - - it 'returns true when files referenced but no zip provided' do - validator = described_class.new(csv_data, nil) - - expect(validator.possible_missing_files?).to be true - end - - it 'returns false when zip provided even if some files missing from zip' do - data_with_missing = csv_data + [{ file: 'missing.jpg' }] - validator = described_class.new(data_with_missing, zip_file) - - expect(validator.possible_missing_files?).to be false - end - - it 'returns false when all referenced files are found in zip' do - validator = described_class.new(csv_data, zip_file) - - expect(validator.possible_missing_files?).to be false - end - - context 'with no files referenced' do - let(:no_files_data) do - [ - { source_identifier: 'work1' }, - { source_identifier: 'work2' } - ] - end - - it 'returns false even when zip is provided' do - validator = described_class.new(no_files_data, zip_file) - - expect(validator.possible_missing_files?).to be false - end - - it 'returns false when no zip provided' do - validator = described_class.new(no_files_data, nil) - - expect(validator.possible_missing_files?).to be false - end - end - - context 'with paths in file references' do - let(:csv_data_with_paths) do - [ - { file: 'images/photo.jpg', source_identifier: 'work1' }, - { file: 'documents/report.pdf', source_identifier: 'work2' } - ] - end - - let(:zip_with_matching_files) do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('photo.jpg') { |f| f.write('fake image') } - zipfile.get_output_stream('report.pdf') { |f| f.write('fake pdf') } - end - zip.rewind - zip - end - - after do - zip_with_matching_files&.close - zip_with_matching_files&.unlink - end - - it 'returns false when all files found (basename matching)' do - validator = described_class.new(csv_data_with_paths, zip_with_matching_files) - - expect(validator.possible_missing_files?).to be false - end - - it 'returns true when files referenced but no zip' do - validator = described_class.new(csv_data_with_paths, nil) - - expect(validator.possible_missing_files?).to be true - end - end - - context 'edge cases' do - it 'returns false for empty CSV data array' do - validator = described_class.new([], zip_file) - - expect(validator.possible_missing_files?).to be false - end - - it 'handles mix of present and absent file references' do - mixed_data = [ - { file: 'image1.jpg', source_identifier: 'work1' }, - { file: nil, source_identifier: 'work2' }, - { file: '', source_identifier: 'work3' } - ] - validator = described_class.new(mixed_data, zip_file) - - expect(validator.possible_missing_files?).to be false - end - end - end - - # FileValidator splits via Bulkrax::CsvParser.file_split_pattern, so any - # `split:` configured on the `file` mapping is honoured — matching - # #file_paths and CsvEntry#add_file. - describe 'split behaviour for the file column' do - context 'with no `file` mapping configured' do - let(:csv_data) { [{ file: 'sun.jpg;moon.jpg', source_identifier: 'work1' }] } - - it 'falls back to Bulkrax.multi_value_element_split_on' do - validator = described_class.new(csv_data, nil) - expect(validator.count_references).to eq(1) - expect(validator.possible_missing_files?).to be true - end - end - - # Use a delimiter (`,`) that the default pattern /\s*[:;|]\s*/ does NOT - # match, so this spec genuinely distinguishes honouring-the-config from - # falling back to the default. - context 'when the `file` mapping configures a non-default split' do - let(:csv_data) { [{ file: 'sun.jpg,moon.jpg', source_identifier: 'work1' }] } - - around do |spec| - old = Bulkrax.field_mappings['Bulkrax::CsvParser'] - Bulkrax.field_mappings['Bulkrax::CsvParser'] = { 'file' => { split: ',' } } - spec.run - Bulkrax.field_mappings['Bulkrax::CsvParser'] = old - end - - it 'honours the configured split' do - zip = Tempfile.new(['test', '.zip']) - Zip::File.open(zip.path, create: true) do |zipfile| - zipfile.get_output_stream('sun.jpg') { |f| f.write('a') } - zipfile.get_output_stream('moon.jpg') { |f| f.write('b') } - end - zip.rewind - validator = described_class.new(csv_data, zip) - expect(validator.found_files_count).to eq(2) - expect(validator.missing_files).to eq([]) - zip.close - zip.unlink - end - end - end -end diff --git a/spec/services/bulkrax/stepper_response_formatter_spec.rb b/spec/services/bulkrax/stepper_response_formatter_spec.rb index b19092fac..f03496d91 100644 --- a/spec/services/bulkrax/stepper_response_formatter_spec.rb +++ b/spec/services/bulkrax/stepper_response_formatter_spec.rb @@ -4,7 +4,9 @@ RSpec.describe Bulkrax::StepperResponseFormatter do # Load demo scenarios from the fixtures - let(:demo_scenarios_path) { File.expand_path('../../fixtures/demo_scenarios.json', __dir__) } + # Single source of truth — production controller reads the same file + # at lib/bulkrax/data/demo_scenarios.json. + let(:demo_scenarios_path) { Bulkrax::Engine.root.join('lib', 'bulkrax', 'data', 'demo_scenarios.json') } let(:demo_scenarios) { JSON.parse(File.read(demo_scenarios_path), symbolize_names: true) } describe '.format' do @@ -28,13 +30,7 @@ expect(result[:isValid]).to be true expect(result[:hasWarnings]).to be false expect(result[:messages][:validationStatus][:severity]).to eq('success') - expect(result[:messages][:issues].length).to eq(1) - - file_issue = result[:messages][:issues].first - expect(file_issue[:type]).to eq('file_references') - expect(file_issue[:severity]).to eq('info') - expect(file_issue[:count]).to eq(25) - expect(file_issue[:summary]).to eq('25 of 25 files found in ZIP.') + expect(result[:messages][:issues]).to be_empty end end @@ -64,17 +60,12 @@ expect(result[:hasWarnings]).to be true expect(result[:messages][:validationStatus][:severity]).to eq('warning') - file_issue = result[:messages][:issues].find { |i| i[:type] == 'file_references' } - expect(file_issue).to be_present - expect(file_issue[:severity]).to eq('warning') - expect(file_issue[:count]).to eq(55) - expect(file_issue[:summary]).to eq('52 of 55 files found in ZIP.') - expect(file_issue[:items].length).to eq(3) - expect(file_issue[:items].map { |i| i[:field] }).to contain_exactly( - 'photo_087.tiff', - 'letter_scan_12.pdf', - 'recording_03.wav' - ) + warnings_issue = result[:messages][:issues].find { |i| i[:type] == 'row_level_warnings' } + expect(warnings_issue).to be_present + expect(warnings_issue[:count]).to eq(3) + expect(warnings_issue[:items].length).to eq(3) + expect(warnings_issue[:items]).to all(satisfy { |i| i[:category] == 'missing_file_reference' }) + expect(warnings_issue[:items].map { |i| i[:message] }).to all(include("is not in the uploaded ZIP")) end it 'formats validation with file references but no ZIP' do @@ -84,13 +75,11 @@ expect(result[:isValid]).to be true expect(result[:hasWarnings]).to be true - file_issue = result[:messages][:issues].find { |i| i[:type] == 'file_references' } - expect(file_issue).to be_present - expect(file_issue[:severity]).to eq('warning') - expect(file_issue[:count]).to eq(30) - expect(file_issue[:summary]).to eq('30 files referenced in CSV.') - expect(file_issue[:description]).to include('No ZIP file uploaded') - expect(file_issue[:items]).to be_empty + notices_issue = result[:messages][:issues].find { |i| i[:type] == 'notices' } + expect(notices_issue).to be_present + expect(notices_issue[:count]).to eq(1) + expect(notices_issue[:items].first[:field]).to eq('file') + expect(notices_issue[:items].first[:message]).to include('no ZIP was uploaded') end it 'formats validation with combined warnings' do @@ -104,8 +93,8 @@ unrecognized_issue = result[:messages][:issues].find { |i| i[:type] == 'unrecognized_fields' } expect(unrecognized_issue[:count]).to eq(1) - file_issue = result[:messages][:issues].find { |i| i[:type] == 'file_references' } - expect(file_issue[:count]).to eq(55) + warnings_issue = result[:messages][:issues].find { |i| i[:type] == 'row_level_warnings' } + expect(warnings_issue[:count]).to eq(3) end end @@ -411,36 +400,31 @@ expect(items.last(2).map { |i| i[:message] }).to all(be_nil) end - it 'generates file references issue for missing files in ZIP' do + it 'surfaces missing-file row warnings via the row_level_warnings accordion' do data = { headers: ['source_identifier', 'title', 'file'], - rowCount: 10, + rowCount: 2, isValid: true, hasWarnings: true, missingRequired: [], unrecognized: {}, - fileReferences: 10, - foundFiles: 8, - missingFiles: ['file1.jpg', 'file2.pdf'], - zipIncluded: true + rowErrors: [ + { row: 2, source_identifier: 'w1', severity: 'warning', + category: 'missing_file_reference', column: 'file', value: 'missing.jpg', + message: 'Referenced file missing.jpg is not in the uploaded ZIP.', + suggestion: 'Check the file path.' } + ] } result = described_class.new(data).format - issue = result[:messages][:issues].find { |i| i[:type] == 'file_references' } - - expect(issue[:severity]).to eq('warning') - expect(issue[:icon]).to eq('fa-info-circle') - expect(issue[:title]).to eq('File References') - expect(issue[:count]).to eq(10) - expect(issue[:summary]).to eq('8 of 10 files found in ZIP.') - expect(issue[:description]).to eq('2 files referenced in your CSV but missing from the ZIP:') - expect(issue[:items]).to contain_exactly( - { field: 'file1.jpg', message: 'missing from ZIP' }, - { field: 'file2.pdf', message: 'missing from ZIP' } + issue = result[:messages][:issues].find { |i| i[:type] == 'row_level_warnings' } + expect(issue).to be_present + expect(issue[:items]).to include( + a_hash_including(category: 'missing_file_reference', message: include('missing.jpg')) ) end - it 'generates file references issue when no ZIP uploaded' do + it 'surfaces a files_referenced_no_zip notice via the notices accordion' do data = { headers: ['source_identifier', 'title', 'file'], rowCount: 5, @@ -448,22 +432,19 @@ hasWarnings: true, missingRequired: [], unrecognized: {}, - fileReferences: 5, - foundFiles: 0, - missingFiles: [], - zipIncluded: false + notices: [ + { field: 'file', category: 'files_referenced_no_zip', + message: 'Files are referenced in the CSV but no ZIP was uploaded.', + suggestion: 'Upload a ZIP of the referenced files.' } + ] } result = described_class.new(data).format - issue = result[:messages][:issues].find { |i| i[:type] == 'file_references' } - - expect(issue[:severity]).to eq('warning') - expect(issue[:icon]).to eq('fa-exclamation-triangle') - expect(issue[:title]).to eq('File References') - expect(issue[:count]).to eq(5) - expect(issue[:summary]).to eq('5 files referenced in CSV not found in import.') - expect(issue[:description]).to eq('No ZIP file uploaded. Ensure files are accessible on the server or upload a ZIP.') - expect(issue[:items]).to be_empty + notice_issue = result[:messages][:issues].find { |i| i[:type] == 'notices' } + expect(notice_issue).to be_present + expect(notice_issue[:items]).to include( + a_hash_including(field: 'file') + ) end it 'generates separate error and warning boxes when both are present' do diff --git a/spec/services/bulkrax/validation_error_csv_builder_spec.rb b/spec/services/bulkrax/validation_error_csv_builder_spec.rb index 4f312646e..5f5b10043 100644 --- a/spec/services/bulkrax/validation_error_csv_builder_spec.rb +++ b/spec/services/bulkrax/validation_error_csv_builder_spec.rb @@ -172,42 +172,6 @@ expect(rows[1][1]).to eq('Column 5 has no header and will be ignored during import') end - it 'emits a row for each missing file' do - result = described_class.build( - headers: headers, csv_data: csv_data, row_errors: row_errors, - file_errors: { missing_files: ['photo.jpg'] } - ) - rows = CSV.parse(result) - expect(rows[1][1]).to eq('Missing file: photo.jpg') - end - - it 'leaves the row number blank for file-level rows' do - result = described_class.build( - headers: headers, csv_data: csv_data, row_errors: row_errors, - file_errors: { missing_files: ['photo.jpg'] } - ) - rows = CSV.parse(result) - expect(rows[1][0]).to be_nil - end - - it 'leaves the category cell blank for file-level rows' do - result = described_class.build( - headers: headers, csv_data: csv_data, row_errors: row_errors, - file_errors: { missing_files: ['photo.jpg'] } - ) - rows = CSV.parse(result) - expect(rows[1][2]).to be_nil - end - - it 'leaves data columns blank for file-level rows' do - result = described_class.build( - headers: headers, csv_data: csv_data, row_errors: row_errors, - file_errors: { missing_files: ['photo.jpg'] } - ) - rows = CSV.parse(result) - expect(rows[1][3..]).to all(be_nil) - end - context 'when both file-level and row-level errors are present' do let(:row_errors) { [{ row: 2, severity: 'error', category: 'test', column: 'title', value: nil, message: 'Row error' }] } diff --git a/spec/validators/bulkrax/csv_row/file_reference_spec.rb b/spec/validators/bulkrax/csv_row/file_reference_spec.rb index 0a14340e6..5d4649f4a 100644 --- a/spec/validators/bulkrax/csv_row/file_reference_spec.rb +++ b/spec/validators/bulkrax/csv_row/file_reference_spec.rb @@ -65,7 +65,7 @@ def build_plan(*paths) a_hash_including( row: 2, source_identifier: 'w1', - severity: 'error', + severity: 'warning', category: 'missing_file_reference', column: 'file', value: 'subdir_a/foo.jpg'