cocina-models 0.92.0 → 0.93.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fb90197036280ad978dc4e59b548fcb954d7b602799294cfdde8bd3ef2a1568
4
- data.tar.gz: fd7c2c49522772f866dc04d629296c537f88d88dd8943ce4b74bb77460be31d9
3
+ metadata.gz: 8cd049152e51891b2ab7fc1bd63fff3f95f8914f9939122ffb6990667f17ad98
4
+ data.tar.gz: 59645a00ccb0d8b21ae4830563f037dba0655a85d4f1b268ba21da66e6406d43
5
5
  SHA512:
6
- metadata.gz: 84a09b86f9f922ba9b8c433760fe3613035d72cba102f5a93dc9e7ec07a4d7bfdef5f449375fa4792eb0b8045f3324f23360183b0f9b5aa8ea21082336681b22
7
- data.tar.gz: 93b59a61650aae4c19cab66d84a216f66f65a289444e58fc48fdbd9088d33dd083fe9c57f57971574dae13a0b21ab4453ee48f8bad14f408ee3149534eb2aa99
6
+ metadata.gz: df8635b1b7318cd957f2071aac2e46cce144119d5c497d166d2e0152db0269fdccd611ea72872e80217a9e1e07ed354393add1e440cc580f86e8493953684767
7
+ data.tar.gz: f12b79669c54e657aa0956412227a18f7445ba4940d4a6a5d27f9d6d9a79fe72aa408df096114e636ff8bd99d76bdfeb99f017efef989f12a27ef891bb4a9033
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cocina-models (0.92.0)
4
+ cocina-models (0.93.1)
5
5
  activesupport
6
6
  deprecation
7
7
  dry-struct (~> 1.0)
@@ -21,7 +21,7 @@ PATH
21
21
  GEM
22
22
  remote: https://rubygems.org/
23
23
  specs:
24
- activesupport (7.1.1)
24
+ activesupport (7.1.2)
25
25
  base64
26
26
  bigdecimal
27
27
  concurrent-ruby (~> 1.0, >= 1.0.2)
@@ -33,8 +33,8 @@ GEM
33
33
  tzinfo (~> 2.0)
34
34
  ast (2.4.2)
35
35
  attr_extras (7.1.0)
36
- base64 (0.1.1)
37
- bigdecimal (3.1.4)
36
+ base64 (0.2.0)
37
+ bigdecimal (3.1.5)
38
38
  byebug (11.1.3)
39
39
  committee (5.0.0)
40
40
  json_schema (~> 0.14, >= 0.14.3)
@@ -47,7 +47,7 @@ GEM
47
47
  activesupport
48
48
  diff-lcs (1.5.0)
49
49
  docile (1.4.0)
50
- drb (2.1.1)
50
+ drb (2.2.0)
51
51
  ruby2_keywords
52
52
  dry-core (1.0.1)
53
53
  concurrent-ruby (~> 1.0)
@@ -75,7 +75,7 @@ GEM
75
75
  i18n (1.14.1)
76
76
  concurrent-ruby (~> 1.0)
77
77
  ice_nine (0.11.2)
78
- json (2.6.3)
78
+ json (2.7.1)
79
79
  json_schema (0.21.0)
80
80
  jsonpath (1.1.5)
81
81
  multi_json
@@ -83,25 +83,25 @@ GEM
83
83
  mini_portile2 (2.8.5)
84
84
  minitest (5.20.0)
85
85
  multi_json (1.15.0)
86
- mutex_m (0.1.2)
87
- nokogiri (1.15.4)
86
+ mutex_m (0.2.0)
87
+ nokogiri (1.16.0)
88
88
  mini_portile2 (~> 2.8.2)
89
89
  racc (~> 1.4)
90
90
  openapi3_parser (0.9.2)
91
91
  commonmarker (~> 0.17)
92
92
  openapi_parser (1.0.0)
93
93
  optimist (3.1.0)
94
- parallel (1.23.0)
94
+ parallel (1.24.0)
95
95
  parser (3.2.2.4)
