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,307 @@
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 a name
9
+ class NameBuilder
10
+ UNCITED_DESCRIPTION = Cocina::Models::Mapping::ToMods::NameWriter::UNCITED_DESCRIPTION
11
+ TYPE_FOR_ROLES = Cocina::Models::Mapping::FromMods::Contributor::ROLES.merge('event' => 'event').freeze
12
+
13
+ # @param [Array<Nokogiri::XML::Element>] name_elements (multiple if parallel)
14
+ # @param [Cocina::Models::Mapping::ErrorNotifier] notifier
15
+ # @return [Hash] a hash that can be mapped to a cocina model
16
+ def self.build(name_elements:, notifier:)
17
+ new(name_elements: name_elements, notifier: notifier).build
18
+ end
19
+
20
+ def initialize(name_elements:, notifier:)
21
+ @name_elements = name_elements
22
+ @notifier = notifier
23
+ end
24
+
25
+ def build
26
+ if name_elements.size == 1
27
+ build_name(name_elements.first)
28
+ else
29
+ build_parallel
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :name_elements, :notifier
36
+
37
+ def build_parallel
38
+ names = {
39
+ parallelValue: name_elements.map { |name_node| build_parallel_name(name_node) },
40
+ type: type_for(name_elements.first['type']),
41
+ status: name_elements.filter_map { |name_element| name_element['usage'] }.first
42
+ }.compact
43
+ { name: [names] }.tap do |attrs|
44
+ roles = name_elements.flat_map { |name_node| build_roles(name_node) }.compact.uniq
45
+ attrs[:role] = roles.presence
46
+ end.compact
47
+ end
48
+
49
+ def build_parallel_name(name_node)
50
+ name_attrs = {
51
+ status: name_node['usage']
52
+ }.compact.merge(common_lang_script(name_node))
53
+
54
+ name_attrs = name_attrs.merge(common_name(name_node, name_attrs[:name]))
55
+ name_parts = build_name_parts(name_node)
56
+ notifier.warn('Missing name/namePart element') if name_parts.all?(&:empty?)
57
+ name_parts.each { |name_part| name_attrs = name_part.merge(name_attrs) }
58
+ name_attrs.compact
59
+ end
60
+
61
+ # build non-parallel, single name
62
+ def build_name(name_node)
63
+ return { type: 'unspecified others' } if name_node.xpath('mods:etal', mods: Description::DESC_METADATA_NS).present?
64
+
65
+ name_parts = build_name_parts(name_node)
66
+ # If there are no name parts, do not map the name
67
+ if name_parts.all?(&:empty?)
68
+ notifier.warn('Missing name/namePart element')
69
+ return {}
70
+ end
71
+
72
+ status = name_node['usage']
73
+ # NOTE: this lovely "or" clause for status 'primary' is brought to you by MARC records for our GoogleBooks
74
+ # in a perfect world, it should be sure there is no existing 'usage' attribute of primary on any top level name nodes
75
+ if status.blank? && name_node[:nameTitleGroup].present? && name_node[:type] == 'corporate' &&
76
+ name_node.parent.node_name == 'mods'
77
+ xpath_expression = "//mods:mods/mods:name[@usage='primary']"
78
+ primary_names = name_node.xpath(xpath_expression, mods: Description::DESC_METADATA_NS)
79
+ status = 'primary' if primary_names.blank?
80
+ end
81
+ {
82
+ name: name_parts,
83
+ type: name_type(name_node),
84
+ status: status
85
+ }.compact.merge(common_name(name_node, name_parts))
86
+ end
87
+
88
+ def common_name(name_node, name)
89
+ {
90
+ note: build_notes(name_node),
91
+ identifier: build_identifier(name_node)
92
+ }.tap do |attrs|
93
+ roles = build_roles(name_node)
94
+ attrs[:role] = roles unless name.nil?
95
+ end.compact
96
+ end
97
+
98
+ def common_lang_script(name_node)
99
+ {
100
+ valueLanguage: LanguageScript.build(node: name_node).presence
101
+ }.tap do |attrs|
102
+ if name_node[:transliteration]
103
+ attrs[:type] = 'transliteration'
104
+ attrs[:standard] = { value: name_node[:transliteration] }
105
+ end
106
+ end.compact
107
+ end
108
+
109
+ def build_name_parts(name_node)
110
+ name_part_nodes = name_node.xpath('mods:namePart', mods: Description::DESC_METADATA_NS)
111
+ alternative_name_nodes = name_node.xpath('mods:alternativeName', mods: Description::DESC_METADATA_NS)
112
+
113
+ parts = []
114
+ case name_part_nodes.size
115
+ when 0
116
+ parts << { valueAt: name_node['xlink:href'] } if name_node['xlink:href']
117
+ parts << common_authority(name_node) if name_node['valueURI']
118
+ when 1
119
+ parts << build_name_part(name_node, name_part_nodes.first,
120
+ default_type: alternative_name_nodes.present?)
121
+ .merge(common_authority(name_node)).merge(common_lang_script(name_node)).presence
122
+ else
123
+ vals = name_part_nodes.filter_map do |name_part|
124
+ build_name_part(name_node, name_part, default_type: name_node['type'] != 'corporate').presence
125
+ end
126
+ parts << { structuredValue: vals }.merge(common_authority(name_node)).merge(common_lang_script(name_node))
127
+ end
128
+
129
+ parts = build_alternative_name(alternative_name_nodes, parts) if alternative_name_nodes.present?
130
+
131
+ display_form = name_node.xpath('mods:displayForm', mods: Description::DESC_METADATA_NS).first
132
+ parts << { value: display_form.text, type: 'display' } if display_form
133
+ parts.compact
134
+ end
135
+
136
+ def build_name_part(name_node, name_part_node, default_type: true)
137
+ if name_part_node.content.blank? && !name_part_node['xlink:href']
138
+ notifier.warn('name/namePart missing value')
139
+ return {}
140
+ end
141
+
142
+ {
143
+ value: name_part_node.content,
144
+ type: name_part_type_for(name_part_node, default_type),
145
+ valueAt: name_part_node['xlink:href'],
146
+ displayLabel: name_node['displayLabel']
147
+ }.compact
148
+ end
149
+
150
+ def build_alternative_name(alternative_name_nodes, parts)
151
+ alternative_name_nodes.each do |alternative_name_node|
152
+ parts << {
153
+ type: alternative_name_node['altType'] || 'alternative',
154
+ value: alternative_name_node.content.presence,
155
+ valueAt: alternative_name_node['xlink:href']
156
+ }.compact
157
+ end
158
+ [{ groupedValue: parts }]
159
+ end
160
+
161
+ def name_part_type_for(name_part_node, default_type)
162
+ type = name_part_node['type']
163
+
164
+ notifier.warn('Name/namePart type attribute set to ""') if type == ''
165
+ if type.present? && !Contributor::NAME_PART.key?(type)
166
+ notifier.warn('namePart has unknown type assigned',
167
+ type: type)
168
+ end
169
+
170
+ if activity_date?(name_part_node)
171
+ 'activity dates'
172
+ elsif Contributor::NAME_PART.key?(type)
173
+ Contributor::NAME_PART[type]
174
+ elsif default_type && type.blank?
175
+ 'name'
176
+ end
177
+ end
178
+
179
+ def name_type(name_node)
180
+ name_type = type_for(name_node['type'])
181
+ return name_type if name_type.present?
182
+
183
+ role_nodes = name_node.xpath('mods:role', mods: Description::DESC_METADATA_NS)
184
+ cocina_roles = role_nodes.filter_map { |role_node| role_for(role_node) }.presence
185
+ return if cocina_roles.blank?
186
+
187
+ return 'event' if cocina_roles.first[:value] == 'event'
188
+ end
189
+
190
+ def activity_date?(name_part_node)
191
+ name_part_node['type'] == 'date' &&
192
+ name_part_node.content.start_with?('active', 'fl', 'floruit')
193
+ end
194
+
195
+ def common_authority(name_node)
196
+ {
197
+ uri: ValueURI.sniff(uri_for(name_node), notifier)
198
+ }.tap do |attrs|
199
+ source = {
200
+ code: Authority.normalize_code(name_node['authority'], notifier),
201
+ uri: Authority.normalize_uri(name_node['authorityURI'])
202
+ }.compact
203
+ attrs[:source] = source unless source.empty?
204
+ attrs[:valueAt] = name_node['xlink:href'] unless xlink_is_value_uri?(name_node)
205
+ end.compact
206
+ end
207
+
208
+ def uri_for(name_node)
209
+ return name_node['valueURI'] if name_node['valueURI']
210
+
211
+ return nil unless name_node['xlink:href'] && xlink_is_value_uri?(name_node)
212
+
213
+ notifier.warn('Name has an xlink:href property')
214
+ name_node['xlink:href']
215
+ end
216
+
217
+ def xlink_is_value_uri?(name_node)
218
+ name_node['authority'] || name_node['authorityURI']
219
+ end
220
+
221
+ def build_identifier(name_node)
222
+ name_node.xpath('mods:nameIdentifier', mods: Description::DESC_METADATA_NS).map do |identifier|
223
+ IdentifierBuilder.build_from_name_identifier(identifier_element: identifier)
224
+ end.presence
225
+ end
226
+
227
+ def build_notes(name_node)
228
+ [].tap do |parts|
229
+ name_node.xpath('mods:affiliation', mods: Description::DESC_METADATA_NS).each do |affiliation_node|
230
+ parts << { value: affiliation_node.text, type: 'affiliation' }
231
+ end
232
+
233
+ description = name_node.xpath('mods:description', mods: Description::DESC_METADATA_NS).first
234
+ if description
235
+ parts << if description.text == UNCITED_DESCRIPTION
236
+ { value: 'false', type: 'citation status' }
237
+ else
238
+ { value: description.text, type: 'description' }
239
+ end
240
+ end
241
+ end.presence
242
+ end
243
+
244
+ def build_roles(name_node)
245
+ role_nodes = name_node.xpath('mods:role', mods: Description::DESC_METADATA_NS)
246
+ role_nodes.filter_map { |role_node| role_for(role_node) }.presence
247
+ end
248
+
249
+ # shameless green
250
+ def role_for(ng_role)
251
+ code = ng_role.xpath('./mods:roleTerm[@type="code"]', mods: Description::DESC_METADATA_NS).first
252
+ text = ng_role.xpath('./mods:roleTerm[@type="text"] | ./mods:roleTerm[not(@type)]',
253
+ mods: Description::DESC_METADATA_NS).first
254
+ return if code.nil? && text.nil?
255
+
256
+ authority = ng_role.xpath('./mods:roleTerm/@authority', mods: Description::DESC_METADATA_NS).first&.content
257
+ authority_uri = ng_role.xpath('./mods:roleTerm/@authorityURI', mods: Description::DESC_METADATA_NS).first&.content
258
+ authority_value = ng_role.xpath('./mods:roleTerm/@valueURI', mods: Description::DESC_METADATA_NS).first&.content
259
+
260
+ check_role_code(code, authority)
261
+
262
+ {}.tap do |role|
263
+ source = {
264
+ code: Authority.normalize_code(authority, notifier),
265
+ uri: Authority.normalize_uri(authority_uri)
266
+ }.compact
267
+ role[:source] = source if source.present?
268
+
269
+ role[:uri] = ValueURI.sniff(authority_value, notifier)
270
+ role[:code] = code&.content
271
+ role[:value] = text.content if text
272
+
273
+ if role[:code].blank? && role[:value].blank?
274
+ notifier.warn('name/role/roleTerm missing value')
275
+ return nil
276
+ end
277
+ end.compact
278
+ end
279
+
280
+ def type_for(type)
281
+ return nil if type.blank?
282
+
283
+ unless TYPE_FOR_ROLES.key?(type.downcase)
284
+ notifier.warn('Name type unrecognized', type: type)
285
+ return
286
+ end
287
+ notifier.warn('Name type incorrectly capitalized', type: type) if type.downcase != type
288
+
289
+ TYPE_FOR_ROLES.fetch(type.downcase)
290
+ end
291
+
292
+ def check_role_code(role_code, role_authority)
293
+ return if role_code.nil? || role_authority
294
+
295
+ if role_code.content.present? && role_code.content.size == 3
296
+ notifier.warn('Contributor role code is missing authority')
297
+ return
298
+ end
299
+
300
+ notifier.error('Contributor role code has unexpected value', role: role_code.content)
301
+ end
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+ # rubocop:enable Metrics/ClassLength
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps notes
8
+ class Note # rubocop:disable Metrics/ClassLength
9
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
10
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder (not used, but passed in by DescriptionBuilder)
11
+ # @param [String] purl (not used, but passed in by DescriptionBuilder)
12
+ # @return [Hash] a hash that can be mapped to a cocina model
13
+ # def self.build(resource_element:, description_builder: nil, purl: nil)
14
+ def self.build(resource_element:, description_builder: nil, purl: nil)
15
+ new(resource_element: resource_element).build
16
+ end
17
+
18
+ def initialize(resource_element:)
19
+ @resource_element = resource_element
20
+ end
21
+
22
+ def build
23
+ abstracts + notes + table_of_contents + target_audience + parts
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :resource_element
29
+
30
+ def abstracts
31
+ all_abstract_nodes = resource_element.xpath('mods:abstract', mods: Description::DESC_METADATA_NS).select do |node|
32
+ note_present?(node)
33
+ end
34
+ altrepgroup_abstract_nodes, other_abstract_nodes = AltRepGroup.split(nodes: all_abstract_nodes)
35
+ other_abstract_nodes.map { |node| common_note_for(node).merge(abstract_type(node)) } + \
36
+ altrepgroup_abstract_nodes.map { |parallel_nodes| parallel_abstract_for(parallel_nodes) }
37
+ end
38
+
39
+ def parallel_abstract_for(abstract_nodes)
40
+ {
41
+ type: 'abstract',
42
+ parallelValue: abstract_nodes.map do |node|
43
+ common_note_for(node).merge(abstract_type(node, parallel: true))
44
+ end
45
+ }
46
+ end
47
+
48
+ def common_note_for(node)
49
+ {
50
+ value: node.content.presence,
51
+ displayLabel: display_label(node),
52
+ type: note_type(node),
53
+ valueAt: node['xlink:href']
54
+ }.tap do |attributes|
55
+ value_language = LanguageScript.build(node: node)
56
+ attributes[:valueLanguage] = value_language if value_language
57
+ if node['ID']
58
+ attributes[:identifier] = [
59
+ {
60
+ value: node['ID'],
61
+ type: 'anchor'
62
+ }
63
+ ]
64
+ end
65
+ end.compact
66
+ end
67
+
68
+ def note_type(node)
69
+ return node['type'].downcase if Cocina::Models::Mapping::ToMods::Note.note_type_to_abstract_type.include?(node['type']&.downcase)
70
+
71
+ node['type']
72
+ end
73
+
74
+ def display_label(node)
75
+ return node[:displayLabel].capitalize if Cocina::Models::Mapping::ToMods::Note.display_label_to_abstract_type.include? node[:displayLabel]
76
+
77
+ node[:displayLabel].presence
78
+ end
79
+
80
+ def abstract_type(node, parallel: false)
81
+ if node['type'].present?
82
+ { type: node['type'].downcase }
83
+ elsif Cocina::Models::Mapping::ToMods::Note.display_label_to_abstract_type.exclude?(node['displayLabel']) && !parallel
84
+ { type: 'abstract' }
85
+ else
86
+ {}
87
+ end
88
+ end
89
+
90
+ def notes
91
+ all_note_nodes = resource_element.xpath('mods:note', mods: Description::DESC_METADATA_NS).select do |node|
92
+ note_present?(node) && node[:type] != 'contact'
93
+ end
94
+ altrepgroup_note_nodes, other_note_nodes = AltRepGroup.split(nodes: all_note_nodes)
95
+ other_note_nodes.map { |node| common_note_for(node) } + \
96
+ altrepgroup_note_nodes.map { |parallel_nodes| parallel_note_for(parallel_nodes) }
97
+ end
98
+
99
+ def note_present?(node)
100
+ node.text.present? || node['xlink:href']
101
+ end
102
+
103
+ def parallel_note_for(note_nodes)
104
+ {
105
+ parallelValue: note_nodes.map { |note_node| common_note_for(note_node) }
106
+ }
107
+ end
108
+
109
+ def target_audience
110
+ resource_element.xpath('mods:targetAudience', mods: Description::DESC_METADATA_NS).filter_map do |node|
111
+ {
112
+ type: 'target audience',
113
+ value: node.content,
114
+ displayLabel: node['displayLabel']
115
+ }.tap do |attrs|
116
+ attrs[:source] = { code: node[:authority] } if node[:authority]
117
+ end.compact
118
+ end
119
+ end
120
+
121
+ def table_of_contents
122
+ all_toc_nodes = resource_element.xpath('mods:tableOfContents', mods: Description::DESC_METADATA_NS).select do |node|
123
+ note_present?(node)
124
+ end
125
+ altrepgroup_toc_nodes, other_toc_nodes = AltRepGroup.split(nodes: all_toc_nodes)
126
+ other_toc_nodes.map { |node| toc_for(node).merge({ type: 'table of contents' }) } + \
127
+ altrepgroup_toc_nodes.map { |parallel_nodes| parallel_toc_for(parallel_nodes) }
128
+ end
129
+
130
+ def parallel_toc_for(toc_nodes)
131
+ {
132
+ type: 'table of contents',
133
+ parallelValue: toc_nodes.map { |toc_node| toc_for(toc_node) }
134
+ }
135
+ end
136
+
137
+ def toc_for(node)
138
+ {
139
+ displayLabel: node[:displayLabel].presence,
140
+ valueAt: node['xlink:href']
141
+ }.tap do |attributes|
142
+ value_language = LanguageScript.build(node: node)
143
+ attributes[:valueLanguage] = value_language if value_language
144
+ value_parts = node.content.split(' -- ')
145
+ if value_parts.size == 1
146
+ attributes[:value] = node.content
147
+ elsif value_parts.present?
148
+ attributes[:structuredValue] = value_parts.map { |value_part| { value: value_part } }
149
+ end
150
+ end.compact
151
+ end
152
+
153
+ def parts
154
+ resource_element.xpath('mods:part', mods: Description::DESC_METADATA_NS).filter_map do |part_node|
155
+ PartBuilder.build(part_element: part_node)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps parts
8
+ class PartBuilder # rubocop:disable Metrics/ClassLength
9
+ # @param [Nokogiri::XML::Element] part_element
10
+ # @return [Hash] a hash that can be mapped to a cocina model
11
+ def self.build(part_element:)
12
+ new(part_element: part_element).build
13
+ end
14
+
15
+ def initialize(part_element:)
16
+ @part_element = part_element
17
+ end
18
+
19
+ def build
20
+ structured_value? ? structured_value : grouped_value
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :part_element
26
+
27
+ def structured_value?
28
+ part_element.xpath('mods:detail[@type]', mods: Description::DESC_METADATA_NS).size > 1
29
+ end
30
+
31
+ def grouped_value
32
+ values = []
33
+ values.concat(detail_values.flatten)
34
+ values.concat(extent_values.flatten)
35
+ values.concat(part_note_value_for(part_element, 'text'))
36
+ values.concat(part_note_value_for(part_element, 'date'))
37
+ values.reject!(&:blank?)
38
+
39
+ return if values.empty?
40
+
41
+ {
42
+ type: 'part',
43
+ groupedValue: values
44
+ }
45
+ end
46
+
47
+ def structured_value
48
+ values = []
49
+ values.concat(detail_values)
50
+ values.concat(extent_values)
51
+ values.concat(part_note_value_for(part_element, 'text'))
52
+ values.concat(part_note_value_for(part_element, 'date'))
53
+ values.reject!(&:blank?)
54
+
55
+ return if values.empty?
56
+
57
+ {
58
+ type: 'part',
59
+ structuredValue: values.filter_map { |value| structured_value_value_for(value) }
60
+ }
61
+ end
62
+
63
+ def structured_value_value_for(value)
64
+ if value.is_a?(Hash)
65
+ value
66
+ elsif value.empty? # else an array
67
+ nil
68
+ else
69
+ {
70
+ groupedValue: value
71
+ }
72
+ end
73
+ end
74
+
75
+ def detail_values
76
+ part_element.xpath('mods:detail', mods: Description::DESC_METADATA_NS).filter_map do |detail_node|
77
+ detail_values_for(detail_node)
78
+ end
79
+ end
80
+
81
+ def detail_values_for(detail_node)
82
+ detail_values = []
83
+ detail_values.concat(part_note_value_for(detail_node, 'number'))
84
+ detail_values.concat(part_note_value_for(detail_node, 'caption'))
85
+ detail_values.concat(part_note_value_for(detail_node, 'title'))
86
+ detail_values.reject!(&:blank?)
87
+ if detail_values.present?
88
+ detail_values.concat(part_note_value_for(detail_node, 'detail type',
89
+ xpath: '@type'))
90
+ end
91
+ detail_values
92
+ end
93
+
94
+ def extent_values
95
+ part_element.xpath('mods:extent', mods: Description::DESC_METADATA_NS).filter_map do |extent_node|
96
+ extent_values_for(extent_node)
97
+ end
98
+ end
99
+
100
+ def extent_values_for(extent_node)
101
+ extent_values = []
102
+ extent_values.concat(part_note_value_for(extent_node, 'list'))
103
+ extent_values << pages_for(extent_node)
104
+ extent_values.reject!(&:blank?)
105
+ if extent_values.present?
106
+ extent_values.concat(part_note_value_for(extent_node, 'extent unit',
107
+ xpath: '@unit'))
108
+ end
109
+ extent_values
110
+ end
111
+
112
+ def pages_for(extent_node)
113
+ values = []
114
+ values << page_value_for(extent_node, 'start')
115
+ values << page_value_for(extent_node, 'end')
116
+ values.compact!
117
+
118
+ return nil if values.empty?
119
+
120
+ {
121
+ structuredValue: values
122
+ }
123
+ end
124
+
125
+ def page_value_for(extent_node, type)
126
+ page_node = extent_node.xpath("mods:#{type}", mods: Description::DESC_METADATA_NS).first
127
+ return nil if page_node.nil?
128
+
129
+ {
130
+ value: page_node.content,
131
+ type: type
132
+ }
133
+ end
134
+
135
+ def part_note_value_for(node, type, xpath: nil)
136
+ xpath ||= "mods:#{type}"
137
+ node.xpath(xpath, mods: Description::DESC_METADATA_NS).filter_map do |value_node|
138
+ next if value_node.content.blank?
139
+
140
+ { type: type, value: value_node.content }
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Helper class: checks and fixes status: primary
8
+ class Primary
9
+ # @params [Nokogiri::XML::NodeSet] node_set
10
+ # @params [String] type the value of a node's type attribute we are concerned with
11
+ def self.adjust(node_set, type, notifier, match_type: false)
12
+ primary_node_set = node_set.select do |node|
13
+ node[:status] == 'primary' && (!match_type || node[:type] == type)
14
+ end
15
+
16
+ return node_set if primary_node_set.size < 2
17
+
18
+ primary_node_set[1..].each { |node| node.delete(:status) }
19
+
20
+ notifier.warn('Multiple marked as primary', { type: type })
21
+ node_set
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Support for mapping PURLs.
8
+ class Purl
9
+ def self.primary_purl_node(resource_element, purl)
10
+ purl_nodes = resource_element.xpath('mods:location/mods:url',
11
+ mods: Description::DESC_METADATA_NS).select do |url_node|
12
+ Cocina::Models::Mapping::Purl.purl?(url_node.text)
13
+ end
14
+
15
+ return purl_nodes.find { |purl_node| purl_value(purl_node) == purl } if purl
16
+
17
+ # Prefer a primary PURL node
18
+ primary_purl_node = purl_nodes.find { |purl_node| purl_node[:usage] == 'primary display' }
19
+
20
+ primary_purl_node || purl_nodes.first
21
+ end
22
+
23
+ def self.purl_note(purl_node)
24
+ notes = []
25
+ if purl_node[:note]
26
+ notes << {
27
+ value: purl_node['note'],
28
+ appliesTo: [{ value: 'purl' }]
29
+ }
30
+ end
31
+ if purl_node['displayLabel']
32
+ notes << {
33
+ value: purl_node['displayLabel'],
34
+ type: 'display label',
35
+ appliesTo: [{ value: 'purl' }]
36
+ }
37
+ end
38
+ notes
39
+ end
40
+
41
+ def self.primary_purl_value(resource_element, purl)
42
+ purl_value(primary_purl_node(resource_element, purl))
43
+ end
44
+
45
+ def self.purl_value(purl_node)
46
+ # Note that normalizing http to https
47
+ purl_node&.content&.sub(/^https?/, 'https')&.presence
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end