cocina-models 0.75.0 → 0.78.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +40 -12
  3. data/.rubocop_todo.yml +71 -2
  4. data/README.md +41 -5
  5. data/cocina-models.gemspec +2 -0
  6. data/description_types.yml +167 -38
  7. data/docs/description_types.md +471 -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 +98 -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 +27 -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/description_values_validator.rb +77 -0
  119. data/lib/cocina/models/validators/open_api_validator.rb +0 -4
  120. data/lib/cocina/models/validators/validator.rb +2 -1
  121. data/lib/cocina/models/version.rb +1 -1
  122. data/lib/cocina/models/world_access.rb +2 -2
  123. data/lib/cocina/models.rb +4 -0
  124. data/lib/cocina/rspec/factories.rb +205 -0
  125. data/lib/cocina/rspec.rb +2 -0
  126. data/openapi.yml +5 -5
  127. metadata +89 -17
  128. data/docs/_config.yml +0 -1
  129. data/docs/maps/Agent.json +0 -18
  130. data/docs/maps/Collection.json +0 -240
  131. data/docs/maps/DRO.json +0 -316
  132. data/docs/maps/Description.json +0 -17
  133. data/docs/maps/File.json +0 -196
  134. data/docs/maps/Fileset.json +0 -143
  135. data/docs/maps/README.md +0 -7
  136. data/docs/maps/ReleaseTag.json +0 -39
  137. data/docs/maps/Sequence.json +0 -46
  138. data/docs/maps/Title.json +0 -18
  139. data/docs/sampleETD/foxml-export.xml +0 -935
  140. data/docs/sampleETD/foxml.xml +0 -3475
  141. data/docs/sampleETD/xn109qc9773_bibframe.ttl +0 -95
  142. data/docs/sampleETD/xn109qc9773_taco.json +0 -158