96
96
  ast (~> 2.4.1)
97
97
  racc
98
98
  patience_diff (1.2.0)
99
99
  optimist (~> 3.0)
100
- racc (1.7.1)
100
+ racc (1.7.3)
101
101
  rack (3.0.8)
102
102
  rainbow (3.1.1)
103
103
  rake (13.1.0)
104
- regexp_parser (2.8.2)
104
+ regexp_parser (2.8.3)
105
105
  rexml (3.2.6)
106
106
  rspec (3.12.0)
107
107
  rspec-core (~> 3.12.0)
@@ -120,7 +120,7 @@ GEM
120
120
  rspec-core (>= 2, < 4, != 2.12.0)
121
121
  rss (0.3.0)
122
122
  rexml
123
- rubocop (1.57.2)
123
+ rubocop (1.59.0)
124
124
  json (~> 2.3)
125
125
  language_server-protocol (>= 3.17.0)
126
126
  parallel (~> 1.10)
@@ -128,7 +128,7 @@ GEM
128
128
  rainbow (>= 2.2.2, < 4.0)
129
129
  regexp_parser (>= 1.8, < 3.0)
130
130
  rexml (>= 3.2.5, < 4.0)
131
- rubocop-ast (>= 1.28.1, < 2.0)
131
+ rubocop-ast (>= 1.30.0, < 2.0)
132
132
  ruby-progressbar (~> 1.7)
133
133
  unicode-display_width (>= 2.4.0, < 3.0)
134
134
  rubocop-ast (1.30.0)
data/README.md CHANGED
@@ -12,6 +12,8 @@ The data model is expressed in an OpenAPI specification that lives in this codeb
12
12
 
13
13
  Note that the data model encodes properties as camelCase, which the team believes to be consistent with other HTTP APIs and the original design of the Cocina data model. While using camelCase in Ruby code may look and feel wrong, we did explore automagic conversion between camelCase in the model and snake_case in the Ruby context. We ultimately concluded that we have enough representations of the data model in enough codebases to reasonably worry about data inconsistency problems, none of which we need in our work on SDR.
14
14
 
15
+ For more about the model for description see https://consul.stanford.edu/display/DIGMETADATA/Digital+Object+Metadata+Documentation#DigitalObjectMetadataDocumentation-Cocinamodel
16
+
15
17
  ## Configuration
16
18
 
17
19
  Set the PURL url base:
@@ -6,8 +6,7 @@ module Cocina
6
6
  module Models
7
7
  module Builders
8
8
  # TitleBuilder selects the prefered title from the cocina object for solr indexing
9
- # rubocop:disable Metrics/ClassLength
10
- class TitleBuilder
9
+ class TitleBuilder # rubocop:disable Metrics/ClassLength
11
10
  extend Deprecation
12
11
  # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
13
12
  # @param [Symbol] strategy ":first" is the strategy for selection when primary or display
@@ -24,24 +23,57 @@ module Cocina
24
23
  new(strategy: strategy, add_punctuation: add_punctuation).build(titles)
25
24
  end
26
25
 
26
+ # the "main title" is the title withOUT subtitle, part name, etc. We want to index it separately so
27
+ # we can boost matches on it in search results (boost matching this string higher than matching full title string)
28
+ # e.g. "The Hobbit" (main_title) vs "The Hobbit, or, There and Back Again (full_title)
29
+ # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
30
+ # @return [String] the main title value for Solr
31
+ def self.main_title(titles)
32
+ new(strategy: :first, add_punctuation: false).main_title(titles)
33
+ end
34
+
35
+ # the "full title" is the title WITH subtitle, part name, etc. We want to able able to index it separately so
36
+ # we can boost matches on it in search results (boost matching this string higher than other titles present)
37
+ # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
38
+ # @return [String] the title value for Solr
39
+ def self.full_title(titles)
40
+ new(strategy: :first, add_punctuation: false).build(titles)
41
+ end
42
+
43
+ # "additional titles" are all title data except for full_title. We want to able able to index it separately so
44
+ # we can boost matches on it in search results (boost matching these strings lower than other titles present)
45
+ # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
46
+ # @return [Array<String>] the values for Solr
47
+ def self.additional_titles(titles)
48
+ new(strategy: :all, add_punctuation: false).build(titles) - [full_title(titles)]
49
+ end
50
+
27
51
  def initialize(strategy:, add_punctuation:)
