bulkrax 9.4.1 → 9.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -2
  3. data/app/assets/javascripts/bulkrax/datatables.js +43 -8
  4. data/app/assets/javascripts/bulkrax/importers_stepper.js +221 -26
  5. data/app/assets/stylesheets/bulkrax/stepper/_review.scss +14 -12
  6. data/app/controllers/bulkrax/entries_controller.rb +2 -2
  7. data/app/controllers/bulkrax/exporters_controller.rb +3 -3
  8. data/app/controllers/bulkrax/guided_imports_controller.rb +3 -1
  9. data/app/controllers/bulkrax/importers_controller.rb +5 -5
  10. data/app/matchers/bulkrax/application_matcher.rb +5 -6
  11. data/app/models/bulkrax/csv_entry.rb +1 -1
  12. data/app/parsers/bulkrax/csv_parser.rb +15 -12
  13. data/app/parsers/concerns/bulkrax/csv_parser/csv_template_generation.rb +4 -1
  14. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation.rb +10 -8
  15. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_helpers.rb +68 -35
  16. data/app/parsers/concerns/bulkrax/csv_parser/csv_validation_hierarchy.rb +9 -7
  17. data/app/services/bulkrax/csv_template/file_validator.rb +1 -1
  18. data/app/services/bulkrax/csv_template/mapping_manager.rb +15 -6
  19. data/app/services/bulkrax/csv_template/split_formatter.rb +10 -3
  20. data/app/services/bulkrax/split_pattern_coercion.rb +42 -0
  21. data/app/services/bulkrax/stepper_response_formatter.rb +2 -1
  22. data/app/services/bulkrax/validation_error_csv_builder.rb +36 -12
  23. data/app/validators/bulkrax/csv_row/child_reference.rb +2 -1
  24. data/app/validators/bulkrax/csv_row/parent_reference.rb +1 -1
  25. data/app/validators/bulkrax/csv_row/required_values.rb +17 -3
  26. data/app/views/bulkrax/exporters/edit.html.erb +1 -1
  27. data/app/views/bulkrax/exporters/index.html.erb +3 -1
  28. data/app/views/bulkrax/exporters/new.html.erb +1 -1
  29. data/app/views/bulkrax/exporters/show.html.erb +1 -1
  30. data/app/views/bulkrax/guided_imports/new.html.erb +7 -0
  31. data/app/views/bulkrax/importers/_edit_item_buttons.html.erb +3 -3
  32. data/app/views/bulkrax/importers/index.html.erb +2 -0
  33. data/app/views/bulkrax/importers/new.html.erb +1 -1
  34. data/app/views/bulkrax/importers/show.html.erb +3 -1
  35. data/app/views/bulkrax/shared/_datatable_i18n.html.erb +3 -0
  36. data/config/locales/bulkrax.de.yml +89 -0
  37. data/config/locales/bulkrax.en.yml +52 -0
  38. data/config/locales/bulkrax.es.yml +89 -0
  39. data/config/locales/bulkrax.fr.yml +89 -0
  40. data/config/locales/bulkrax.it.yml +89 -0
  41. data/config/locales/bulkrax.pt-BR.yml +89 -0
  42. data/config/locales/bulkrax.zh.yml +90 -0
  43. data/db/migrate/20260424081537_remove_parents_from_bulkrax_importer_runs.rb +9 -0
  44. data/lib/bulkrax/version.rb +1 -1
  45. data/lib/bulkrax.rb +15 -1
  46. metadata +7 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 420e0b83f78ad1c411b0532bda121bebe74a651a9d1abec51549273896a00bcb
4
- data.tar.gz: 892e143d2de6c714121804bf547b9473545c1071ecce6ef9a6269cb60eeaf66f
3
+ metadata.gz: 71b1c3954d163d1bf9466de4754d00af59da446bf3772c401b11fbbd4de03a2b
4
+ data.tar.gz: 3a96a3b0d58992dd3c57a5253cd981bfd672645c67b47c08b2f684603467cce4
5
5
  SHA512:
6
- metadata.gz: 497fe999aa3d39f3e7281b5e743a75d5b6c60ba93d0a7a40bd63bdfe248b0c35e52dffaa9b8aebe59489e68999ea9a0e22826ed30ffea0f2d8927cfaa61852d5
7
- data.tar.gz: 176a04163d610ad5241b96ecd107ae4bc79e64dddaff94f2fa80b7b93ce48951934ffb8f4a3438169c185f7c5a6e9f468aa8371c3c1c3d282d7c9d59ebfde946
6
+ metadata.gz: f278c8b666e3b9460ed4a06aec63f963f982b11a66a3c9e4d6282a5e475238e1dad5f5a5fb006b5f913649d879f9fbe947a1caf5ad29a6d26f8846d56cff0870
7
+ data.tar.gz: 94a2041d470a33d8ed790f819b3e017a4cba6be85749b4693796e8a55fe4759810123dfe82525bcc08e52343f5a174ae69ba919750c99c927e80e1dd0dadd766
data/README.md CHANGED
@@ -225,9 +225,15 @@ This project is intended to be a safe, welcoming space for collaboration, and co
225
225
  ```bash
226
226
  /path/to/ruby/install/bin/gem install bundler -v '~> 2.4.0'
227
227
  ```