@@ -0,0 +1,381 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps relevant MODS physicalDescription, typeOfResource and genre from descMetadata to cocina form
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Form
10
+ # NOTE: H2 is the first case of structured form (genre/typeOfResource) values we're implementing
11
+ H2_GENRE_TYPE_PREFIX = 'H2 '
12
+
13
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
14
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder
15
+ # @param [String] purl
16
+ # @return [Hash] a hash that can be mapped to a cocina model
17
+ def self.build(resource_element:, description_builder:, purl: nil)
18
+ new(resource_element: resource_element, description_builder: description_builder).build
19
+ end
20
+
21
+ def initialize(resource_element:, description_builder:)
22
+ @resource_element = resource_element
23
+ @notifier = description_builder.notifier
24
+ end
25
+
26
+ def build
27
+ forms = []
28
+ add_genre(forms)
29
+ add_types(forms)
30
+ add_physical_descriptions(forms)
31
+ add_subject_cartographics(forms)
32
+ Primary.adjust(forms, 'genre', notifier, match_type: true)
33
+ Primary.adjust(forms, 'resource type', notifier, match_type: true)
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :resource_element, :notifier
39
+
40
+ def add_subject_cartographics(forms)
41
+ subject_nodes = resource_element.xpath('mods:subject[mods:cartographics]', mods: Description::DESC_METADATA_NS)
42
+ altrepgroup_subject_nodes, other_subject_nodes = AltRepGroup.split(nodes: subject_nodes)
43
+
44
+ forms.concat(
45
+ altrepgroup_subject_nodes.map do |parallel_subject_nodes|
46
+ build_parallel_cartographics(parallel_subject_nodes)
47
+ end +
48
+ other_subject_nodes.flat_map { |subject_node| build_cartographics(subject_node) }.uniq
49
+ )
50
+ end
51
+
52
+ def build_parallel_cartographics(parallel_subject_nodes)
53
+ {
54
+ parallelValue: parallel_subject_nodes.flat_map { |subject_node| build_cartographics(subject_node) }
55
+ }
56
+ end
57
+
58
+ def build_cartographics(subject_node)
59
+ carto_forms = []
60
+ subject_node.xpath('mods:cartographics[mods:scale]', mods: Description::DESC_METADATA_NS).each do |carto_node|
61
+ scale_nodes = carto_node.xpath('mods:scale', mods: Description::DESC_METADATA_NS).reject do |scale_node|
62
+ scale_node.text.blank?
63
+ end
64
+ if scale_nodes.size == 1
65
+ carto_forms << {
66
+ value: scale_nodes.first.text,
67
+ type: 'map scale'
68
+ }
69
+ elsif scale_nodes.size > 1
70
+ carto_forms << {
71
+ groupedValue: scale_nodes.map { |scale_node| { value: scale_node.text } },
72
+ type: 'map scale'
73
+ }
74
+ end
75
+ end
76
+
77
+ subject_node.xpath('mods:cartographics/mods:projection',
78
+ mods: Description::DESC_METADATA_NS).each do |projection_node|
79
+ next if projection_node.text.blank?
80
+
81
+ carto_forms << {
82
+ value: projection_node.text,
83
+ type: 'map projection',
84
+ displayLabel: subject_node['displayLabel'],
85
+ uri: ValueURI.sniff(subject_node['valueURI'], notifier)
86
+ }.tap do |attrs|
87
+ source = {
88
+ code: subject_node['authority'],
89
+ uri: subject_node['authorityURI']
90
+ }.compact
91
+ attrs[:source] = source if source.present?
92
+ end.compact
93
+ end
94
+ carto_forms.uniq
95
+ end
96
+
97
+ def add_genre(forms)
98
+ add_structured_genre(forms) if structured_genre.any?
99
+
100
+ altrepgroup_genres, other_genre = AltRepGroup.split(nodes: basic_genre)
101
+
102
+ other_genre.each { |genre| forms << { type: 'genre' }.merge(build_genre(genre)) }
103
+ altrepgroup_genres.each { |parallel_genres| forms << build_parallel_genre(parallel_genres) }
104
+ end
105
+
106
+ def build_genre(genre)
107
+ {
108
+ value: genre.text,
109
+ displayLabel: genre[:displayLabel],
110
+ uri: ValueURI.sniff(genre[:valueURI], notifier)
111
+ }.tap do |attrs|
112
+ source = {
113
+ code: Authority.normalize_code(genre[:authority], notifier),
114
+ uri: Authority.normalize_uri(genre[:authorityURI])
115
+ }.compact
116
+ attrs[:source] = source if source.present?
117
+ attrs[:status] = 'primary' if genre['usage'] == 'primary'
118
+ language_script = LanguageScript.build(node: genre)
119
+ attrs[:valueLanguage] = language_script if language_script
120
+ if genre['type']
121
+ attrs[:note] = [
122
+ {
123
+ value: genre['type'],
124
+ type: 'genre type'
125
+ }
126
+ ]
127
+ end
128
+ end.compact
129
+ end
130
+
131
+ def build_parallel_genre(genres)
132
+ {
133
+ parallelValue: genres.map { |genre| build_genre(genre) },
134
+ type: 'genre'
135
+ }
136
+ end
137
+
138
+ def add_structured_genre(forms)
139
+ # The only use case we're supporting for structured forms at the
140
+ # moment is for H2. Assume these are H2 values.
141
+ forms << {
142
+ type: 'resource type',
143
+ source: {
144
+ value: Cocina::Models::Mapping::ToMods::Form::H2_SOURCE_LABEL
145
+ },
146
+ structuredValue: structured_genre.map do |genre|
147
+ {
148
+ value: genre.text,
149
+ type: genre.attributes['type'].value.delete_prefix(H2_GENRE_TYPE_PREFIX)
150
+ }
151
+ end
152
+ }
153
+ end
154
+
155
+ def add_types(forms)
156
+ type_of_resource.each do |type|
157
+ forms << resource_type_form(type) if type.text.present?
158
+
159
+ forms << manuscript_form if type[:manuscript] == 'yes'
160
+
161
+ forms << collection_form if type[:collection] == 'yes'
162
+ end
163
+
164
+ datacite_form = datacite_resource_type
165
+ forms << datacite_form if datacite_form
166
+ end
167
+
168
+ def resource_type_form(type)
169
+ {
170
+ value: type.text,
171
+ type: 'resource type',
172
+ uri: type['valueURI'],
173
+ source: resource_type_form_source(type),
174
+ displayLabel: type[:displayLabel].presence
175
+ }.tap do |attrs|
176
+ attrs[:status] = 'primary' if type['usage'] == 'primary'
177
+ end.compact
178
+ end
179
+
180
+ def resource_type_form_source(type)
181
+ {}.tap do |attrs|
182
+ if type['authorityURI']
183
+ attrs[:uri] = type['authorityURI']
184
+ else
185
+ attrs[:value] = 'MODS resource types'
186
+ end
187
+ end
188
+ end
189
+
190
+ def manuscript_form
191
+ {
192
+ value: 'manuscript',
193
+ source: {
194
+ value: 'MODS resource types'
195
+ }
196
+ }
197
+ end
198
+
199
+ def collection_form
200
+ {
201
+ value: 'collection',
202
+ source: {
203
+ value: 'MODS resource types'
204
+ }
205
+ }
206
+ end
207
+
208
+ def add_physical_descriptions(forms)
209
+ altrepgroup_physical_descr_nodes, other_physical_descr_nodes = AltRepGroup.split(nodes: physical_descriptions)
210
+ other_physical_descr_nodes.each do |physical_description_node|
211
+ forms.concat(physical_description_forms_for(physical_description_node))
212
+ end
213
+
214
+ altrepgroup_physical_descr_nodes.each do |altrepgroup_physical_descr_node_group|
215
+ form = { parallelValue: [] }
216
+ altrepgroup_physical_descr_node_group.each do |physical_descr_node|
217
+ form[:parallelValue].concat(physical_description_forms_for(physical_descr_node))
218
+ end
219
+ adjust_parallel_value(form, :displayLabel)
220
+ adjust_parallel_value(form, :type)
221
+ adjust_parallel_value(form, :source)
222
+ forms << form
223
+ end
224
+ end
225
+
226
+ def adjust_parallel_value(form, key)
227
+ return unless form[:parallelValue].all? do |form_value|
228
+ form_value[key] && form_value[key] == form[:parallelValue].first[key]
229
+ end
230
+
231
+ form[key] = form[:parallelValue].first[key]
232
+ form[:parallelValue].each { |form_value| form_value.delete(key) }
233
+ end
234
+
235
+ def physical_description_forms_for(physical_description_node)
236
+ form_values = physical_description_form_values_for(physical_description_node)
237
+ notes = physical_description_notes_for(physical_description_node)
238
+ # Depends on how many physicalDescriptions there are or if there is a displayLabel
239
+ forms = []
240
+ if physical_descriptions.size == 1 && form_values.size > 1 && physical_description_node['displayLabel'].nil?
241
+ forms.concat(form_values)
242
+ forms << { note: notes } if notes.present?
243
+ elsif form_values.size == 1
244
+ if form_values.first[:note]&.first&.fetch(:type) == 'unit'
245
+ forms << form_values.first.compact
246
+ forms << { note: notes } if notes.present?
247
+ else
248
+ forms << form_values.first.merge({
249
+ note: notes.presence,
250
+ displayLabel: physical_description_node['displayLabel']
251
+ }.compact)
252
+ end
253
+ else
254
+ forms << {
255
+ groupedValue: form_values,
256
+ note: notes.presence,
257
+ displayLabel: physical_description_node['displayLabel']
258
+ }.compact
259
+ end
260
+ forms
261
+ end
262
+
263
+ def physical_description_form_values_for(physical_description_node)
264
+ form_values = []
265
+ add_forms(form_values, physical_description_node)
266
+ add_reformatting_quality(form_values, physical_description_node)
267
+ add_media_type(form_values, physical_description_node)
268
+ add_extent(form_values, physical_description_node)
269
+ add_digital_origin(form_values, physical_description_node)
270
+ form_values
271
+ end
272
+
273
+ def physical_description_notes_for(physical_description)
274
+ physical_description.xpath('mods:note', mods: Description::DESC_METADATA_NS).filter_map do |node|
275
+ next if node.content.blank?
276
+
277
+ {
278
+ value: node.content,
279
+ displayLabel: node['displayLabel'],
280
+ type: node['type']
281
+ }.compact
282
+ end
283
+ end
284
+
285
+ def add_digital_origin(forms, physical_description)
286
+ physical_description.xpath('mods:digitalOrigin', mods: Description::DESC_METADATA_NS).each do |node|
287
+ forms << {
288
+ value: node.content,
289
+ type: 'digital origin',
290
+ source: { value: 'MODS digital origin terms' }
291
+ }.compact
292
+ end
293
+ end
294
+
295
+ def add_extent(forms, physical_description)
296
+ physical_description.xpath('mods:extent', mods: Description::DESC_METADATA_NS).each do |extent|
297
+ forms << {
298
+ value: extent.content,
299
+ type: 'extent'
300
+ }.tap do |form_attrs|
301
+ form_attrs[:note] = [{ type: 'unit', value: extent['unit'] }] if extent['unit']
302
+ end
303
+ end
304
+ end
305
+
306
+ def add_media_type(forms, physical_description)
307
+ physical_description.xpath('mods:internetMediaType', mods: Description::DESC_METADATA_NS).each do |node|
308
+ forms << {
309
+ value: node.content,
310
+ type: 'media type',
311
+ source: { value: 'IANA media types' }
312
+ }.compact
313
+ end
314
+ end
315
+
316
+ def add_reformatting_quality(forms, physical_description)
317
+ physical_description.xpath('mods:reformattingQuality', mods: Description::DESC_METADATA_NS).each do |node|
318
+ forms << {
319
+ value: node.content,
320
+ type: 'reformatting quality',
321
+ source: { value: 'MODS reformatting quality terms' }
322
+ }.compact
323
+ end
324
+ end
325
+
326
+ def add_forms(forms, physical_description)
327
+ physical_description.xpath('mods:form', mods: Description::DESC_METADATA_NS).each do |form_content|
328
+ forms << {
329
+ value: form_content.content,
330
+ uri: ValueURI.sniff(form_content['valueURI'], notifier),
331
+ type: form_content['type'] || 'form',
332
+ source: source_for(form_content).presence
333
+ }.compact
334
+ end
335
+ end
336
+
337
+ def physical_descriptions
338
+ resource_element.xpath('mods:physicalDescription', mods: Description::DESC_METADATA_NS)
339
+ end
340
+
341
+ def source_for(form)
342
+ {
343
+ code: Authority.normalize_code(form['authority'], notifier),
344
+ uri: Authority.normalize_uri(form['authorityURI'])
345
+ }.compact
346
+ end
347
+
348
+ def type_of_resource
349
+ resource_element.xpath('mods:typeOfResource', mods: Description::DESC_METADATA_NS)
350
+ end
351
+
352
+ def datacite_resource_type
353
+ node = resource_element.xpath('mods:extension[@displayLabel="datacite"]/mods:resourceType',
354
+ mods: Description::DESC_METADATA_NS).first
355
+ return unless node
356
+
357
+ { value: node[:resourceTypeGeneral], type: 'resource type',
358
+ source: { value: 'DataCite resource types' } }
359
+ end
360
+
361
+ # returns genre at the root and inside subjects excluding structured genres
362
+ def basic_genre
363
+ resource_element.xpath("mods:genre[not(@type) or not(starts-with(@type, '#{H2_GENRE_TYPE_PREFIX}'))]",
364
+ mods: Description::DESC_METADATA_NS)
365
+ end
366
+
367
+ def subject_genre
368
+ resource_element.xpath('mods:subject/mods:genre', mods: Description::DESC_METADATA_NS)
369
+ end
370
+
371
+ # returns structured genres at the root and inside subjects, which are combined to form a single, structured Cocina element
372
+ def structured_genre
373
+ resource_element.xpath("mods:genre[@type and starts-with(@type, '#{H2_GENRE_TYPE_PREFIX}')]",
374
+ mods: Description::DESC_METADATA_NS)
375
+ end
376
+ end
377
+ # rubocop:enable Metrics/ClassLength
378
+ end
379
+ end
380
+ end
381
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps MODS extension displayLabel geo to cocina descriptive extension
8
+ # rubocop:disable Metrics/ClassLength
9
+ class Geographic
10
+ DUBLIN_CORE_NS = 'http://purl.org/dc/elements/1.1/'
11
+ RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'
12
+ GMD_NS = 'http://www.isotc211.org/2005/gmd'
13
+ GML_NS = 'http://www.opengis.net/gml/3.2/'
14
+
15
+ NAMESPACE = {
16
+ 'mods' => Description::DESC_METADATA_NS,
17
+ 'dc' => DUBLIN_CORE_NS,
18
+ 'rdf' => RDF_NS,
19
+ 'gmd' => GMD_NS,
20
+ 'gml' => GML_NS
21
+ }.freeze
22
+
23
+ # Directional Constants for GEO
24
+ SOUTH = 'south'
25
+ WEST = 'west'
26
+ NORTH = 'north'
27
+ EAST = 'east'
28
+
29
+ # Geo Extention Constants
30
+ BOUNDING_BOX_COORDS = 'bounding box coordinates'
31
+ COVERAGE = 'coverage'
32
+ DATA_FORMAT = 'data format'
33
+ DCMI_VOCAB = { value: 'DCMI Type Vocabulary' }.freeze
34
+ DECIMAL_ENCODING = { value: 'decimal' }.freeze
35
+ FORMAT_DELIM = '; format='
36
+ IANA_TERMS = { value: 'IANA media type terms' }.freeze
37
+ LANGUAGE = { code: 'eng' }.freeze
38
+ MEDIA_TYPE = 'media type'
39
+ POINT_COORDS = 'point coordinates'
40
+ TYPE = 'type'
41
+
42
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
43
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder
44
+ # @param [String] purl
45
+ # @return [Hash] a hash that can be mapped to a cocina model
46
+ def self.build(resource_element:, description_builder:, purl: nil)
47
+ new(resource_element: resource_element, description_builder: description_builder).build
48
+ end
49
+
50
+ def initialize(resource_element:, description_builder:)
51
+ @resource_element = resource_element
52
+ @notifier = description_builder.notifier
53
+ end
54
+
55
+ def build
56
+ return unless description
57
+
58
+ check_purl
59
+
60
+ [{}.tap do |extension|
61
+ extension[:form] = build_form.flatten if build_form
62
+ extension[:subject] = build_subject
63
+ end.compact]
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :resource_element, :notifier
69
+
70
+ def build_form
71
+ return unless format
72
+
73
+ [].tap do |form|
74
+ form << { value: format[:text], type: MEDIA_TYPE, source: IANA_TERMS } if format[:text]
75
+ form << build_type
76
+ end
77
+ end
78
+
79
+ def build_type
80
+ type_section =
81
+ if type == 'Image'
82
+ { value: type, type: MEDIA_TYPE, source: DCMI_VOCAB }
83
+ else
84
+ { value: type, type: TYPE }
85
+ end
86
+
87
+ if format[:format].present?
88
+ [{ value: format[:format], type: DATA_FORMAT }, type_section]
89
+ else
90
+ type_section
91
+ end
92
+ end
93
+
94
+ def build_subject
95
+ return [build_subject_for_center_point] unless centerpoint.empty?
96
+ return build_subject_for_bounding_box unless envelope.empty?
97
+ end
98
+
99
+ def build_subject_for_center_point
100
+ {
101
+ structuredValue: [centerpoint_latitude, centerpoint_longitude],
102
+ type: POINT_COORDS,
103
+ encoding: DECIMAL_ENCODING
104
+ }
105
+ end
106
+
107
+ def build_subject_for_bounding_box
108
+ [].tap do |subject|
109
+ subject << structure_map
110
+ coverage_map.map { |block| subject << block } unless coverage.empty?
111
+ end
112
+ end
113
+
114
+ def structure_map
115
+ {}.tap do |structure|
116
+ structure[:structuredValue] = bounding_box_coordinates
117
+ structure[:type] = BOUNDING_BOX_COORDS
118
+ structure[:encoding] = DECIMAL_ENCODING
119
+ structure[:standard] = { code: standard } if standard
120
+ end
121
+ end
122
+
123
+ def coverage_map
124
+ coverage.map do |data|
125
+ title = data.attr('dc:title')
126
+ uri = data.attr('rdf:resource')
127
+ {}.tap do |coverage_for|
128
+ coverage_for[:value] = title if title
129
+ coverage_for[:type] = COVERAGE
130
+ coverage_for[:valueLanguage] = LANGUAGE
131
+ coverage_for[:uri] = uri if uri.present?
132
+ end
133
+ end
134
+ end
135
+
136
+ def bounding_box_coordinates
137
+ [WEST, SOUTH, EAST, NORTH].map { |dir| boundary_position(dir) }
138
+ end
139
+
140
+ def description
141
+ @description ||= resource_element.xpath('mods:extension/rdf:RDF/rdf:Description', NAMESPACE).first
142
+ end
143
+
144
+ def centerpoint
145
+ description.xpath('//gmd:centerPoint/gml:Point/gml:pos', NAMESPACE).text.split
146
+ end
147
+
148
+ def centerpoint_latitude
149
+ { value: centerpoint.first, type: 'latitude' }
150
+ end
151
+
152
+ def centerpoint_longitude
153
+ { value: centerpoint.last, type: 'longitude' }
154
+ end
155
+
156
+ def coverage
157
+ description.xpath('//dc:coverage', NAMESPACE)
158
+ end
159
+
160
+ def envelope
161
+ description.xpath('//gml:boundedBy/gml:Envelope', NAMESPACE)
162
+ end
163
+
164
+ def format
165
+ return unless description
166
+
167
+ text, format = description.xpath('//dc:format', NAMESPACE).text.split(FORMAT_DELIM)
168
+ @format ||= { text: text, format: format }
169
+ end
170
+
171
+ def lower_left_corner
172
+ envelope.xpath('//gml:lowerCorner', NAMESPACE).text.split
173
+ end
174
+
175
+ def upper_right_corner
176
+ envelope.xpath('//gml:upperCorner', NAMESPACE).text.split
177
+ end
178
+
179
+ def boundaries
180
+ {
181
+ SOUTH => lower_left_corner.last,
182
+ WEST => lower_left_corner.first,
183
+ NORTH => upper_right_corner.last,
184
+ EAST => upper_right_corner.first
185
+ }
186
+ end
187
+
188
+ def boundary_position(direction)
189
+ { value: boundaries[direction], type: direction }
190
+ end
191
+
192
+ def standard
193
+ envelope.attr('srsName')&.value
194
+ end
195
+
196
+ def type
197
+ @type ||= normalize_type_text(description.xpath('//dc:type', NAMESPACE).text)
198
+ end
199
+
200
+ def normalize_type_text(text)
201
+ if text.casecmp('image').zero? && text != 'Image'
202
+ notifier.warn('dc:type normalized to <dc:type>Image</dc:type>', type: text)
203
+ 'Image'
204
+ else
205
+ text
206
+ end
207
+ end
208
+
209
+ def check_purl
210
+ return if %r{^https?://purl.stanford.edu/}.match?(description['rdf:about'])
211
+
212
+ notifier.warn('rdf:about does not contain a correctly formatted PURL')
213
+ end
214
+ end
215
+ # rubocop:enable Metrics/ClassLength
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps titles
8
+ class HydrusDefaultTitleBuilder
9
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
10
+ # @param [Cocina::Models::Mapping::ErrorNotifier] notifier
11
+ # @return [Hash] a hash that can be mapped to a cocina model
12
+ def self.build(resource_element:, notifier:, require_title: nil)
13
+ titles = resource_element.xpath('mods:titleInfo/mods:title[string-length() > 0]',
14
+ mods: Description::DESC_METADATA_NS)
15
+
16
+ if titles.empty?
17
+ return [{ value: 'Hydrus' }] if resource_element.name != 'relatedItem'
18
+
19
+ return []
20
+ end
21
+
22
+ Title.build(resource_element: resource_element, notifier: notifier)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Mapping
6
+ module FromMods
7
+ # Maps MODS identifer to cocina identifier
8
+ class Identifier
9
+ # @param [Nokogiri::XML::Element] resource_element mods or relatedItem element
10
+ # @param [Cocina::Models::Mapping::FromMods::DescriptionBuilder] description_builder
11
+ # @param [String] purl
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
+ new(resource_element: resource_element).build
15
+ end
16
+
17
+ def initialize(resource_element:)
18
+ @resource_element = resource_element
19
+ end
20
+
21
+ def build
22
+ altrepgroup_identifier_nodes, other_identifier_nodes = AltRepGroup.split(nodes: identifiers)
23
+
24
+ altrepgroup_identifier_nodes.map { |id_nodes| build_parallel(id_nodes) } +
25
+ other_identifier_nodes.map do |id_node|
26
+ IdentifierBuilder.build_from_identifier(identifier_element: id_node)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :resource_element
33
+
34
+ def build_parallel(identifier_nodes)
35
+ {
36
+ parallelValue: identifier_nodes.map do |id_node|
37
+ IdentifierBuilder.build_from_identifier(identifier_element: id_node)
38
+ end
39
+ }
40
+ end
41
+
42
+ def identifiers
43
+ (resource_element.xpath('mods:identifier', mods: Description::DESC_METADATA_NS) +
44
+ resource_element.xpath('mods:recordIdentifier',
45
+ mods: Description::DESC_METADATA_NS)).reject { |identifier_node| identifier_node.text.blank? && identifier_node.attributes.size.zero? }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end