28
52
  @strategy = strategy
29
53
  @add_punctuation = add_punctuation
30
54
  end
31
55
 
32
- # @param [[Array<Cocina::Models::Title>] titles the titles to consider
56
+ # @param [[Array<Cocina::Models::Title>] cocina_titles the titles to consider
33
57
  # @return [String] the title value for Solr
34
- def build(titles)
35
- cocina_title = primary_title(titles) || untyped_title(titles)
36
- cocina_title = other_title(titles) if cocina_title.blank?
58
+ def build(cocina_titles)
59
+ cocina_title = primary_title(cocina_titles) || untyped_title(cocina_titles)
60
+ cocina_title = other_title(cocina_titles) if cocina_title.blank?
37
61
 
38
62
  if strategy == :first
39
63
  extract_title(cocina_title)
40
64
  else
41
- cocina_title.map { |one| extract_title(one) }
65
+ cocina_titles.map { |ctitle| extract_title(ctitle) }.flatten
42
66
  end
43
67
  end
44
68
 
69
+ def main_title(titles)
70
+ cocina_title = primary_title(titles) || untyped_title(titles)
71
+ cocina_title = other_title(titles) if cocina_title.blank?
72
+
73
+ cocina_title = cocina_title.first if cocina_title.is_a?(Array)
74
+ extract_main_title(cocina_title)
75
+ end
76
+
45
77
  private
46
78
 
47
79
  attr_reader :strategy
@@ -57,6 +89,16 @@ module Cocina
57
89
  remove_trailing_punctuation(result.strip) if result.present?
58
90
  end
59
91
 
92
+ def extract_main_title(cocina_title)
93
+ if cocina_title.value
94
+ cocina_title.value # covers both title and main title types
95
+ elsif cocina_title.structuredValue.present?
96
+ main_title_from_structured_values(cocina_title)
97
+ elsif cocina_title.parallelValue.present?
98
+ main_title(cocina_title.parallelValue)
99
+ end
100
+ end
101
+
60
102
  def add_punctuation?
61
103
  @add_punctuation
62
104
  end
@@ -93,7 +135,8 @@ module Cocina
93
135
  end
94
136
  end
95
137
 
96
- # This handles 'main title', 'uniform' or 'translated'
138
+ # This is called when there is no primary title and no untyped title
139
+ # @return [Cocina::Models::Title, Array<Cocina::Models::Title>] first title or all titles
97
140
  def other_title(titles)
98
141
  if strategy == :first
99
142
  titles.first
@@ -102,17 +145,19 @@ module Cocina
102
145
  end
103
146
  end
104
147
 
105
- # rubocop:disable Metrics/BlockLength
106
- # rubocop:disable Metrics/CyclomaticComplexity
107
- # rubocop:disable Metrics/PerceivedComplexity
108
- # rubocop:disable Metrics/MethodLength
109
148
  # @param [Cocina::Models::Title] title with structured values
110
149
  # @return [String] the title value from combining the pieces of the structured_values by type and order
111
150
  # with desired punctuation per specs
151
+ #
152
+ # rubocop:disable Metrics/CyclomaticComplexity
153
+ # rubocop:disable Metrics/MethodLength
154
+ # rubocop:disable Metrics/PerceivedComplexity
112
155
  def title_from_structured_values(title)
113
- structured_title = ''
156
+ # parse out the parts
157
+ main_title = ''
158
+ subtitle = ''
159
+ non_sort_value = ''
114
160
  part_name_number = ''
115
- # combine pieces of the cocina structuredValue into a single title
116
161
  title.structuredValue.each do |structured_value|