228
- - Decide on your version of Hyrax to test against and export it to your environment, then bundle install. The Hyrax version should be greater or equal to 2.3.
228
+ - Decide on your version of Hyrax & Rails to test against and export it to your environment, then bundle install. The Hyrax version should be greater or equal to 2.3.
229
+
230
+ You can find version combinations that should work in [.github/workflows/test.yml](.github/workflows/test.yml)
231
+
232
+ If you have previously bundle installed with a different combination of versions, you may need to remove your Gemfile.lock `rm Gemfile.lock`
233
+
229
234
  ```bash
230
- export HYRAX_VERSION="~> 4.0.0"
235
+ export RAILS_GEM_VERSION="~> 7.2"
236
+ export HYRAX_VERSION="~> 5.2"
231
237
  bundle install
232
238
  ```
233
239
  - Run the test migrations
@@ -1,3 +1,22 @@
1
+ function bulkraxDatatableLanguage() {
2
+ var i18n = (window.BulkraxI18n && window.BulkraxI18n.datatable && window.BulkraxI18n.datatable.language) || {}
3
+ return {
4
+ emptyTable: i18n.empty_table,
5
+ info: i18n.info,
6
+ infoEmpty: i18n.info_empty,
7
+ infoFiltered: i18n.info_filtered,
8
+ lengthMenu: i18n.length_menu,
9
+ loadingRecords: i18n.loading_records,
10
+ processing: i18n.processing,
11
+ search: i18n.search,
12
+ zeroRecords: i18n.zero_records,
13
+ paginate: {
14
+ next: i18n.next,
15
+ previous: i18n.previous
16
+ }
17
+ }
18
+ }
19
+
1
20
  Blacklight.onLoad(function() {
2
21
  if($('#importer-show-table').length) {
3
22
  $('#importer-show-table').DataTable( {
@@ -10,6 +29,7 @@ Blacklight.onLoad(function() {
10
29
  "ajax": window.location.href.replace(/(\/(importers|exporters)\/\d+)/, "$1/entry_table.json"),
11
30
  "pageLength": 30,
12
31
  "lengthMenu": [[30, 100, 200], [30, 100, 200]],
32
+ "language": bulkraxDatatableLanguage(),
13
33
  "columns": [
14
34
  { "data": "identifier" },
15
35
  { "data": "id" },
@@ -45,6 +65,7 @@ Blacklight.onLoad(function() {
45
65
  "ajax": window.location.href.replace(/(\/importers)/, "$1/importer_table.json"),
46
66
  "pageLength": 30,
47
67
  "lengthMenu": [[30, 100, 200], [30, 100, 200]],
68
+ "language": bulkraxDatatableLanguage(),
48
69
  "order": [[2, 'desc']],
49
70
  "columns": [
50
71
  { "data": "name" },
@@ -76,6 +97,7 @@ Blacklight.onLoad(function() {
76
97
  "ajax": window.location.href.replace(/(\/exporters)/, "$1/exporter_table.json"),
77
98
  "pageLength": 30,
78
99
  "lengthMenu": [[30, 100, 200], [30, 100, 200]],
100
+ "language": bulkraxDatatableLanguage(),
79
101
  "columns": [
80
102
  { "data": "name" },
81
103
  { "data": "status_message" },
@@ -94,13 +116,22 @@ Blacklight.onLoad(function() {
94
116
 
95
117
  })
96
118
 
119
+ function bulkraxDatatableFilters() {
120
+ return (window.BulkraxI18n && window.BulkraxI18n.datatable && window.BulkraxI18n.datatable.filters) || {}
121
+ }
122
+
123
+ function bulkraxDatatableStatuses() {
124
+ return (window.BulkraxI18n && window.BulkraxI18n.datatable && window.BulkraxI18n.datatable.status) || {}
125
+ }
126
+
97
127
  function entrySelect() {
98
128
  let entrySelect = document.createElement('select')
99
129
  entrySelect.id = 'entry-filter'
100
130
  entrySelect.classList.value = 'form-control input-sm'
101
131
  entrySelect.style.marginRight = '10px'
102
132
 
103
- entrySelect.add(new Option('Filter by Entry Class', ''))
133
+ var filters = bulkraxDatatableFilters()
134
+ entrySelect.add(new Option(filters.filter_by_entry_class || 'Filter by Entry Class', ''))
104
135
  // Read the options from the footer and add them to the entrySelect
105
136
  $('#importer-entry-classes').text().split('|').forEach(function (col, i) {
106
137
  entrySelect.add(new Option(col.trim()))
@@ -122,13 +153,17 @@ function statusSelect() {
122
153
  statusSelect.classList.value = 'form-control input-sm'
123
154
  statusSelect.style.marginRight = '10px'
124
155
 
125
- statusSelect.add(new Option('Filter by Status', ''));
126
- statusSelect.add(new Option('Complete'))
127
- statusSelect.add(new Option('Pending'))
128
- statusSelect.add(new Option('Failed'))
129
- statusSelect.add(new Option('Skipped'))
130
- statusSelect.add(new Option('Deleted'))
131
- statusSelect.add(new Option('Complete (with failures)'))
156
+ var filters = bulkraxDatatableFilters()
157
+ var statuses = bulkraxDatatableStatuses()
158
+ statusSelect.add(new Option(filters.filter_by_status || 'Filter by Status', ''));
159
+ // The option value must remain the English status string, as that is what
160
+ // the backend search/filter logic matches against.
161
+ statusSelect.add(new Option(statuses.complete || 'Complete', 'Complete'))
162
+ statusSelect.add(new Option(statuses.pending || 'Pending', 'Pending'))
163
+ statusSelect.add(new Option(statuses.failed || 'Failed', 'Failed'))
164
+ statusSelect.add(new Option(statuses.skipped || 'Skipped', 'Skipped'))
165
+ statusSelect.add(new Option(statuses.deleted || 'Deleted', 'Deleted'))
166
+ statusSelect.add(new Option(statuses.complete_with_failures || 'Complete (with failures)', 'Complete (with failures)'))
132
167
 
133
168
  document.querySelector('div.dataTables_filter').firstChild.prepend(statusSelect)
134
169
 
@@ -91,6 +91,9 @@
91
91
  // Hierarchy rendering limits
92
92
  MAX_TREE_DEPTH: 50, // Prevent stack overflow on deeply nested hierarchies
93
93
 
94
+ // Validation issue list cap — accordion shows up to this many items; full list is in the downloaded CSV
95
+ MAX_ISSUE_ITEMS: 10,
96
+
94
97
  // API endpoints
95
98
  ENDPOINTS: {
96
99
  DEMO_SCENARIOS: '/importers/guided_import/demo_scenarios',
@@ -1341,6 +1344,8 @@
1341
1344
  $('input[name="importer[parser_fields][override_rights_statement]"]').prop('checked', false)
1342
1345
 
1343
1346
  // Clear review step warnings from previous run
1347
+ $('.review-errors-list').empty()
1348
+ $('.review-errors').hide()
1344
1349
  $('.review-warnings-list').empty()
1345
1350
  $('.review-warnings').hide()
1346
1351
  $('.large-import-warning').hide()
@@ -1658,8 +1663,11 @@
1658
1663
 
1659
1664
  // Download errors button
1660
1665
  if (data.validationErrorsCacheKey) {
1666
+ var locale = $('input[name="locale"]').val()
1661
1667
  var downloadUrl =
1662
- CONSTANTS.ENDPOINTS.DOWNLOAD_VALIDATION_ERRORS + '?key=' + encodeURIComponent(data.validationErrorsCacheKey)
1668
+ CONSTANTS.ENDPOINTS.DOWNLOAD_VALIDATION_ERRORS +
1669
+ '?key=' + encodeURIComponent(data.validationErrorsCacheKey) +
1670
+ (locale ? '&locale=' + encodeURIComponent(locale) : '')
1663
1671
  var $btn = $(
1664
1672
  '<a class="btn btn-outline-secondary btn-sm mt-2" href="' +
1665
1673
  downloadUrl +
@@ -1745,12 +1753,14 @@
1745
1753
  return grouped
1746
1754
  }
1747
1755
 
1748
- // Render missing required fields grouped by model
1756
+ // Render missing required fields grouped by model. Group count capped at
1757
+ // CONSTANTS.MAX_ISSUE_ITEMS so we never show a partial group.
1749
1758
  function renderMissingRequiredFields(items) {
1750
1759
  var groupedByModel = groupItemsByModel(items)
1760
+ var modelNames = Object.keys(groupedByModel).slice(0, CONSTANTS.MAX_ISSUE_ITEMS)
1751
1761
  var parts = []
1752
1762
 
1753
- Object.keys(groupedByModel).forEach(function (modelName) {
1763
+ modelNames.forEach(function (modelName) {
1754
1764
  parts.push('<div class="missing-field-group">')
1755
1765
  parts.push('<strong class="missing-field-model">' + modelName + '</strong>')
1756
1766
  parts.push('<ul>')
@@ -1768,9 +1778,14 @@
1768
1778
  return parts.join('')
1769
1779
  }
1770
1780
 
1771
- // Render default issue items (unrecognized fields, file references, etc.)
1781
+ // Render default issue items (unrecognized fields, file references, etc.).
1782
+ // When items carry a `category` (row-level errors/warnings), show up to
1783
+ // CONSTANTS.MAX_ISSUE_ITEMS per category so each distinct problem type is
1784
+ // represented. Otherwise fall back to a simple slice of the first N items.
1772
1785
  function renderDefaultIssueItems(items) {
1773
- var listItems = items.map(function (item) {
1786
+ var hasCategories = items.some(function (item) { return item.category })
1787
+ var capped = hasCategories ? capItemsPerCategory(items) : items.slice(0, CONSTANTS.MAX_ISSUE_ITEMS)
1788
+ var listItems = capped.map(function (item) {
1774
1789
  var msg = item.message ? ' — ' + item.message : ''
1775
1790
  return '<li>• ' + item.field + msg + '</li>'
1776
1791
  })
@@ -1778,6 +1793,19 @@
1778
1793
  return '<ul>' + listItems.join('') + '</ul>'
1779
1794
  }
1780
1795
 
1796
+ // Keep the first CONSTANTS.MAX_ISSUE_ITEMS entries per category, preserving
1797
+ // their original order so rows still read top-to-bottom within each category.
1798
+ function capItemsPerCategory(items) {
1799
+ var counts = {}
1800
+ var result = []
1801
+ items.forEach(function (item) {
1802
+ var cat = item.category || 'uncategorized'
1803
+ counts[cat] = (counts[cat] || 0) + 1
1804
+ if (counts[cat] <= CONSTANTS.MAX_ISSUE_ITEMS) result.push(item)
1805
+ })
1806
+ return result
1807
+ }
1808
+
1781
1809
  // Render issue items based on issue type
1782
1810
  function renderIssueItems(issue) {
1783
1811
  var hasModelField = issue.items.some(function (item) { return item.model })
@@ -1845,7 +1873,7 @@
1845
1873
  issue.title,
1846
1874
  issue.icon,
1847
1875
  issue.severity,
1848
- issue.count,
1876
+ countDisplayFor(issue),
1849
1877
  issue.defaultOpen,
1850
1878
  issueContent
1851
1879
  )
@@ -1855,6 +1883,185 @@
1855
1883
 
1856
1884
  }
1857
1885
 
1886
+ // Build the count badge display for a validation issue accordion.
1887
+ // Returns a plain number (or null) normally, or an HTML fragment with a
1888
+ // truncation note when the inline list is capped.
1889
+ function countDisplayFor(issue) {
1890
+ if (issue.count == null) return null
1891
+ if (!isIssueTruncated(issue)) return issue.count
1892
+
1893
+ var noteText = t('validation_truncated_note', {
1894
+ shown: issueShownCount(issue)
1895
+ })
1896
+ var tooltip = t('validation_truncated_tooltip')
1897
+ return issue.count +
1898
+ ' <span class="accordion-count-note" title="' + escapeHtml(tooltip) +
1899
+ '" aria-label="' + escapeHtml(tooltip) + '">' +
1900
+ escapeHtml(noteText) + '</span>'
1901
+ }
1902
+
1903
+ // How many items are actually rendered in the accordion body, mirroring the
1904
+ // slicing logic in renderDefaultIssueItems / renderMissingRequiredFields.
1905
+ function issueShownCount(issue) {
1906
+ var items = issue.items || []
1907
+ if (!items.length) return 0
1908
+
1909
+ var hasModelField = issue.type === 'missing_required_fields' &&
1910
+ items.some(function (item) { return item.model })
1911
+ if (hasModelField) {
1912
+ return Math.min(issueDisplayUnitCount(issue), CONSTANTS.MAX_ISSUE_ITEMS)
1913
+ }
1914
+
1915
+ var hasCategories = items.some(function (item) { return item.category })
1916
+ if (hasCategories) return capItemsPerCategory(items).length
1917
+
1918
+ return Math.min(items.length, CONSTANTS.MAX_ISSUE_ITEMS)
1919
+ }
1920
+
1921
+ // Decide whether an issue's rendered item list is capped. Mirrors the
1922
+ // slicing logic in renderDefaultIssueItems / renderMissingRequiredFields so
1923
+ // the header note only appears when items are actually dropped.
1924
+ function isIssueTruncated(issue) {
1925
+ var items = issue.items || []
1926
+ if (!items.length) return false
1927
+
1928
+ var hasModelField = issue.type === 'missing_required_fields' &&
1929
+ items.some(function (item) { return item.model })
1930
+ if (hasModelField) return issueDisplayUnitCount(issue) > CONSTANTS.MAX_ISSUE_ITEMS
1931
+
1932
+ var hasCategories = items.some(function (item) { return item.category })
1933
+ if (hasCategories) {
1934
+ var counts = {}
1935
+ for (var i = 0; i < items.length; i++) {
1936
+ var cat = items[i].category || 'uncategorized'
1937
+ counts[cat] = (counts[cat] || 0) + 1
1938
+ if (counts[cat] > CONSTANTS.MAX_ISSUE_ITEMS) return true
1939
+ }
1940
+ return false
1941
+ }
1942
+
1943
+ return items.length > CONSTANTS.MAX_ISSUE_ITEMS
1944
+ }
1945
+
1946
+ // Render one review-section (errors or warnings) on Step 3 from a list of
1947
+ // validation issues. Each issue gets a breakdown appropriate to its shape:
1948
+ // row-level issues group by category, missing_required_fields groups by
1949
+ // model, everything else is a flat list of field names.
1950
+ function renderReviewIssueSection(issues, sectionSelector, listSelector) {
1951
+ if (!issues.length) {
1952
+ $(listSelector).empty()
1953
+ $(sectionSelector).hide()
1954
+ return
1955
+ }
1956
+
1957
+ var html = ''
1958
+ issues.forEach(function (issue) {
1959
+ var label = issue.title
1960
+ if (issue.count) { label += ' (' + issue.count + ')' }
1961
+ var detail = issue.summary || issue.description || ''
1962
+ html += '<p>' + label
1963
+ if (detail) { html += ' — ' + detail }
1964
+ html += '</p>'
1965
+
1966
+ html += renderReviewIssueBreakdown(issue)
1967
+ })
1968
+ $(listSelector).html(html)
1969
+ $(sectionSelector).show()
1970
+ }
1971
+
1972
+ // Pick the right breakdown renderer for an issue on the Step 3 summary.
1973
+ function renderReviewIssueBreakdown(issue) {
1974
+ var items = issue.items || []
1975
+ if (!items.length) return ''
1976
+
1977
+ if (issue.type === 'row_level_errors' || issue.type === 'row_level_warnings') {
1978
+ return renderWarningCategoryBreakdown(items)
1979
+ }
1980
+ if (issue.type === 'missing_required_fields' && items.some(function (i) { return i.model })) {
1981
+ return renderMissingRequiredBreakdown(items)
1982
+ }
1983
+ return renderFieldListBreakdown(items)
1984
+ }
1985
+
1986
+ // Missing required fields grouped by model, matching the Step 1 accordion layout.
1987
+ function renderMissingRequiredBreakdown(items) {
1988
+ var grouped = groupItemsByModel(items)
1989
+ var html = '<ul class="review-warning-categories">'
1990
+ Object.keys(grouped).forEach(function (modelName) {
1991
+ var fields = grouped[modelName].map(escapeHtml).join(', ')
1992
+ html += '<li><strong>' + escapeHtml(modelName) + ':</strong> ' + fields + '</li>'
1993
+ })
1994
+ html += '</ul>'
1995
+ return html
1996
+ }
1997
+
1998
+ // Flat list of `field — message` entries — used for unrecognized fields,
1999
+ // file references, notices. Message provides the context a bare field name
2000
+ // lacks (e.g. "No model column found — all rows will be imported as …").
2001
+ function renderFieldListBreakdown(items) {
2002
+ var html = '<ul class="review-warning-categories">'
2003
+ items.forEach(function (item) {
2004
+ if (!item || !item.field) return
2005
+ var line = escapeHtml(item.field)
2006
+ if (item.message) line += ' — ' + escapeHtml(item.message)
2007
+ html += '<li>' + line + '</li>'
2008
+ })
2009
+ html += '</ul>'
2010
+ return html
2011
+ }
2012
+
2013
+ // Render a bulleted breakdown of row-warning items grouped by category.
2014
+ // Used on the Review & Start Import step under "Row Validation Warnings".
2015
+ function renderWarningCategoryBreakdown(items) {
2016
+ var counts = items.reduce(function (acc, item) {
2017
+ var cat = item.category || 'uncategorized'
2018
+ acc[cat] = (acc[cat] || 0) + 1
2019
+ return acc
2020
+ }, {})
2021
+ var html = '<ul class="review-warning-categories">'
2022
+ Object.keys(counts).forEach(function (cat) {
2023
+ html += '<li>' + escapeHtml(warningCategoryLabel(cat)) + ' (' + counts[cat] + ')</li>'
2024
+ })
2025
+ html += '</ul>'
2026
+ return html
2027
+ }
2028
+
2029
+ // Localized label for a warning category, with a humanized fallback so a
2030
+ // new validator category doesn't produce raw snake_case in the UI.
2031
+ function warningCategoryLabel(category) {
2032
+ var key = 'warning_category_' + category
2033
+ var label = t(key)
2034
+ if (label === key) return humanizeCategory(category)
2035
+ return label
2036
+ }
2037
+
2038
+ function humanizeCategory(category) {
2039
+ if (!category) return ''
2040
+ return category
2041
+ .replace(/_/g, ' ')
2042
+ .replace(/\b\w/g, function (c) { return c.toUpperCase() })
2043
+ }
2044
+
2045
+ // Count the display units for an issue. For missing-required-fields grouped
2046
+ // by model the unit is a model-group; for everything else it's an item.
2047
+ function issueDisplayUnitCount(issue) {
2048
+ if (!issue.items || !issue.items.length) return 0
2049
+ var hasModelField = issue.type === 'missing_required_fields' &&
2050
+ issue.items.some(function (item) { return item.model })
2051
+ if (!hasModelField) return issue.items.length
2052
+
2053
+ var seen = {}
2054
+ var groups = 0
2055
+ issue.items.forEach(function (item) {
2056
+ var key = item.model || 'Unknown'
2057
+ if (!seen[key]) {
2058
+ seen[key] = true
2059
+ groups += 1
2060
+ }
2061
+ })
2062
+ return groups
2063
+ }
2064
+
1858
2065
  // Create accordion HTML
1859
2066
  function createAccordion(title, icon, variant, count, defaultOpen, content) {
1860
2067
  var variantClass = 'accordion-' + variant
@@ -2313,26 +2520,14 @@
2313
2520
 
2314
2521
  $('.review-settings').html(settingsHtml)
2315
2522
 
2316
- // Warnings — derive from messages.issues so all warning types are covered
2317
- var warningIssues = (data && data.messages && data.messages.issues)
2318
- ? data.messages.issues.filter(function (issue) { return issue.severity === 'warning' })
2319
- : []
2320
- if (warningIssues.length > 0) {
2321
- var warningsHtml = ''
2322
- warningIssues.forEach(function (issue) {
2323
- var label = issue.title
2324
- if (issue.count) { label += ' (' + issue.count + ')' }
2325
- var detail = issue.summary || issue.description || ''
2326
- warningsHtml += '<p>' + label
2327
- if (detail) { warningsHtml += ' — ' + detail }
2328
- warningsHtml += '</p>'
2329
- })
2330
- $('.review-warnings-list').html(warningsHtml)
2331
- $('.review-warnings').show()
2332
- } else {
2333
- $('.review-warnings-list').empty()
2334
- $('.review-warnings').hide()
2335
- }
2523
+ // Errors and warnings — derive from messages.issues so every severity is covered.
2524
+ // Row-level errors/warnings get a per-category breakdown beneath the summary line.
2525
+ var allIssues = (data && data.messages && data.messages.issues) || []
2526
+ var errorIssues = allIssues.filter(function (issue) { return issue.severity === 'error' })
2527
+ var warningIssues = allIssues.filter(function (issue) { return issue.severity === 'warning' })
2528
+
2529
+ renderReviewIssueSection(errorIssues, '.review-errors', '.review-errors-list')
2530
+ renderReviewIssueSection(warningIssues, '.review-warnings', '.review-warnings-list')
2336
2531
 
2337
2532
  // Large import warning
2338
2533
  $('.total-items-count').text(totalItems)
@@ -50,20 +50,22 @@
50
50
  }
51
51
  }
52
52
 
53
- .review-warnings {
54
- h4 {
55
- color: $color-warning-dark;
56
- }
57
- }
53
+ .review-errors h4 { color: $color-error-dark; }
54
+ .review-warnings h4 { color: $color-warning-dark; }
58
55
 
59
- .review-warnings-list {
60
- font-size: 12px;
61
- color: $color-warning-dark;
56
+ .review-errors-list { color: $color-error-dark; }
57
+ .review-warnings-list { color: $color-warning-dark; }
62
58
 
63
- ul {
64
- margin: 0;
65
- padding-left: 0;
66
- list-style: none;
59
+ .review-errors-list,
60
+ .review-warnings-list {
61
+ .review-warning-categories {
62
+ margin: 4px 0 8px 0;
63
+ padding-left: 20px;
64
+ list-style: disc;
65
+
66
+ li {
67
+ font-size: 13px;
68
+ }
67
69
  }
68
70
  }
69
71
 
@@ -75,7 +75,7 @@ module Bulkrax
75
75
  return unless defined?(::Hyrax)
76
76
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
77
77
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
78
- add_breadcrumb 'Importers', bulkrax.importers_path
78
+ add_breadcrumb t(:'bulkrax.headings.importers'), bulkrax.importers_path
79
79
  add_breadcrumb @importer.name, bulkrax.importer_path(@importer.id)
80
80
  add_breadcrumb @entry.id
81
81
  end
@@ -88,7 +88,7 @@ module Bulkrax
88
88
  return unless defined?(::Hyrax)
89
89
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
90
90
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
91
- add_breadcrumb 'Exporters', bulkrax.exporters_path
91
+ add_breadcrumb t(:'bulkrax.headings.exporters'), bulkrax.exporters_path
92
92
  add_breadcrumb @exporter.name, bulkrax.exporter_path(@exporter.id)
93
93
  add_breadcrumb @entry.id
94
94
  end
@@ -48,7 +48,7 @@ module Bulkrax
48
48
  @exporter = Exporter.new
49
49
  return unless defined?(::Hyrax)
50
50
  add_exporter_breadcrumbs
51
- add_breadcrumb 'New'
51
+ add_breadcrumb t(:'bulkrax.headings.new_exporter')
52
52
  end
53
53
 
54
54
  # GET /exporters/1/edit
@@ -56,7 +56,7 @@ module Bulkrax
56
56
  if defined?(::Hyrax)
57
57
  add_exporter_breadcrumbs
58
58
  add_breadcrumb @exporter.name, bulkrax.exporter_path(@exporter.id)
59
- add_breadcrumb 'Edit'
59
+ add_breadcrumb t(:'bulkrax.headings.edit_exporter')
60
60
  end
61
61
 
62
62
  # Correctly populate export_source_collection input
@@ -141,7 +141,7 @@ module Bulkrax
141
141
  def add_exporter_breadcrumbs
142
142
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
143
143
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
144
- add_breadcrumb 'Exporters', bulkrax.exporters_path
144
+ add_breadcrumb t(:'bulkrax.headings.exporters'), bulkrax.exporters_path
145
145
  end
146
146
 
147
147
  # Download methods
@@ -48,6 +48,8 @@ module Bulkrax
48
48
  end
49
49
 
50
50
  def download_validation_errors
51
+ set_locale_from_params
52
+
51
53
  cache_key = params[:key].to_s
52
54
  expected_prefix = "guided_import_errors:#{session.id}:"
53
55
  return head :not_found unless cache_key.start_with?(expected_prefix)
@@ -165,7 +167,7 @@ module Bulkrax
165
167
  def add_importer_breadcrumbs
166
168
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
167
169
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
168
- add_breadcrumb 'Importers', bulkrax.importers_path
170
+ add_breadcrumb t(:'bulkrax.headings.importers'), bulkrax.importers_path
169
171
  end
170
172
 
171
173
  def check_permissions
@@ -63,7 +63,7 @@ module Bulkrax
63
63
  json_response('new')
64
64
  elsif defined?(::Hyrax)
65
65
  add_importer_breadcrumbs
66
- add_breadcrumb 'New'
66
+ add_breadcrumb t(:'bulkrax.headings.new_importer')
67
67
  end
68
68
  end
69
69
 
@@ -84,7 +84,7 @@ module Bulkrax
84
84
  elsif defined?(::Hyrax)
85
85
  add_importer_breadcrumbs
86
86
  add_breadcrumb @importer.name, bulkrax.importer_path(@importer.id)
87
- add_breadcrumb 'Edit'
87
+ add_breadcrumb t(:'bulkrax.headings.edit_importer')
88
88
  end
89
89
  end
90
90
 
@@ -189,9 +189,9 @@ module Bulkrax
189
189
  return unless defined?(::Hyrax)
190
190
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
191
191
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
192
- add_breadcrumb 'Importers', bulkrax.importers_path
192
+ add_breadcrumb t(:'bulkrax.headings.importers'), bulkrax.importers_path
193
193
  add_breadcrumb @importer.name, bulkrax.importer_path(@importer.id)
194
- add_breadcrumb 'Upload Corrected Entries'
194
+ add_breadcrumb t(:'bulkrax.headings.upload_corrected_entries_action')
195
195
  end
196
196
 
197
197
  # POST /importer/1/upload_corrected_entries_file
@@ -313,7 +313,7 @@ module Bulkrax
313
313
  def add_importer_breadcrumbs
314
314
  add_breadcrumb t(:'hyrax.controls.home'), main_app.root_path
315
315
  add_breadcrumb t(:'hyrax.dashboard.breadcrumbs.admin'), hyrax.dashboard_path
316
- add_breadcrumb 'Importers', bulkrax.importers_path
316
+ add_breadcrumb t(:'bulkrax.headings.importers'), bulkrax.importers_path
317
317
  end
318
318
 
319
319
  def setup_client(url)
@@ -33,12 +33,11 @@ module Bulkrax
33
33
  end
34
34
 
35
35
  def process_split
36
- if self.split.is_a?(TrueClass)
37
- @result = @result.split(Bulkrax.multi_value_element_split_on)
38
- elsif self.split
39
- @result = @result.split(Regexp.new(self.split))
40
- @result = @result.map(&:strip).select(&:present?)
41
- end
36
+ pattern = Bulkrax::SplitPatternCoercion.coerce(self.split)
37
+ return unless pattern
38
+
39
+ @result = @result.split(pattern)
40
+ @result = @result.map(&:strip).select(&:present?) unless self.split.is_a?(TrueClass)
42
41
  end
43
42
 
44
43
  def process_parse
@@ -165,7 +165,7 @@ module Bulkrax
165
165
  def add_file
166
166
  self.parsed_metadata['file'] ||= []
167
167
  if record['file']&.is_a?(String)
168
- self.parsed_metadata['file'] = record['file'].split(Bulkrax.multi_value_element_split_on)
168
+ self.parsed_metadata['file'] = record['file'].split(Bulkrax::CsvParser.file_split_pattern)
169
169
  elsif record['file'].is_a?(Array)
170
170
  self.parsed_metadata['file'] = record['file']
171
171
  end
@@ -13,6 +13,16 @@ module Bulkrax
13
13
  true
14
14
  end
15
15
 
16
+ # @return [Regexp] the pattern String#split should use on a `file` cell.
17
+ # Honours the `file` mapping's `split:` when set, otherwise falls back
18
+ # to {Bulkrax.multi_value_element_split_on}.
19
+ def self.file_split_pattern
20
+ file_mapping = Bulkrax.field_mappings.dig(to_s, 'file') ||
21
+ Bulkrax.field_mappings.dig(to_s, :file) || {}
22
+ split_value = file_mapping['split'] || file_mapping[:split]
23
+ Bulkrax::SplitPatternCoercion.coerce(split_value) || Bulkrax.multi_value_element_split_on
24
+ end
25
+
16
26
  def records(_opts = {})
17
27
  return @records if @records.present?
18
28
 
@@ -352,20 +362,13 @@ module Bulkrax
352
362
  raise StandardError, 'No records were found' if records.blank?
353
363
  return [] if importerexporter.metadata_only?
354
364
 
365
+ # Compute once — these don't vary per record.
366
+ file_mapping = Bulkrax.field_mappings.dig(self.class.to_s, 'file', :from)&.first&.to_sym || :file
367
+ split_pattern = self.class.file_split_pattern
368
+ files_dir = path_to_files
369
+
355
370
  @file_paths ||= records.map do |r|
356
- file_mapping = Bulkrax.field_mappings.dig(self.class.to_s, 'file', :from)&.first&.to_sym || :file
357
371
  next if r[file_mapping].blank?
358
-
359
- split_value = Bulkrax.field_mappings.dig(self.class.to_s, :file, :split)
360
- split_pattern = case split_value
361
- when Regexp
362
- split_value
363
- when String
364
- Regexp.new(split_value)
365
- else
366
- Bulkrax.multi_value_element_split_on
367
- end
368
- files_dir = path_to_files
369
372
  raise StandardError, "Record references local files but no files directory could be resolved from the import path" if files_dir.nil?
370
373
 
371
374
  r[file_mapping].split(split_pattern).map do |f|
@@ -27,7 +27,10 @@ module Bulkrax
27
27
 
28
28
  def initialize(models: nil, admin_set_id: nil)
29
29
  @admin_set_id = admin_set_id
30
- @mapping_manager = CsvTemplate::MappingManager.new
30
+ # Template generation excludes system-maintained fields (generated:
31
+ # true) so users don't see columns like date_uploaded, depositor,
32
+ # etc. on the downloadable template.
33
+ @mapping_manager = CsvTemplate::MappingManager.new(include_generated: false)
31
34
  @mappings = @mapping_manager.mappings
32
35
  @field_analyzer = CsvTemplate::FieldAnalyzer.new(@mappings, admin_set_id)
33
36
  @all_models = CsvTemplate::ModelLoader.new(Array.wrap(models)).models