cocina-models 0.75.0 → 0.76.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +40 -12
  3. data/.rubocop_todo.yml +71 -2
  4. data/README.md +10 -3
  5. data/cocina-models.gemspec +2 -0
  6. data/description_types.yml +165 -38
  7. data/docs/description_types.md +469 -216
  8. data/lib/cocina/generator/generator.rb +7 -12
  9. data/lib/cocina/generator/schema.rb +1 -3
  10. data/lib/cocina/generator/schema_base.rb +0 -8
  11. data/lib/cocina/generator/schema_ref.rb +1 -1
  12. data/lib/cocina/generator/schema_value.rb +14 -4
  13. data/lib/cocina/models/access.rb +4 -4
  14. data/lib/cocina/models/admin_policy.rb +1 -1
  15. data/lib/cocina/models/admin_policy_access_template.rb +7 -7
  16. data/lib/cocina/models/admin_policy_administrative.rb +1 -1
  17. data/lib/cocina/models/admin_policy_with_metadata.rb +3 -3
  18. data/lib/cocina/models/builders/name_title_group_builder.rb +0 -4
  19. data/lib/cocina/models/builders/title_builder.rb +0 -2
  20. data/lib/cocina/models/citation_only_access.rb +2 -2
  21. data/lib/cocina/models/collection_access.rb +4 -4
  22. data/lib/cocina/models/collection_identification.rb +1 -1
  23. data/lib/cocina/models/collection_with_metadata.rb +2 -2
  24. data/lib/cocina/models/contributor.rb +4 -4
  25. data/lib/cocina/models/controlled_digital_lending_access.rb +2 -2
  26. data/lib/cocina/models/dark_access.rb +4 -4
  27. data/lib/cocina/models/description.rb +3 -3
  28. data/lib/cocina/models/descriptive_basic_value.rb +13 -13
  29. data/lib/cocina/models/descriptive_parallel_contributor.rb +5 -5
  30. data/lib/cocina/models/descriptive_parallel_event.rb +3 -3
  31. data/lib/cocina/models/descriptive_value.rb +13 -13
  32. data/lib/cocina/models/descriptive_value_language.rb +6 -6
  33. data/lib/cocina/models/dro.rb +1 -1
  34. data/lib/cocina/models/dro_access.rb +8 -8
  35. data/lib/cocina/models/dro_with_metadata.rb +3 -3
  36. data/lib/cocina/models/embargo.rb +5 -5
  37. data/lib/cocina/models/event.rb +3 -3
  38. data/lib/cocina/models/file.rb +4 -4
  39. data/lib/cocina/models/file_access.rb +4 -4
  40. data/lib/cocina/models/identification.rb +2 -2
  41. data/lib/cocina/models/language.rb +12 -12
  42. data/lib/cocina/models/location_based_access.rb +1 -1
  43. data/lib/cocina/models/location_based_download_access.rb +1 -1
  44. data/lib/cocina/models/mapping/error_notifier.rb +36 -0
  45. data/lib/cocina/models/mapping/from_mods/access.rb +177 -0
  46. data/lib/cocina/models/mapping/from_mods/admin_metadata.rb +217 -0
  47. data/lib/cocina/models/mapping/from_mods/alt_rep_group.rb +26 -0
  48. data/lib/cocina/models/mapping/from_mods/authority.rb +51 -0
  49. data/lib/cocina/models/mapping/from_mods/contributor.rb +161 -0
  50. data/lib/cocina/models/mapping/from_mods/description.rb +99 -0
  51. data/lib/cocina/models/mapping/from_mods/description_builder.rb +61 -0
  52. data/lib/cocina/models/mapping/from_mods/event.rb +543 -0
  53. data/lib/cocina/models/mapping/from_mods/form.rb +381 -0
  54. data/lib/cocina/models/mapping/from_mods/geographic.rb +219 -0
  55. data/lib/cocina/models/mapping/from_mods/hydrus_default_title_builder.rb +28 -0
  56. data/lib/cocina/models/mapping/from_mods/identifier.rb +51 -0
  57. data/lib/cocina/models/mapping/from_mods/identifier_builder.rb +71 -0
  58. data/lib/cocina/models/mapping/from_mods/identifier_type.rb +292 -0
  59. data/lib/cocina/models/mapping/from_mods/language.rb +36 -0
  60. data/lib/cocina/models/mapping/from_mods/language_script.rb +30 -0
  61. data/lib/cocina/models/mapping/from_mods/language_term.rb +106 -0
  62. data/lib/cocina/models/mapping/from_mods/name_builder.rb +307 -0
  63. data/lib/cocina/models/mapping/from_mods/note.rb +162 -0
  64. data/lib/cocina/models/mapping/from_mods/part_builder.rb +147 -0
  65. data/lib/cocina/models/mapping/from_mods/primary.rb +27 -0
  66. data/lib/cocina/models/mapping/from_mods/purl.rb +53 -0
  67. data/lib/cocina/models/mapping/from_mods/related_resource.rb +105 -0
  68. data/lib/cocina/models/mapping/from_mods/subject.rb +413 -0
  69. data/lib/cocina/models/mapping/from_mods/subject_authority_codes.rb +794 -0
  70. data/lib/cocina/models/mapping/from_mods/title.rb +160 -0
  71. data/lib/cocina/models/mapping/from_mods/title_builder.rb +106 -0
  72. data/lib/cocina/models/mapping/from_mods/title_builder_strategy.rb +19 -0
  73. data/lib/cocina/models/mapping/from_mods/value_uri.rb +25 -0
  74. data/lib/cocina/models/mapping/normalizers/base.rb +16 -0
  75. data/lib/cocina/models/mapping/normalizers/mods/geo_extension_normalizer.rb +69 -0
  76. data/lib/cocina/models/mapping/normalizers/mods/name_normalizer.rb +191 -0
  77. data/lib/cocina/models/mapping/normalizers/mods/origin_info_normalizer.rb +157 -0
  78. data/lib/cocina/models/mapping/normalizers/mods/subject_normalizer.rb +296 -0
  79. data/lib/cocina/models/mapping/normalizers/mods/title_normalizer.rb +91 -0
  80. data/lib/cocina/models/mapping/normalizers/mods_normalizer.rb +409 -0
  81. data/lib/cocina/models/mapping/purl.rb +28 -0
  82. data/lib/cocina/models/mapping/to_mods/access.rb +155 -0
  83. data/lib/cocina/models/mapping/to_mods/admin_metadata.rb +129 -0
  84. data/lib/cocina/models/mapping/to_mods/contributor.rb +49 -0
  85. data/lib/cocina/models/mapping/to_mods/description.rb +63 -0
  86. data/lib/cocina/models/mapping/to_mods/event.rb +200 -0
  87. data/lib/cocina/models/mapping/to_mods/form.rb +292 -0
  88. data/lib/cocina/models/mapping/to_mods/geographic.rb +151 -0
  89. data/lib/cocina/models/mapping/to_mods/id_generator.rb +25 -0
  90. data/lib/cocina/models/mapping/to_mods/identifier.rb +57 -0
  91. data/lib/cocina/models/mapping/to_mods/language.rb +82 -0
  92. data/lib/cocina/models/mapping/to_mods/mods_writer.rb +38 -0
  93. data/lib/cocina/models/mapping/to_mods/name_title_group.rb +29 -0
  94. data/lib/cocina/models/mapping/to_mods/name_writer.rb +228 -0
  95. data/lib/cocina/models/mapping/to_mods/note.rb +105 -0
  96. data/lib/cocina/models/mapping/to_mods/part_writer.rb +115 -0
  97. data/lib/cocina/models/mapping/to_mods/related_resource.rb +108 -0
  98. data/lib/cocina/models/mapping/to_mods/role_writer.rb +50 -0
  99. data/lib/cocina/models/mapping/to_mods/subject.rb +486 -0
  100. data/lib/cocina/models/mapping/to_mods/title.rb +260 -0
  101. data/lib/cocina/models/object_metadata.rb +2 -2
  102. data/lib/cocina/models/presentation.rb +2 -2
  103. data/lib/cocina/models/related_resource.rb +9 -9
  104. data/lib/cocina/models/release_tag.rb +4 -4
  105. data/lib/cocina/models/request_admin_policy.rb +1 -1
  106. data/lib/cocina/models/request_administrative.rb +1 -1
  107. data/lib/cocina/models/request_collection.rb +2 -2
  108. data/lib/cocina/models/request_description.rb +3 -3
  109. data/lib/cocina/models/request_dro.rb +4 -4
  110. data/lib/cocina/models/request_file.rb +5 -5
  111. data/lib/cocina/models/request_identification.rb +1 -1
  112. data/lib/cocina/models/sequence.rb +1 -1
  113. data/lib/cocina/models/source.rb +4 -4
  114. data/lib/cocina/models/standard.rb +5 -5
  115. data/lib/cocina/models/stanford_access.rb +2 -2
  116. data/lib/cocina/models/title.rb +13 -13
  117. data/lib/cocina/models/validators/dark_validator.rb +4 -2
  118. data/lib/cocina/models/validators/open_api_validator.rb +0 -4
  119. data/lib/cocina/models/version.rb +1 -1
  120. data/lib/cocina/models/world_access.rb +2 -2
  121. data/lib/cocina/models.rb +4 -0
  122. data/lib/cocina/rspec/factories.rb +157 -0
  123. data/lib/cocina/rspec.rb +2 -0
  124. data/openapi.yml +4 -4
  125. metadata +88 -3
  126. data/docs/_config.yml +0 -1
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps MODS relatedItem to cocina relatedResource
8
+ class RelatedResource
9
+ TYPES = Cocina::Models::Mapping::ToMods::RelatedResource::TYPES.invert.freeze
10
+ DETAIL_TYPES = Cocina::Models::Mapping::ToMods::RelatedResource::DETAIL_TYPES.invert.freeze
11
+
12
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
13
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder
14
+ # @param [String] purl
15
+ # @return [Hash] a hash that can be mapped to a cocina model
16
+ def self.build(resource_element:, description_builder:, purl:)
17
+ new(resource_element: resource_element, description_builder: description_builder, purl: purl).build
18
+ end
19
+
20
+ def initialize(resource_element:, description_builder:, purl:)
21
+ @resource_element = resource_element
22
+ @description_builder = description_builder
23
+ @notifier = description_builder.notifier
24
+ @purl = purl
25
+ end
26
+
27
+ def build
28
+ related_items + related_purls
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :resource_element, :description_builder, :notifier, :purl
34
+
35
+ def related_items
36
+ resource_element.xpath('mods:relatedItem', mods: Description::DESC_METADATA_NS).filter_map do |related_item|
37
+ check_other_type(related_item)
38
+ next { valueAt: related_item['xlink:href'] } if related_item['xlink:href']
39
+ next if related_item.elements.empty?
40
+
41
+ related_item = build_related_item(related_item)
42
+ # Skip if type only.
43
+ next if related_item.keys == [:type]
44
+
45
+ related_item.presence
46
+ end
47
+ end
48
+
49
+ def build_related_item(related_item)
50
+ description_builder.build(resource_element: related_item, require_title: false).tap do |item|
51
+ item[:displayLabel] = related_item['displayLabel']
52
+ if related_item['type']
53
+ item[:type] = normalized_type_for(related_item['type'])
54
+ elsif related_item['otherType']
55
+ item[:type] = 'related to'
56
+ item[:note] ||= []
57
+ item[:note] <<
58
+ { type: 'other relation type', value: related_item['otherType'] }.tap do |note|
59
+ note[:uri] = related_item['otherTypeURI'] if related_item['otherTypeURI']
60
+ note[:source] = { value: related_item['otherTypeAuth'] } if related_item['otherTypeAuth']
61
+ end
62
+ end
63
+ end.compact
64
+ end
65
+
66
+ # Normalize type so we can tolerate certain known data errors, but report anything that is not found or not an exact match
67
+ def normalized_type_for(type)
68
+ return TYPES.fetch(type) if TYPES.key?(type)
69
+
70
+ normalized_type = if type.casecmp('other version').zero?
71
+ TYPES['otherVersion']
72
+ elsif type.casecmp('isreferencedby').zero?
73
+ TYPES['isReferencedBy']
74
+ end
75
+
76
+ notifier.warn('Invalid related resource type', { resource_type: type })
77
+ normalized_type
78
+ end
79
+
80
+ def check_other_type(related_item)
81
+ return unless related_item['type'] && related_item['otherType']
82
+
83
+ notifier.warn('Related resource has type and otherType')
84
+ end
85
+
86
+ def related_purls
87
+ primary_purl_node = Purl.primary_purl_node(resource_element, purl)
88
+ purl_nodes = resource_element.xpath('mods:location/mods:url',
89
+ mods: Description::DESC_METADATA_NS).select do |url_node|
90
+ Cocina::Models::Mapping::Purl.purl?(url_node.text) && url_node != primary_purl_node
91
+ end
92
+ purl_nodes.map do |purl_node|
93
+ {
94
+ purl: Purl.purl_value(purl_node),
95
+ access: {
96
+ note: Purl.purl_note(purl_node).presence
97
+ }.compact.presence
98
+ }.compact
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/ClassLength
4
+ module Cocina
5
+ module Models
6
+ module Mapping
7
+ module FromMods
8
+ # Maps subject elements from MODS to cocina
9
+ class Subject
10
+ NODE_TYPE = {
11
+ 'classification' => 'classification',
12
+ 'genre' => 'genre',
13
+ 'geographic' => 'place',
14
+ 'occupation' => 'occupation',
15
+ 'temporal' => 'time',
16
+ 'topic' => 'topic'
17
+ }.freeze
18
+
19
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
20
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder
21
+ # @param [String] purl
22
+ # @return [Hash] a hash that can be mapped to a cocina model
23
+ def self.build(resource_element:, description_builder:, purl: nil)
24
+ new(resource_element: resource_element, description_builder: description_builder).build
25
+ end
26
+
27
+ def initialize(resource_element:, description_builder:)
28
+ @resource_element = resource_element
29
+ @notifier = description_builder.notifier
30
+ end
31
+
32
+ def build
33
+ altrepgroup_subject_nodes, other_subject_nodes = AltRepGroup.split(nodes: subject_nodes)
34
+
35
+ subjects = (altrepgroup_subject_nodes.map { |subject_nodes| build_parallel_subject(subject_nodes) } +
36
+ other_subject_nodes.filter_map { |subject_node| build_subject(subject_node) } +
37
+ build_cartographics).compact
38
+ Primary.adjust(subjects, 'classification', notifier, match_type: true)
39
+ Primary.adjust(subjects.reject { |subject| subject[:type] == 'classification' }, 'subject', notifier)
40
+ subjects
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :resource_element, :notifier
46
+
47
+ def build_parallel_subject(parallel_subject_nodes)
48
+ parallel_subjects = parallel_subject_nodes.filter_map { |subject_node| build_subject(subject_node) }
49
+ # Moving type up from parallel subjects if they are all the same.
50
+ move_type = parallel_subjects.uniq { |subject| subject[:type] }.size == 1
51
+ type = move_type ? parallel_subjects.filter_map { |subject| subject.delete(:type) }.first : nil
52
+ {
53
+ parallelValue: parallel_subjects.presence,
54
+ type: type
55
+ }.compact.presence
56
+ end
57
+
58
+ # rubocop:disable Metrics/CyclomaticComplexity
59
+ # rubocop:disable Metrics/AbcSize
60
+ def build_subject(subject_node)
61
+ if subject_node['xlink:href']
62
+ return { valueAt: subject_node['xlink:href'] } if subject_node.elements.empty?
63
+
64
+ notifier.warn('Element with both xlink and value')
65
+ end
66
+
67
+ attrs = common_attrs(subject_node)
68
+ return subject_classification(subject_node, attrs) if subject_node.name == 'classification'
69
+
70
+ if subject_node.elements.empty?
71
+ unless subject_node[:valueURI]
72
+ notifier.error('Subject has no children nodes', { subject: subject_node.to_s })
73
+ return nil
74
+ end
75
+ notifier.warn('Subject has text', { subject: subject_node.to_s }) if subject_node.content.present?
76
+ end
77
+
78
+ children_nodes = subject_node.xpath('mods:*', mods: Description::DESC_METADATA_NS).to_a.reject do |child_node|
79
+ child_node.children.empty? && child_node.attributes.empty?
80
+ end
81
+ first_child_node = children_nodes.first
82
+
83
+ if children_nodes.empty?
84
+ attrs if subject_node[:valueURI]
85
+ elsif temporal_range?(children_nodes)
86
+ temporal_range(children_nodes, attrs)
87
+ elsif children_nodes.size != 1
88
+ if geo_code?(children_nodes)
89
+ geo_code_and_terms(children_nodes, attrs)
90
+ else
91
+ structured_value(children_nodes, attrs)
92
+ end
93
+ elsif first_child_node.name == 'hierarchicalGeographic'
94
+ hierarchical_geographic(first_child_node, attrs)
95
+ else
96
+ simple_item(first_child_node, attrs)
97
+ end
98
+ end
99
+ # rubocop:enable Metrics/CyclomaticComplexity
100
+ # rubocop:enable Metrics/AbcSize
101
+
102
+ def temporal_range?(children_nodes)
103
+ children_nodes.all? { |node| node.name == 'temporal' && node['point'] }
104
+ end
105
+
106
+ def geo_code?(children_nodes)
107
+ children_nodes.any? { |node| node.name == 'geographicCode' }
108
+ end
109
+
110
+ def common_attrs(subject)
111
+ {
112
+ displayLabel: subject[:displayLabel],
113
+ valueAt: subject['xlink:href']
114
+ }.tap do |attrs|
115
+ source = {
116
+ code: code_for(subject),
117
+ uri: Authority.normalize_uri(subject[:authorityURI]),
118
+ version: subject['edition'].presence # We are not interested in blank versions
119
+ }.compact
120
+ attrs[:source] = source unless source.empty?
121
+ attrs[:uri] = ValueURI.sniff(subject[:valueURI], notifier)
122
+ attrs[:encoding] = { code: subject[:encoding] } if subject[:encoding]
123
+ language_script = LanguageScript.build(node: subject)
124
+ attrs[:valueLanguage] = language_script if language_script
125
+ attrs[:status] = 'primary' if subject['usage'] == 'primary'
126
+ end.compact
127
+ end
128
+
129
+ def code_for(subject)
130
+ code = Authority.normalize_code(subject[:authority], notifier)
131
+
132
+ return nil if code.nil?
133
+
134
+ unless SubjectAuthorityCodes::SUBJECT_AUTHORITY_CODES.include?(code)
135
+ notifier.warn('Subject has unknown authority code',
136
+ { code: code })
137
+ end
138
+ code
139
+ end
140
+
141
+ def structured_value(node_set, attrs)
142
+ values = node_set.filter_map { |node| simple_item(node) }
143
+ if values.present?
144
+ Primary.adjust(values, 'genre', notifier, match_type: true)
145
+ attrs = attrs.merge(structuredValue: values)
146
+ adjust_source(attrs)
147
+ adjust_lang(attrs)
148
+ end
149
+
150
+ # Authority should be 'naf', not 'lcsh'
151
+ attrs[:source][:code] = 'naf' if attrs.dig(:source, :uri) == 'http://id.loc.gov/authorities/names/'
152
+
153
+ attrs.presence
154
+ end
155
+
156
+ def geo_code_and_terms(node_set, attrs)
157
+ values = node_set.filter_map { |node| simple_item(node) }
158
+ if values.present?
159
+ # Removes type from values
160
+
161
+ values.each { |value| value.delete(:type) }
162
+ # If nodes are all the same type then groupedValue; otherwise, a parallelValue.
163
+ attrs = if node_set.all? { |node| node.name == node_set.first.name }
164
+ attrs.merge(groupedValue: values)
165
+ else
166
+ attrs.merge(parallelValue: values)
167
+ end
168
+ adjust_source(attrs)
169
+ end
170
+ attrs[:type] = 'place'
171
+ attrs.presence
172
+ end
173
+
174
+ def adjust_lang(attrs)
175
+ # If all values have same valueLanguage then move to subject.
176
+ check_value_language = attrs[:structuredValue].first[:valueLanguage]
177
+ return unless check_value_language && attrs[:structuredValue].all? do |value|
178
+ value[:valueLanguage] == check_value_language
179
+ end
180
+
181
+ attrs[:valueLanguage] = check_value_language
182
+ attrs[:structuredValue].each { |value| value.delete(:valueLanguage) }
183
+ end
184
+
185
+ def adjust_source(attrs)
186
+ values = attrs[:structuredValue] || attrs[:groupedValue]
187
+ return if values.nil?
188
+
189
+ remove_source_prior(attrs)
190
+
191
+ values.each do |value|
192
+ uri_or_code = value[:uri] || value[:code]
193
+ # If attr has source, add to all values that have valueURI but no source.
194
+ value[:source] ||= attrs[:source] if attrs[:source] && uri_or_code
195
+ # If value has source and source matches subject source and no valueURI, then remove source.
196
+ value.delete(:source) if value[:source] && attrs[:source] == value[:source] && !uri_or_code
197
+ end
198
+
199
+ remove_source_post(attrs)
200
+ end
201
+
202
+ def remove_source_prior(attrs)
203
+ # Remove source if no uri and all values have source and all are not same type
204
+ values = attrs[:structuredValue] || attrs[:groupedValue]
205
+ return if attrs[:uri] ||
206
+ values.any? { |value| value[:source].nil? } ||
207
+ values.any? { |value| value[:type] != values.first[:types] }
208
+
209
+ attrs.delete(:source)
210
+ end
211
+
212
+ def remove_source_post(attrs)
213
+ # Delete source if no uri and all values have same source and any have uri or code.
214
+ values = attrs[:structuredValue] || attrs[:groupedValue]
215
+ return unless attrs[:uri].nil? &&
216
+ values.all? { |value| equal_sources?(attrs[:source], value[:source]) } &&
217
+ values.any? { |value| value[:uri] || value[:code] }
218
+
219
+ # values.all? { |value| value[:source] == attrs[:source] || value.dig(:source, :code) == attrs.dig(:source, :code) } &&
220
+
221
+ attrs.delete(:source)
222
+ end
223
+
224
+ def equal_sources?(source1, source2)
225
+ return true if source1 == source2
226
+ return false if source1.nil? || source2.nil?
227
+ return true if source1[:code] == source2[:code]
228
+ return true if lcsh?(source1[:code]) && lcsh?(source2[:code])
229
+
230
+ false
231
+ end
232
+
233
+ def lcsh?(code)
234
+ %w[lcsh naf].include?(code)
235
+ end
236
+
237
+ def hierarchical_geographic(hierarchical_geographic_node, attrs)
238
+ attrs = attrs.deep_merge(common_attrs(hierarchical_geographic_node))
239
+ node_set = hierarchical_geographic_node.xpath('*')
240
+ values = node_set.map do |node|
241
+ {
242
+ value: node.text,
243
+ type: decamelize(node.name)
244
+ }
245
+ end
246
+ attrs.merge(structuredValue: values, type: 'place')
247
+ end
248
+
249
+ def decamelize(str)
250
+ str.underscore.tr('_', ' ')
251
+ end
252
+
253
+ def subject_classification(subject_classification_node, attrs)
254
+ unless attrs[:uri] || attrs.dig(
255
+ :source, :code
256
+ ) || attrs.dig(:source, :uri)
257
+ notifier.warn('No source given for classification value',
258
+ value: subject_classification_node.text)
259
+ end
260
+
261
+ classification_attributes = {}.tap do |attributes|
262
+ attributes[:type] = 'classification'
263
+ attributes[:value] = subject_classification_node.text
264
+ if subject_classification_node[:displayLabel]
265
+ attributes[:displayLabel] =
266
+ subject_classification_node[:displayLabel]
267
+ end
268
+ attributes[:status] = 'primary' if subject_classification_node['usage'] == 'primary'
269
+ end
270
+ attrs.merge(classification_attributes)
271
+ end
272
+
273
+ # @return [Hash, NilClass]
274
+ def simple_item(node, orig_attrs = {})
275
+ attrs = orig_attrs.deep_merge(common_attrs(node))
276
+ case node.name
277
+ when 'name'
278
+ name(node, attrs)
279
+ when 'titleInfo'
280
+ title(node, attrs, orig_attrs)
281
+ when 'geographicCode'
282
+ code = node.text
283
+ code = normalized_marcgac(code) if attrs.dig(:source, :code) == 'marcgac'
284
+ attrs.merge(code: code, type: 'place')
285
+ when 'cartographics'
286
+ # Cartographics are built separately
287
+ nil
288
+ when 'Topic'
289
+ notifier.warn('<subject> has <Topic>; normalized to "topic"')
290
+ attrs.merge(value: node.text, type: 'topic')
291
+ else
292
+ node_type = node_type_for(node, attrs[:displayLabel])
293
+ attrs.merge(value: node.text, type: node_type) if node_type
294
+ end
295
+ end
296
+
297
+ # Strip any trailing dashes
298
+ def normalized_marcgac(code)
299
+ code.sub(/-+$/, '')
300
+ end
301
+
302
+ def title(node, attrs, orig_attrs)
303
+ title_attrs = TitleBuilder.build(title_info_element: node, notifier: notifier)
304
+ unless title_attrs
305
+ notifier.warn('<subject> found with an empty <titleInfo>; Skipping')
306
+ return
307
+ end
308
+
309
+ if node['type'] == 'uniform'
310
+ title_attrs[:type] = 'uniform'
311
+ attrs[:groupedValue] = [title_attrs]
312
+ if (uri = attrs.delete(:uri))
313
+ attrs[:groupedValue].each { |value| value[:uri] = uri }
314
+ end
315
+ if (source = attrs.delete(:source))
316
+ attrs[:groupedValue].each { |value| value[:source] = source }
317
+ end
318
+ attrs[:uri] = orig_attrs[:uri]
319
+ attrs[:source] = orig_attrs[:source]
320
+ else
321
+ attrs = attrs.merge(title_attrs)
322
+ end
323
+ attrs[:type] = 'title'
324
+ attrs.compact
325
+ end
326
+
327
+ def name(node, attrs)
328
+ name_type = name_type_for_subject(node)
329
+ attrs[:type] = name_type if name_type
330
+ full_name = NameBuilder.build(name_elements: [node], notifier: notifier)
331
+ return nil if full_name[:name].nil?
332
+
333
+ name_attrs = if full_name[:name].size > 1
334
+ {
335
+ parallelValue: full_name[:name]
336
+ }
337
+ else
338
+ full_name[:name].first
339
+ end
340
+ notes = name_notes_for(full_name[:role], node)
341
+ name_attrs[:note] = notes unless notes.empty?
342
+ name_attrs.merge(attrs)
343
+ end
344
+
345
+ def name_notes_for(roles, name_node)
346
+ notes = Array(roles).map { |role| role.merge({ type: 'role' }) }
347
+ name_node.xpath('mods:affiliation', mods: Description::DESC_METADATA_NS).each do |affil_node|
348
+ notes << { value: affil_node.text, type: 'affiliation' }
349
+ end
350
+ name_node.xpath('mods:description', mods: Description::DESC_METADATA_NS).each do |descr_node|
351
+ notes << { value: descr_node.text, type: 'description' }
352
+ end
353
+ notes
354
+ end
355
+
356
+ def node_type_for(node, display_label)
357
+ return 'event' if display_label == 'Event'
358
+
359
+ return NODE_TYPE.fetch(node.name) if NODE_TYPE.key?(node.name)
360
+
361
+ notifier.warn('Unexpected node type for subject', name: node.name)
362
+ nil
363
+ end
364
+
365
+ def name_type_for_subject(node)
366
+ name_type = node[:type]
367
+
368
+ return nil if node['xlink:href'] && node.children.empty?
369
+
370
+ return 'name' unless name_type
371
+
372
+ return 'topic' if name_type.casecmp('topic').zero?
373
+
374
+ Contributor::ROLES.fetch(name_type) if Contributor::ROLES.key?(name_type)
375
+ end
376
+
377
+ def subject_nodes
378
+ resource_element.xpath('mods:subject',
379
+ mods: Description::DESC_METADATA_NS) + resource_element.xpath('mods:classification',
380
+ mods: Description::DESC_METADATA_NS)
381
+ end
382
+
383
+ def temporal_range(children_nodes, attrs)
384
+ attrs[:structuredValue] = children_nodes.select { |node| node.content.present? }.map do |node|
385
+ {
386
+ type: node['point'],
387
+ value: node.content
388
+ }
389
+ end
390
+ attrs[:type] = 'time'
391
+ attrs[:encoding] = { code: children_nodes.first['encoding'] }
392
+ attrs
393
+ end
394
+
395
+ def build_cartographics
396
+ coordinates = subject_nodes.map do |subject_node|
397
+ subject_node.xpath('mods:cartographics/mods:coordinates',
398
+ mods: Description::DESC_METADATA_NS).filter_map do |coordinate_node|
399
+ coordinate = coordinate_node.content
400
+ next if coordinate.blank?
401
+
402
+ coordinate.delete_prefix('(').delete_suffix(')')
403
+ end
404
+ end.flatten.compact.uniq
405
+
406
+ coordinates.map { |coordinate| { value: coordinate, type: 'map coordinates' } }
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end
413
+ # rubocop:enable Metrics/ClassLength