117
162
  # There can be a structuredValue inside a structuredValue. For example,
118
163
  # a uniform title where both the name and the title have internal StructuredValue
@@ -125,42 +170,88 @@ module Cocina
125
170
  case structured_value.type&.downcase
126
171
  when 'nonsorting characters'
127
172
  non_sort_value = "#{value}#{non_sorting_padding(title, value)}"
128
- structured_title = if structured_title.present?
129
- "#{structured_title}#{non_sort_value}"
130
- else
131
- non_sort_value
132
- end
133
173
  when 'part name', 'part number'
134
- if part_name_number.blank?
135
- part_name_number = part_name_number(title.structuredValue)
136
- structured_title = if !add_punctuation?
137
- [structured_title, part_name_number].join(' ')
138
- elsif structured_title.present?
139
- "#{structured_title.sub(/[ .,]*$/, '')}. #{part_name_number}. "
140
- else
141
- "#{part_name_number}. "
142
- end
143
- end
174
+ part_name_number = part_name_number(title.structuredValue) if part_name_number.blank?
144
175
  when 'main title', 'title'
145
- structured_title = "#{structured_title}#{value}"
176
+ main_title = value
146
177
  when 'subtitle'
147
- # subtitle is preceded by space colon space, unless it is at the beginning of the title string
148
- structured_title = if !add_punctuation?
149
- [structured_title, value].join(' ')
150
- elsif structured_title.present?
151
- "#{structured_title.sub(/[. :]+$/, '')} : #{value.sub(/^:/, '').strip}"
152
- else
153
- structured_title = value.sub(/^:/, '').strip
154
- end
178
+ # combine multiple subtitles into a single string
179
+ subtitle = if !add_punctuation?
180
+ if subtitle.present?
181
+ [subtitle, value].join(' ')
182
+ else
183
+ value
184
+ end
185
+ elsif subtitle.present?
186
+ # subtitle is preceded by space colon space, unless it is at the beginning of the title string
187
+ "#{subtitle.sub(/[. :]+$/, '')} : #{value.sub(/^:/, '').strip}"
188
+ else
189
+ value.sub(/^:/, '').strip
190
+ end
155
191
  end
156
192
  end
157
- structured_title
193
+
194
+ # combine the parts into a single title string
195
+ if add_punctuation?
196
+ combine_with_punctuation(non_sort_value: non_sort_value, main_title: main_title, subtitle: subtitle,
197
+ part_name_number: part_name_number)
198
+ else
199
+ ["#{non_sort_value}#{main_title}", subtitle, part_name_number].select(&:presence).join(' ')
200
+ end
158
201
  end
159
- # rubocop:enable Metrics/MethodLength
160
- # rubocop:enable Metrics/BlockLength
161
202
  # rubocop:enable Metrics/CyclomaticComplexity
203
+ # rubocop:enable Metrics/MethodLength
162
204
  # rubocop:enable Metrics/PerceivedComplexity
163
205
 
206
+ # main_title is title.structuredValue.value with type 'main title' (or just title.value)
207
+ # @param [Cocina::Models::Title] title with structured values
208
+ # @return [String] the main title value
209
+ def main_title_from_structured_values(cocina_title) # rubocop:disable Metrics/MethodLength
210
+ result = ''
211
+ # combine pieces of the cocina structuredValue into a single title
212
+ cocina_title.structuredValue.each do |structured_value|
213
+ # There can be a structuredValue inside a structuredValue. For example,
214
+ # a uniform title where both the name and the title have internal StructuredValue
215
+ return main_title_from_structured_values(structured_value) if structured_value.structuredValue.present?
216
+
217
+ value = structured_value.value&.strip
218
+ next unless value
219
+
220
+ case structured_value.type&.downcase
221
+ when 'nonsorting characters'
222
+ non_sort_value = "#{value}#{non_sorting_padding(cocina_title, value)}"
223
+ result = "#{non_sort_value}#{result}" # non-sorting characters are at the beginning of the title
224
+ when 'main title'
225
+ result = "#{result}#{value}"
226
+ when 'title'
227
+ result = value
228
+ end
229
+ end
230
+ result
231
+ end
232
+
233
+ # Thank MARC and catalog cards for this mess. We need to add punctuation.
234
+ # rubocop:disable Metrics/MethodLength
235
+ def combine_with_punctuation(non_sort_value:, main_title:, subtitle:, part_name_number:)
236
+ result = "#{non_sort_value}#{main_title}"
237
+ if subtitle.present?
238
+ result = if result.present?
239
+ "#{result.sub(/[. :]+$/, '')} : #{subtitle.sub(/^:/, '').strip}"
240
+ else
241
+ result = subtitle
242
+ end
243
+ end
244
+ if part_name_number.present?
245
+ result = if result.present?
246
+ "#{result.sub(/[ .,]*$/, '')}. #{part_name_number}."
247
+ else
248
+ "#{part_name_number}."
249
+ end
250
+ end
251
+ result
252
+ end
253
+ # rubocop:enable Metrics/MethodLength
254
+
164
255
  def remove_trailing_punctuation(title)
165
256
  title.sub(%r{[ .,;:/\\]+$}, '')
166
257
  end
@@ -205,7 +296,6 @@ module Cocina
205
296
  end
206
297
  end
207
298
  end
208
- # rubocop:enable Metrics/ClassLength
209
299
  end
210
300
  end
211
301
  end
@@ -23,9 +23,9 @@ module Cocina
23
23
  # MIME Type of the File.
24
24
  attribute? :hasMimeType, Types::Strict::String
25
25
  # BCP 47 language tag: https://www.rfc-editor.org/rfc/rfc4646.txt -- other applications (like media players) expect language codes of this format, see e.g. https://videojs.com/guides/text-tracks/#srclang
26
- attribute? :languageTag, Types::Strict::String
26
+ attribute? :languageTag, LanguageTag.optional
27
27
  # Use for the File.
28
- attribute? :use, Types::Strict::String
28
+ attribute? :use, FileUse.optional
29
29
  attribute :hasMessageDigests, Types::Strict::Array.of(MessageDigest).default([].freeze)
30
30
  attribute(:access, FileAccess.default { FileAccess.new })
31
31
  attribute(:administrative, FileAdministrative.default { FileAdministrative.new })
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ FileUse = Types::String
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ LanguageTag = Types::String
6
+ end
7
+ end
@@ -37,7 +37,7 @@ module Cocina
37
37
  def build
38
38
  grouped_altrepgroup_name_nodes, other_name_nodes = AltRepGroup.split(nodes: deduped_name_nodes)
39
39
  check_altrepgroup_type_inconsistency(grouped_altrepgroup_name_nodes)
40
- contributors = grouped_altrepgroup_name_nodes.map { |name_nodes| build_name_nodes(name_nodes) } + \
40
+ contributors = grouped_altrepgroup_name_nodes.map { |name_nodes| build_name_nodes(name_nodes) } +
41
41
  other_name_nodes.map { |name_node| build_name_nodes([name_node]) }
42
42
  contrib_level_type_and_status(contributors)
43
43
  adjust_primary(contributors.compact).presence
@@ -110,7 +110,7 @@ module Cocina
110
110
  end
111
111
  end
112
112
 
113
- result.each { |_k, role_nodes| role_nodes.uniq! { |role_node| name_node_comparitor(role_node) } }
113
+ result.each_value { |role_nodes| role_nodes.uniq! { |role_node| name_node_comparitor(role_node) } }
114
114
  end
115
115
 
116
116
  def check_altrepgroup_type_inconsistency(grouped_altrepgroup_name_nodes)
@@ -95,7 +95,7 @@ module Cocina
95
95
  node.text.size + add
96
96
  end
97
97
  [{
98
- value: count.to_s, # cast to String until cocina-models 0.40.0 is used. See https://github.com/sul-dlss/cocina-models/pull/146
98
+ value: count.to_s, # cast to String until cocina-models 0.40.0 is used. See https://github.com/sul-dlss/cocina-models/pull/146
99
99
  type: 'nonsorting character count'
100
100
  }]
101
101
  end
@@ -174,7 +174,7 @@ module Cocina
174
174
  [role_node]
175
175
  end
176
176
  end
177
- result.each { |_k, role_nodes| role_nodes.uniq! { |role_node| name_node_comparitor(role_node) } }
177
+ result.each_value { |role_nodes| role_nodes.uniq! { |role_node| name_node_comparitor(role_node) } }
178
178
  end
179
179
 
180
180
  def normalize_type
@@ -13,9 +13,11 @@ module Cocina
13
13
  attribute? :size, Types::Strict::Integer
14
14
  attribute :version, Types::Strict::Integer
15
15
  attribute? :hasMimeType, Types::Strict::String
16
- attribute? :languageTag, Types::Strict::String
16
+ # BCP 47 language tag: https://www.rfc-editor.org/rfc/rfc4646.txt -- other applications (like media players) expect language codes of this format, see e.g. https://videojs.com/guides/text-tracks/#srclang
17
+ attribute? :languageTag, LanguageTag.optional
17
18
  attribute? :externalIdentifier, Types::Strict::String
18
- attribute? :use, Types::Strict::String
19
+ # Use for the File.
20
+ attribute? :use, FileUse.optional
19
21
  attribute :hasMessageDigests, Types::Strict::Array.of(MessageDigest).default([].freeze)
20
22
  attribute(:access, FileAccess.default { FileAccess.new })
21
23
  attribute(:administrative, FileAdministrative.default { FileAdministrative.new })
@@ -39,12 +39,7 @@ module Cocina
39
39
  end
40
40
 
41
41
  def invalid_files
42
- @invalid_files ||=
43
- [].tap do |invalid_files|
44
- files.each do |file|
45
- invalid_files << file if invalid?(file)
46
- end
47
- end
42
+ @invalid_files ||= files.select { |file| invalid?(file) }
48
43
  end
49
44
 
50
45
  def invalid_filenames
@@ -12,7 +12,8 @@ module Cocina
12
12
  def initialize(clazz, attributes)
13
13
  @clazz = clazz
14
14
  @attributes = attributes
15
- @error_paths = []
15
+ @error_paths_multiple = []
16
+ @error_paths_blank = []
16
17
  end
17
18
 
18
19
  def validate
@@ -20,21 +21,21 @@ module Cocina
20
21
 
21
22
  validate_obj(attributes, [])
22
23
 
23
- return if error_paths.empty?
24
-
25
- raise ValidationError, "Multiple value, groupedValue, structuredValue, and parallelValue in description: #{error_paths.join(', ')}"
24
+ raise ValidationError, "Multiple value, groupedValue, structuredValue, and parallelValue in description: #{error_paths_multiple.join(', ')}" unless error_paths_multiple.empty?
25
+ raise ValidationError, "Blank value in description: #{error_paths_blank.join(', ')}" unless error_paths_blank.empty?
26
26
  end
27
27
 
28
28
  private
29
29
 
30
- attr_reader :clazz, :attributes, :error_paths
30
+ attr_reader :clazz, :attributes, :error_paths_blank, :error_paths_multiple
31
31
 
32
32
  def meets_preconditions?
33
33
  [Cocina::Models::Description, Cocina::Models::RequestDescription].include?(clazz)
34
34
  end
35
35
 
36
36
  def validate_hash(hash, path)
37
- validate_values(hash, path)
37
+ validate_values_for_blanks(hash, path)
38
+ validate_values_for_multiples(hash, path)
38
39
  hash.each do |key, obj|
39
40
  validate_obj(obj, path + [key])
40
41
  end
@@ -51,10 +52,16 @@ module Cocina
51
52
  validate_array(obj, path) if obj.is_a?(Array)
52
53
  end
53
54
 
54
- def validate_values(hash, path)
55
+ def validate_values_for_blanks(hash, path)
56
+ return unless hash[:value] && hash[:value].is_a?(String) && /\A\s+\z/.match?(hash[:value]) # rubocop:disable Style/SafeNavigation
57
+
58
+ error_paths_blank << path_to_s(path)
59
+ end
60
+
61
+ def validate_values_for_multiples(hash, path)
55
62
  return unless hash.count { |key, value| %i[value groupedValue structuredValue parallelValue].include?(key) && value.present? } > 1
56
63
 
57
- error_paths << path_to_s(path)
64
+ error_paths_multiple << path_to_s(path)
58
65
  end
59
66
 
60
67
  def path_to_s(path)
@@ -17,10 +17,10 @@ module Cocina
17
17
  def validate
18
18
  return unless meets_preconditions?
19
19
 
20
- return if valid_language_tag?
20
+ return if invalid_files.empty?
21
21
 
22
- raise ValidationError, 'The provided language tag is not valid according to RFC 4646: ' \
23
- "#{attributes[:languageTag]}"
22
+ raise ValidationError, 'Some files have invalid language tags according to RFC 4646: ' \
23
+ "#{invalid_filenames_with_language_tags.join(', ')}"
24
24
  end
25
25
 
26
26
  private
@@ -28,19 +28,45 @@ module Cocina
28
28
  attr_reader :clazz, :attributes
29
29
 
30
30
  def meets_preconditions?
31
- file? && attributes[:languageTag].present?
31
+ dro?
32
32
  end
33
33
 
34
- def file?
35
- (clazz::TYPES & File::TYPES).any?
34
+ def dro?
35
+ (clazz::TYPES & DRO::TYPES).any?
36
36
  rescue NameError
37
37
  false
38
38
  end
39
39
 
40
- def valid_language_tag?
41
- parsed_tag = I18n::Locale::Tag::Rfc4646.tag(attributes[:languageTag])
40
+ def valid_language_tag?(file)
41
+ # I18n::Locale::Tag::Rfc4646.tag will return an instance of I18n::Locale::Tag::Rfc4646 (with fields like language, script,
42
+ # region) for strings that can be parsed according to RFC 4646, and nil for strings that do not conform to the spec.
43
+ I18n::Locale::Tag::Rfc4646.tag(file[:languageTag]).present?
44
+ end
45
+
46
+ def invalid_files
47
+ @invalid_files ||= language_tag_files.reject { |file| valid_language_tag?(file) }
48
+ end
49
+
50
+ def invalid_filenames_with_language_tags
51
+ invalid_files.map { |invalid_file| "#{invalid_file[:filename] || invalid_file[:label]} (#{invalid_file[:languageTag]})" }
52
+ end
53
+
54
+ def language_tag_files
55
+ files.select { |file| file[:languageTag].present? }
56
+ end
57
+
58
+ def files
59
+ [].tap do |files|
60
+ next if attributes.dig(:structural, :contains).nil?
61
+
62
+ attributes[:structural][:contains].each do |fileset|
63
+ next if fileset.dig(:structural, :contains).nil?
42
64
 
43
- parsed_tag.present? && parsed_tag.is_a?(I18n::Locale::Tag::Rfc4646)
65
+ fileset[:structural][:contains].each do |file|
66
+ files << file
67
+ end
68
+ end
69
+ end
44
70
  end
45
71
  end
46
72
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.92.0'
5
+ VERSION = '0.93.1'
6
6
  end
7
7
  end
@@ -38,7 +38,7 @@ module Cocina
38
38
 
39
39
  build_type = type.to_s.delete_suffix(WITH_METADATA_SUFFIX)
40
40
 
41
- fixture = public_send("build_#{build_type}".to_sym, attributes)
41
+ fixture = public_send("build_#{build_type}".to_sym, attributes) # rubocop:disable Lint/SymbolConversion
42
42
  return fixture unless type.end_with?(WITH_METADATA_SUFFIX)
43
43
 
44
44
  Cocina::Models.with_metadata(fixture, 'abc123')
data/openapi.yml CHANGED
@@ -330,6 +330,7 @@ components:
330
330
  pattern: '^[0-9]+-[0-9]+$'
331
331
  example: '6772719-1001'
332
332
  CitationOnlyAccess:
333
+ description: A type of access for an object wherein users can see the metadata and a list of files, but the files will not have view or download access
333
334
  type: object
334
335
  properties:
335
336
  view:
@@ -1045,11 +1046,9 @@ components:
1045
1046
  description: MIME Type of the File.
1046
1047
  type: string
1047
1048
  languageTag:
1048
- description: "BCP 47 language tag: https://www.rfc-editor.org/rfc/rfc4646.txt -- other applications (like media players) expect language codes of this format, see e.g. https://videojs.com/guides/text-tracks/#srclang"
1049
- type: string
1049
+ $ref: '#/components/schemas/LanguageTag'
1050
1050
  use:
1051
- description: Use for the File.
1052
- type: string
1051
+ $ref: '#/components/schemas/FileUse'
1053
1052
  hasMessageDigests:
1054
1053
  type: array
1055
1054
  items:
@@ -1144,6 +1143,10 @@ components:
1144
1143
  type: array
1145
1144
  items:
1146
1145
  $ref: '#/components/schemas/File'
1146
+ FileUse:
1147
+ description: Use for the File.
1148
+ type: string
1149
+ nullable: true
1147
1150
  FolioCatalogLink:
1148
1151
  description: A linkage between an object and a Folio catalog record
1149
1152
  type: object
@@ -1272,6 +1275,10 @@ components:
1272
1275
  valueLanguage:
1273
1276
  # description: present for mapping to additional schemas in the future and for consistency but not otherwise used
1274
1277
  $ref: "#/components/schemas/DescriptiveValueLanguage"
1278
+ LanguageTag:
1279
+ description: "BCP 47 language tag: https://www.rfc-editor.org/rfc/rfc4646.txt -- other applications (like media players) expect language codes of this format, see e.g. https://videojs.com/guides/text-tracks/#srclang"
1280
+ type: string
1281
+ nullable: true
1275
1282
  License:
1276
1283
  description: The license governing reuse of the DRO. Should be an IRI for known licenses (i.e. CC, RightsStatement.org URI, etc.).
1277
1284
  type: string
@@ -1779,11 +1786,11 @@ components:
1779
1786
  hasMimeType:
1780
1787
  type: string
1781
1788
  languageTag:
1782
- type: string
1789
+ $ref: '#/components/schemas/LanguageTag'
1783
1790
  externalIdentifier:
1784
1791
  type: string
1785
1792
  use:
1786
- type: string
1793
+ $ref: '#/components/schemas/FileUse'
1787
1794
  hasMessageDigests:
1788
1795
  type: array
1789
1796
  items:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina-models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.92.0
4
+ version: 0.93.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Coyne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-11-02 00:00:00.000000000 Z
11
+ date: 2024-01-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -429,11 +429,13 @@ files:
429
429
  - lib/cocina/models/file_set.rb
430
430
  - lib/cocina/models/file_set_structural.rb
431
431
  - lib/cocina/models/file_set_type.rb
432
+ - lib/cocina/models/file_use.rb
432
433
  - lib/cocina/models/folio_catalog_link.rb
433
434
  - lib/cocina/models/geographic.rb
434
435
  - lib/cocina/models/identification.rb
435
436
  - lib/cocina/models/lane_medical_barcode.rb
436
437
  - lib/cocina/models/language.rb
438
+ - lib/cocina/models/language_tag.rb
437
439
  - lib/cocina/models/license.rb
438
440
  - lib/cocina/models/location_based_access.rb
439
441
  - lib/cocina/models/location_based_download_access.rb
@@ -560,7 +562,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
560
562
  - !ruby/object:Gem::Version
561
563
  version: '0'
562
564
  requirements: []
563
- rubygems_version: 3.4.19
565
+ rubygems_version: 3.4.13
564
566
  signing_key:
565
567
  specification_version: 4
566
568
  summary: Data models for the SDR