cocina-models 0.74.1 → 0.75.0

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: f6cb9999eaf14875627c7f5248ee0989a2780a7adb4c5a035341627667248678
4
- data.tar.gz: b939b0889194eef22ea2ba184704cb4182f51fd57ff5722669ea438fb3236b94
3
+ metadata.gz: 5ec67ece77b9025a117182d0213342e180e7b87dcd003a44e3d9dffeb03d08e1
4
+ data.tar.gz: 3ee4eee501728b92c356760c660514789082da70c41c990fc3822f5833a7f47d
5
5
  SHA512:
6
- metadata.gz: e44e7a8c2b03956d62f798c451867fcf71314faf444c426734f4063e91ec353f90c61313481b3926f68a746b20c379b7820ef09ab0993d9d1fe501d5e23ce9ea
7
- data.tar.gz: 0c6fb86b2b7b1961dbbeed1bb08e184baff2f6c3404e95f866b841f3484baa8a7ecf0e3085077d711fa45eb2456a10565471915ad8a18d884908157b90de21fb
6
+ metadata.gz: 4b0b94409167d640cfd84c7202468664ec32887405b17973e05ebdd77a55f608e53ad2c2956aabc85ac09dd9edecb6281eaafea7822d77bdea1075301e04aae6
7
+ data.tar.gz: 821ba48bca0b9c92031eb6ba2dbde666f2116c6c7f9076663730636f1c61f11e098551f2e81b975dea06289bd9ba09aa4f498ef67b36dbfa76d6581f29c515c4
data/.rubocop.yml CHANGED
@@ -99,6 +99,7 @@ RSpec/ExampleLength:
99
99
  Exclude:
100
100
  - spec/cocina/models/description_spec.rb
101
101
  - spec/cocina/models/dro_shared_examples.rb
102
+ - spec/cocina/models/builders/name_title_group_builder_spec.rb
102
103
 
103
104
  RSpec/MultipleExpectations:
104
105
  Enabled: false
data/README.md CHANGED
@@ -42,6 +42,15 @@ The generator is tested via its output when run against `openapi.yml`, viz., the
42
42
 
43
43
  Beyond what is necessary to test the generator, the Cocina model classes are not tested, i.e., they are assumed to be as specified in `openapi.yml`.
44
44
 
45
+ ## Testing validation changes
46
+
47
+ If there is a possibility that a model or validation change will conflict with some existing objects then [validate-cocina](https://github.com/sul-dlss/dor-services-app/blob/main/bin/validate-cocina) should be used for testing. This must be run on sdr-deploy since it requires deploying a branch of cocina-models.
48
+
49
+ 1. Create a cocina-models branch containing the proposed change and push to Github.
50
+ 2. On sdr-deploy, check out `main`, update the `Gemfile` so that cocina-models references the branch, and `bundle install`.
51
+ 3. Run `bin/validate-cocina`.
52
+ 4. Check `validate-cocina.csv` for validation errors.
53
+
45
54
  ## Releasing
46
55
 
47
56
  ### Step 0: Share intent to change the models
@@ -364,7 +364,7 @@ identifier:
364
364
  code: urn
365
365
  - value: videorecording identifier
366
366
  code: videorecording-identifier
367
- - value: West Mat \#
367
+ - value: 'West Mat #'
368
368
  - value: Wikidata
369
369
  code: wikidata
370
370
  - value: Bodley 342
@@ -122,10 +122,7 @@ module Cocina
122
122
 
123
123
  NO_CLEAN = [
124
124
  'checkable.rb',
125
- 'dro_rights_description_builder.rb',
126
125
  'license.rb',
127
- 'rights_description_builder.rb',
128
- 'title_builder.rb',
129
126
  'validatable.rb',
130
127
  'version.rb',
131
128
  'vocabulary.rb'
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Builders
6
+ # Rights description builder for items
7
+ class DroRightsDescriptionBuilder < RightsDescriptionBuilder
8
+ # @param [Cocina::Models::DRO] cocina_item
9
+
10
+ # This overrides the superclass
11
+ # @return [Cocina::Models::DROAccess]
12
+ def object_access
13
+ @object_access ||= cocina.access
14
+ end
15
+
16
+ private
17
+
18
+ def object_level_access
19
+ super + access_level_from_files.uniq.map { |str| "#{str} (file)" }
20
+ end
21
+
22
+ def access_level_from_files
23
+ # dark access doesn't permit any file access
24
+ return [] if object_access.view == 'dark'
25
+
26
+ file_access_nodes.reject { |fa| same_as_object_access?(fa) }.flat_map do |fa|
27
+ file_access_from_file(fa)
28
+ end
29
+ end
30
+
31
+ # rubocop:disable Metrics/MethodLength
32
+ def file_access_from_file(file_access)
33
+ basic_access = if file_access[:view] == 'location-based'
34
+ "location: #{file_access[:location]}"
35
+ else
36
+ file_access[:view]
37
+ end
38
+
39
+ return [basic_access] if file_access[:view] == file_access[:download]
40
+
41
+ basic_access += ' (no-download)' if file_access[:view] != 'dark'
42
+
43
+ case file_access[:download]
44
+ when 'stanford'
45
+ [basic_access, 'stanford']
46
+ when 'location-based'
47
+ # Here we're using location to mean download location.
48
+ [basic_access, "location: #{file_access[:location]}"]
49
+ else
50
+ [basic_access]
51
+ end
52
+ end
53
+ # rubocop:enable Metrics/MethodLength
54
+
55
+ def same_as_object_access?(file_access)
56
+ (file_access[:view] == object_access.view && file_access[:download] == object_access.download) ||
57
+ (object_access.view == 'citation-only' && file_access[:view] == 'dark')
58
+ end
59
+
60
+ def file_access_nodes
61
+ Array(cocina.structural.contains)
62
+ .flat_map { |fs| Array(fs.structural.contains) }
63
+ .map { |file| file.access.to_h }
64
+ .uniq
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Builders
6
+ # Helpers for MODS nameTitleGroups.
7
+ # MODS titles need to know if they match a contributor and thus need a nameTitleGroup
8
+ # MODS contributors need to know if they match a title and thus need a nameTitleGroup
9
+ # If there is a match, the nameTitleGroup number has to be consistent for the matching title(s)
10
+ # and the contributor(s)
11
+ class NameTitleGroupBuilder
12
+ # When to assign nameTitleGroup to MODS from cocina:
13
+ # for cocina title of type "uniform",
14
+ # look for cocina title properties :value or :structuredValue (recurse down through :parallelValue
15
+ # as needed), and look for associated :note with :type of "associated name" at the level of the
16
+ # non-empty title [value|structuredValue]
17
+ # The note of type "associated name" will have [value|structuredValue] which will match
18
+ # [value|structuredValue] for a contributor (possibly after recursing through :parallelValue).
19
+ # Thus, a title [value|structuredValue] and a contributor [value|structuredValue] are associated in
20
+ # cocina.
21
+ #
22
+ # If those criteria not met in Cocina, do not assign nameTitleGroup in MODS
23
+ #
24
+ # @params [Cocina::Models::Title] title
25
+ # @return [Hash<Hash, Hash>] key: hash of value or structuredValue property for title
26
+ # value: hash of value or structuredValue property for contributor
27
+ # e.g. {{:value=>"Portrait of the artist as a young man"}=>{:value=>"James Joyce"}}
28
+ # e.g. {{:value=>"Portrait of the artist as a young man"}=>{:structuredValue=>
29
+ # [{:value=>"Joyce, James", :type=>"name"},{:value=>"1882-1941", :type=>"life dates"}]}}
30
+ # e.g. {{:structuredValue=>[{:value=>"Demanding Food", :type=>"main"},
31
+ # {:value=>"A Cat's Life", :type=>"subtitle"}]}=>{:value=>"James Joyce"}}
32
+ # this complexity is needed for multilingual titles mapping to multilingual names.
33
+ def self.build_title_values_to_contributor_name_values(title)
34
+ result = {}
35
+ return result if title.blank?
36
+
37
+ # pair title value with contributor name value
38
+ title_value_note_slices(title).each do |value_note_slice|
39
+ title_val_slice = slice_of_value_or_structured_value(value_note_slice)
40
+ next if title_val_slice.blank?
41
+
42
+ associated_name_note = value_note_slice[:note]&.detect { |note| note[:type] == 'associated name' }
43
+ next if associated_name_note.blank?
44
+
45
+ # relevant note will be Array of either
46
+ # {
47
+ # value: 'string contributor name',
48
+ # type: 'associated name'
49
+ # }
50
+ # OR
51
+ # {
52
+ # structuredValue: [ structuredValue contributor name ],
53
+ # type: 'associated name'
54
+ # }
55
+ # and we want the hash without the :type attribute
56
+ result[title_val_slice] = slice_of_value_or_structured_value(associated_name_note)
57
+ end
58
+ result
59
+ end
60
+
61
+ def self.contributor_for_contributor_name_value_slice(contributor_name_value_slice:, contributors:)
62
+ Array(contributors).find do |contributor|
63
+ contrib_name_value_slices = contributor_name_value_slices(contributor)
64
+ contrib_name_value_slices.include?(contributor_name_value_slice)
65
+ end
66
+ end
67
+
68
+ # @params [Cocina::Models::Contributor] contributor
69
+ # @return [Hash] where we are only interested in
70
+ # hashes containing (either :value or :structureValue)
71
+ def self.contributor_name_value_slices(contributor)
72
+ return if contributor&.name.blank?
73
+
74
+ slices = []
75
+ Array(contributor.name).each do |contrib_name|
76
+ slices << value_slices(contrib_name)
77
+ end
78
+ slices.flatten
79
+ end
80
+
81
+ # @params [Cocina::Models::DescriptiveValue] desc_value
82
+ # @return [Array<Cocina::Models::DescriptiveValue>] where we are only interested in
83
+ # hashes containing (either :value or :structuredValue)
84
+ # rubocop:disable Metrics/AbcSize
85
+ def self.value_slices(desc_value)
86
+ slices = []
87
+ desc_value_slice = desc_value.to_h.slice(:value, :structuredValue, :parallelValue)
88
+ if desc_value_slice[:value].present? || desc_value_slice[:structuredValue].present?
89
+ slices << desc_value_slice.select { |_k, value| value.present? }
90
+ elsif desc_value_slice[:parallelValue].present?
91
+ desc_value_slice[:parallelValue].each { |parallel_val| slices << value_slices(parallel_val) }
92
+ end
93
+ # ignoring groupedValue
94
+ slices.flatten
95
+ end
96
+ # rubocop:enable Metrics/AbcSize
97
+ # private_class_method :value_slices
98
+
99
+ # for a given Hash (from a Cocina DescriptiveValue or Title or Name or ...)
100
+ # result will be either
101
+ # { value: 'string value' }
102
+ # OR
103
+ # { structuredValue: [ some structuredValue ] }
104
+ def self.slice_of_value_or_structured_value(hash)
105
+ if hash[:value].present?
106
+ hash.slice(:value).select { |_k, value| value.present? }
107
+ elsif hash[:structuredValue].present?
108
+ hash.slice(:structuredValue).select { |_k, value| value.present? }
109
+ end
110
+ end
111
+
112
+ # reduce parallelValues down to value or structuredValue for these slices
113
+ # @params [Cocina::Models::Title] title
114
+ # @return [Array<Cocina::Models::DescriptiveValue>] where we are only interested in
115
+ # hashes containing (either :value or :structureValue) and :note if present
116
+ # rubocop:disable Metrics/AbcSize
117
+ def self.title_value_note_slices(title)
118
+ slices = []
119
+ title_slice = title.to_h.slice(:value, :structuredValue, :parallelValue, :note)
120
+ if title_slice[:value].present? || title_slice[:structuredValue].present?
121
+ slices << title_slice.select { |_k, value| value.present? }
122
+ elsif title_slice[:parallelValue].present?
123
+ title_slice[:parallelValue].each do |parallel_val|
124
+ slices << title_value_note_slices(parallel_val)
125
+ end
126
+ end
127
+ # ignoring groupedValue
128
+ slices.flatten
129
+ end
130
+ # rubocop:enable Metrics/AbcSize
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Builders
6
+ # RightsDescriptionBuilder
7
+ class RightsDescriptionBuilder
8
+ # @param [Cocina::Models::AdminPolicy, Cocina::Models::DRO] cocina_object
9
+ def self.build(cocina_object)
10
+ new(cocina_object).build
11
+ end
12
+
13
+ def initialize(cocina_object)
14
+ @cocina = cocina_object
15
+ end
16
+
17
+ # This is set up to work for APOs, but this method is to be overridden on sub classes
18
+ # @return [Cocina::Models::AdminPolicyDefaultAccess]
19
+ def object_access
20
+ @object_access ||= cocina.administrative.accessTemplate
21
+ end
22
+
23
+ def build
24
+ return 'controlled digital lending' if object_access.controlledDigitalLending
25
+
26
+ return ['dark'] if object_access.view == 'dark'
27
+
28
+ object_level_access
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :cocina
34
+
35
+ # rubocop:disable Metrics/MethodLength
36
+ def object_level_access
37
+ case object_access.view
38
+ when 'citation-only'
39
+ ['citation']
40
+ when 'world'
41
+ world_object_access
42
+ when 'location-based'
43
+ case object_access.download
44
+ when 'none'
45
+ ["location: #{object_access.location} (no-download)"]
46
+ else
47
+ ["location: #{object_access.location}"]
48
+ end
49
+ when 'stanford'
50
+ stanford_object_access
51
+ end
52
+ end
53
+ # rubocop:enable Metrics/MethodLength
54
+
55
+ def stanford_object_access
56
+ case object_access.download
57
+ when 'none'
58
+ ['stanford (no-download)']
59
+ when 'location-based'
60
+ # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
61
+ ['stanford (no-download)', "location: #{object_access.location}"]
62
+ else
63
+ ['stanford']
64
+ end
65
+ end
66
+
67
+ def world_object_access
68
+ case object_access.download
69
+ when 'stanford'
70
+ ['stanford', 'world (no-download)']
71
+ when 'none'
72
+ ['world (no-download)']
73
+ when 'world'
74
+ ['world']
75
+ when 'location-based'
76
+ # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
77
+ ['world (no-download)', "location: #{object_access.location}"]
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'deprecation'
4
+
5
+ module Cocina
6
+ module Models
7
+ module Builders
8
+ # TitleBuilder selects the prefered title from the cocina object for solr indexing
9
+ # rubocop:disable Metrics/ClassLength
10
+ class TitleBuilder
11
+ extend Deprecation
12
+ # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
13
+ # @param [Symbol] strategy ":first" is the strategy for selection when primary or display
14
+ # title are missing
15
+ # @param [Boolean] add_punctuation determines if the title should be formmated with punctuation
16
+ # @return [String] the title value for Solr
17
+ def self.build(titles, strategy: :first, add_punctuation: true)
18
+ if titles.respond_to?(:description)
19
+ Deprecation.warn(self,
20
+ "Calling TitleBuilder.build with a #{titles.class} is deprecated. " \
21
+ 'It must be called with an array of titles')
22
+ titles = titles.description.title
23
+ end
24
+ new(strategy: strategy, add_punctuation: add_punctuation).build(titles)
25
+ end
26
+
27
+ def initialize(strategy:, add_punctuation:)
28
+ @strategy = strategy
29
+ @add_punctuation = add_punctuation
30
+ end
31
+
32
+ # @param [[Array<Cocina::Models::Title>] titles the titles to consider
33
+ # @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?
37
+
38
+ if strategy == :first
39
+ extract_title(cocina_title)
40
+ else
41
+ cocina_title.map { |one| extract_title(one) }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :strategy
48
+
49
+ def extract_title(cocina_title)
50
+ result = if cocina_title.value
51
+ cocina_title.value
52
+ elsif cocina_title.structuredValue.present?
53
+ title_from_structured_values(cocina_title)
54
+ elsif cocina_title.parallelValue.present?
55
+ return build(cocina_title.parallelValue)
56
+ end
57
+ remove_trailing_punctuation(result.strip) if result.present?
58
+ end
59
+
60
+ def add_punctuation?
61
+ @add_punctuation
62
+ end
63
+
64
+ # @return [Cocina::Models::Title, nil] title that has status=primary
65
+ def primary_title(titles)
66
+ primary_title = titles.find do |title|
67
+ title.status == 'primary'
68
+ end
69
+ return primary_title if primary_title.present?
70
+
71
+ # NOTE: structuredValues would only have status primary assigned as a sibling, not as an attribute
72
+
73
+ titles.find do |title|
74
+ title.parallelValue&.find do |parallel_title|
75
+ parallel_title.status == 'primary'
76
+ end
77
+ end
78
+ end
79
+
80
+ def untyped_title(titles)
81
+ method = strategy == :first ? :find : :select
82
+ untyped_title_for(titles.public_send(method))
83
+ end
84
+
85
+ # @return [Array[Cocina::Models::Title]] first title that has no type attribute
86
+ def untyped_title_for(titles)
87
+ titles.each do |title|
88
+ if title.parallelValue.present?
89
+ untyped_title_for(title.parallelValue)
90
+ else
91
+ title.type.nil? || title.type == 'title'
92
+ end
93
+ end
94
+ end
95
+
96
+ # This handles 'main title', 'uniform' or 'translated'
97
+ def other_title(titles)
98
+ if strategy == :first
99
+ titles.first
100
+ else
101
+ titles
102
+ end
103
+ end
104
+
105
+ # rubocop:disable Metrics/BlockLength
106
+ # rubocop:disable Metrics/CyclomaticComplexity
107
+ # rubocop:disable Metrics/PerceivedComplexity
108
+ # rubocop:disable Metrics/MethodLength
109
+ # rubocop:disable Metrics/AbcSize
110
+ # @param [Cocina::Models::Title] title with structured values
111
+ # @return [String] the title value from combining the pieces of the structured_values by type and order
112
+ # with desired punctuation per specs
113
+ def title_from_structured_values(title)
114
+ structured_title = ''
115
+ part_name_number = ''
116
+ # combine pieces of the cocina structuredValue into a single title
117
+ title.structuredValue.each do |structured_value|
118
+ # There can be a structuredValue inside a structuredValue. For example,
119
+ # a uniform title where both the name and the title have internal StructuredValue
120
+ return title_from_structured_values(structured_value) if structured_value.structuredValue.present?
121
+
122
+ value = structured_value.value&.strip
123
+ next unless value
124
+
125
+ # additional types: name, uniform ...
126
+ case structured_value.type&.downcase
127
+ when 'nonsorting characters'
128
+ non_sort_value = "#{value}#{non_sorting_padding(title, value)}"
129
+ structured_title = if structured_title.present?
130
+ "#{structured_title}#{non_sort_value}"
131
+ else
132
+ non_sort_value
133
+ end
134
+ when 'part name', 'part number'
135
+ if part_name_number.blank?
136
+ part_name_number = part_name_number(title.structuredValue)
137
+ structured_title = if !add_punctuation?
138
+ [structured_title, part_name_number].join(' ')
139
+ elsif structured_title.present?
140
+ "#{structured_title.sub(/[ .,]*$/, '')}. #{part_name_number}. "
141
+ else
142
+ "#{part_name_number}. "
143
+ end
144
+ end
145
+ when 'main title', 'title'
146
+ structured_title = "#{structured_title}#{value}"
147
+ when 'subtitle'
148
+ # subtitle is preceded by space colon space, unless it is at the beginning of the title string
149
+ structured_title = if !add_punctuation?
150
+ [structured_title, value].join(' ')
151
+ elsif structured_title.present?
152
+ "#{structured_title.sub(/[. :]+$/, '')} : #{value.sub(/^:/, '').strip}"
153
+ else
154
+ structured_title = value.sub(/^:/, '').strip
155
+ end
156
+ end
157
+ end
158
+ structured_title
159
+ end
160
+ # rubocop:enable Metrics/AbcSize
161
+ # rubocop:enable Metrics/MethodLength
162
+ # rubocop:enable Metrics/BlockLength
163
+ # rubocop:enable Metrics/CyclomaticComplexity
164
+ # rubocop:enable Metrics/PerceivedComplexity
165
+
166
+ def remove_trailing_punctuation(title)
167
+ title.sub(%r{[ .,;:/\\]+$}, '')
168
+ end
169
+
170
+ def non_sorting_padding(title, non_sorting_value)
171
+ non_sort_note = title.note&.find { |note| note.type&.downcase == 'nonsorting character count' }
172
+ if non_sort_note
173
+ padding_count = [non_sort_note.value.to_i - non_sorting_value.length, 0].max
174
+ ' ' * padding_count
175
+ elsif ['\'', '-'].include?(non_sorting_value.last)
176
+ ''
177
+ else
178
+ ' '
179
+ end
180
+ end
181
+
182
+ # combine part name and part number:
183
+ # respect order of occurrence
184
+ # separated from each other by comma space
185
+ def part_name_number(structured_values)
186
+ title_from_part = ''
187
+ structured_values.each do |structured_value|
188
+ case structured_value.type&.downcase
189
+ when 'part name', 'part number'
190
+ value = structured_value.value&.strip
191
+ next unless value
192
+
193
+ title_from_part = append_part_to_title(title_from_part, value)
194
+
195
+ end
196
+ end
197
+ title_from_part
198
+ end
199
+
200
+ def append_part_to_title(title_from_part, value)
201
+ if !add_punctuation?
202
+ [title_from_part, value].select(&:presence).join(' ')
203
+ elsif title_from_part.strip.present?
204
+ "#{title_from_part.sub(/[ .,]*$/, '')}, #{value}"
205
+ else
206
+ value
207
+ end
208
+ end
209
+ end
210
+ # rubocop:enable Metrics/ClassLength
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Validators
6
+ # Validates that when title.note with type "associated name" has a value, it must match a contributor name.
7
+ class AssociatedNameValidator
8
+ def self.validate(clazz, attributes)
9
+ new(clazz, attributes).validate
10
+ end
11
+
12
+ def initialize(clazz, attributes)
13
+ @clazz = clazz
14
+ @attributes = attributes.deep_symbolize_keys
15
+ @error_paths = []
16
+ end
17
+
18
+ def validate
19
+ return unless meets_preconditions?
20
+
21
+ return if resources.all? { |resource| valid?(resource) }
22
+
23
+ raise ValidationError,
24
+ 'Missing data: Name associated with uniform title does not match any contributor.'
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :clazz, :attributes, :error_paths
30
+
31
+ def meets_preconditions?
32
+ resources.any? do |resource|
33
+ titles_with_associated_name_note_for(resource).present?
34
+ end
35
+ end
36
+
37
+ def valid?(resource)
38
+ titles_with_associated_name_note_for(resource).all? do |title|
39
+ contributor_name_value_slices = Builders::NameTitleGroupBuilder
40
+ .build_title_values_to_contributor_name_values(
41
+ Cocina::Models::Title.new(title)
42
+ ).values
43
+ contributor_name_value_slices.all? do |contributor_name_value_slice|
44
+ contributors = Array(resource[:contributor]).map do |contributor|
45
+ Cocina::Models::Contributor.new(contributor)
46
+ end
47
+ Builders::NameTitleGroupBuilder.contributor_for_contributor_name_value_slice(
48
+ contributor_name_value_slice: contributor_name_value_slice, contributors: contributors
49
+ ).present?
50
+ end
51
+ end
52
+ end
53
+
54
+ def resources
55
+ @resources ||= [description_attributes] + Array(description_attributes[:relatedResource])
56
+ end
57
+
58
+ def description_attributes
59
+ @description_attributes ||= if [Cocina::Models::Description,
60
+ Cocina::Models::RequestDescription].include?(clazz)
61
+ attributes
62
+ else
63
+ attributes[:description] || {}
64
+ end
65
+ end
66
+
67
+ def associated_name_note_for(title)
68
+ Array(title[:note]).detect { |note| note[:type] == 'associated name' }
69
+ end
70
+
71
+ def titles_with_associated_name_note_for(resource)
72
+ Array(resource[:title]).select { |title| associated_name_note_for(title).present? }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -10,6 +10,7 @@ module Cocina
10
10
  DarkValidator,
11
11
  PurlValidator,
12
12
  CatalogLinksValidator,
13
+ AssociatedNameValidator,
13
14
  DescriptionTypesValidator
14
15
  ].freeze
15
16
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.74.1'
5
+ VERSION = '0.75.0'
6
6
  end
7
7
  end
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.74.1
4
+ version: 0.75.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Coyne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-04-15 00:00:00.000000000 Z
11
+ date: 2022-04-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -313,6 +313,10 @@ files:
313
313
  - lib/cocina/models/admin_policy_with_metadata.rb
314
314
  - lib/cocina/models/administrative.rb
315
315
  - lib/cocina/models/applies_to.rb
316
+ - lib/cocina/models/builders/dro_rights_description_builder.rb
317
+ - lib/cocina/models/builders/name_title_group_builder.rb
318
+ - lib/cocina/models/builders/rights_description_builder.rb
319
+ - lib/cocina/models/builders/title_builder.rb
316
320
  - lib/cocina/models/business_barcode.rb
317
321
  - lib/cocina/models/catalog_link.rb
318
322
  - lib/cocina/models/catkey_barcode.rb
@@ -341,7 +345,6 @@ files:
341
345
  - lib/cocina/models/doi.rb
342
346
  - lib/cocina/models/dro.rb
343
347
  - lib/cocina/models/dro_access.rb
344
- - lib/cocina/models/dro_rights_description_builder.rb
345
348
  - lib/cocina/models/dro_structural.rb
346
349
  - lib/cocina/models/dro_with_metadata.rb
347
350
  - lib/cocina/models/druid.rb
@@ -377,7 +380,6 @@ files:
377
380
  - lib/cocina/models/request_file_set.rb
378
381
  - lib/cocina/models/request_file_set_structural.rb
379
382
  - lib/cocina/models/request_identification.rb
380
- - lib/cocina/models/rights_description_builder.rb
381
383
  - lib/cocina/models/sequence.rb
382
384
  - lib/cocina/models/source.rb
383
385
  - lib/cocina/models/source_id.rb
@@ -385,8 +387,8 @@ files:
385
387
  - lib/cocina/models/standard_barcode.rb
386
388
  - lib/cocina/models/stanford_access.rb
387
389
  - lib/cocina/models/title.rb
388
- - lib/cocina/models/title_builder.rb
389
390
  - lib/cocina/models/validatable.rb
391
+ - lib/cocina/models/validators/associated_name_validator.rb
390
392
  - lib/cocina/models/validators/catalog_links_validator.rb
391
393
  - lib/cocina/models/validators/dark_validator.rb
392
394
  - lib/cocina/models/validators/description_types_validator.rb
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cocina
4
- module Models
5
- # Rights description builder for items
6
- class DroRightsDescriptionBuilder < RightsDescriptionBuilder
7
- # @param [Cocina::Models::DRO] cocina_item
8
-
9
- # This overrides the superclass
10
- # @return [Cocina::Models::DROAccess]
11
- def object_access
12
- @object_access ||= cocina.access
13
- end
14
-
15
- private
16
-
17
- def object_level_access
18
- super + access_level_from_files.uniq.map { |str| "#{str} (file)" }
19
- end
20
-
21
- def access_level_from_files
22
- # dark access doesn't permit any file access
23
- return [] if object_access.view == 'dark'
24
-
25
- file_access_nodes.reject { |fa| same_as_object_access?(fa) }.flat_map do |fa|
26
- file_access_from_file(fa)
27
- end
28
- end
29
-
30
- # rubocop:disable Metrics/MethodLength
31
- def file_access_from_file(file_access)
32
- basic_access = if file_access[:view] == 'location-based'
33
- "location: #{file_access[:location]}"
34
- else
35
- file_access[:view]
36
- end
37
-
38
- return [basic_access] if file_access[:view] == file_access[:download]
39
-
40
- basic_access += ' (no-download)' if file_access[:view] != 'dark'
41
-
42
- case file_access[:download]
43
- when 'stanford'
44
- [basic_access, 'stanford']
45
- when 'location-based'
46
- # Here we're using location to mean download location.
47
- [basic_access, "location: #{file_access[:location]}"]
48
- else
49
- [basic_access]
50
- end
51
- end
52
- # rubocop:enable Metrics/MethodLength
53
-
54
- def same_as_object_access?(file_access)
55
- (file_access[:view] == object_access.view && file_access[:download] == object_access.download) ||
56
- (object_access.view == 'citation-only' && file_access[:view] == 'dark')
57
- end
58
-
59
- def file_access_nodes
60
- Array(cocina.structural.contains)
61
- .flat_map { |fs| Array(fs.structural.contains) }
62
- .map { |file| file.access.to_h }
63
- .uniq
64
- end
65
- end
66
- end
67
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cocina
4
- module Models
5
- # RightsDescriptionBuilder
6
- class RightsDescriptionBuilder
7
- # @param [Cocina::Models::AdminPolicy, Cocina::Models::DRO] cocina_object
8
- def self.build(cocina_object)
9
- new(cocina_object).build
10
- end
11
-
12
- def initialize(cocina_object)
13
- @cocina = cocina_object
14
- end
15
-
16
- # This is set up to work for APOs, but this method is to be overridden on sub classes
17
- # @return [Cocina::Models::AdminPolicyDefaultAccess]
18
- def object_access
19
- @object_access ||= cocina.administrative.accessTemplate
20
- end
21
-
22
- def build
23
- return 'controlled digital lending' if object_access.controlledDigitalLending
24
-
25
- return ['dark'] if object_access.view == 'dark'
26
-
27
- object_level_access
28
- end
29
-
30
- private
31
-
32
- attr_reader :cocina
33
-
34
- # rubocop:disable Metrics/MethodLength
35
- def object_level_access
36
- case object_access.view
37
- when 'citation-only'
38
- ['citation']
39
- when 'world'
40
- world_object_access
41
- when 'location-based'
42
- case object_access.download
43
- when 'none'
44
- ["location: #{object_access.location} (no-download)"]
45
- else
46
- ["location: #{object_access.location}"]
47
- end
48
- when 'stanford'
49
- stanford_object_access
50
- end
51
- end
52
- # rubocop:enable Metrics/MethodLength
53
-
54
- def stanford_object_access
55
- case object_access.download
56
- when 'none'
57
- ['stanford (no-download)']
58
- when 'location-based'
59
- # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
60
- ['stanford (no-download)', "location: #{object_access.location}"]
61
- else
62
- ['stanford']
63
- end
64
- end
65
-
66
- def world_object_access
67
- case object_access.download
68
- when 'stanford'
69
- ['stanford', 'world (no-download)']
70
- when 'none'
71
- ['world (no-download)']
72
- when 'world'
73
- ['world']
74
- when 'location-based'
75
- # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
76
- ['world (no-download)', "location: #{object_access.location}"]
77
- end
78
- end
79
- end
80
- end
81
- end
@@ -1,208 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'deprecation'
4
-
5
- module Cocina
6
- module Models
7
- # TitleBuilder selects the prefered title from the cocina object for solr indexing
8
- # rubocop:disable Metrics/ClassLength
9
- class TitleBuilder
10
- extend Deprecation
11
- # @param [[Array<Cocina::Models::Title,Cocina::Models::DescriptiveValue>] titles the titles to consider
12
- # @param [Symbol] strategy ":first" is the strategy for selection when primary or display title are missing
13
- # @param [Boolean] add_punctuation determines if the title should be formmated with punctuation
14
- # @return [String] the title value for Solr
15
- def self.build(titles, strategy: :first, add_punctuation: true)
16
- if titles.respond_to?(:description)
17
- Deprecation.warn(self, "Calling TitleBuilder.build with a #{titles.class} is deprecated. It must be called with an array of titles")
18
- titles = titles.description.title
19
- end
20
- new(strategy: strategy, add_punctuation: add_punctuation).build(titles)
21
- end
22
-
23
- def initialize(strategy:, add_punctuation:)
24
- @strategy = strategy
25
- @add_punctuation = add_punctuation
26
- end
27
-
28
- # @param [[Array<Cocina::Models::Title>] titles the titles to consider
29
- # @return [String] the title value for Solr
30
- def build(titles)
31
- cocina_title = primary_title(titles) || untyped_title(titles)
32
- cocina_title = other_title(titles) if cocina_title.blank?
33
-
34
- if strategy == :first
35
- extract_title(cocina_title)
36
- else
37
- cocina_title.map { |one| extract_title(one) }
38
- end
39
- end
40
-
41
- private
42
-
43
- attr_reader :strategy
44
-
45
- def extract_title(cocina_title)
46
- result = if cocina_title.value
47
- cocina_title.value
48
- elsif cocina_title.structuredValue.present?
49
- title_from_structured_values(cocina_title)
50
- elsif cocina_title.parallelValue.present?
51
- return build(cocina_title.parallelValue)
52
- end
53
- remove_trailing_punctuation(result.strip) if result.present?
54
- end
55
-
56
- def add_punctuation?
57
- @add_punctuation
58
- end
59
-
60
- # @return [Cocina::Models::Title, nil] title that has status=primary
61
- def primary_title(titles)
62
- primary_title = titles.find do |title|
63
- title.status == 'primary'
64
- end
65
- return primary_title if primary_title.present?
66
-
67
- # NOTE: structuredValues would only have status primary assigned as a sibling, not as an attribute
68
-
69
- titles.find do |title|
70
- title.parallelValue&.find do |parallel_title|
71
- parallel_title.status == 'primary'
72
- end
73
- end
74
- end
75
-
76
- def untyped_title(titles)
77
- method = strategy == :first ? :find : :select
78
- untyped_title_for(titles.public_send(method))
79
- end
80
-
81
- # @return [Array[Cocina::Models::Title]] first title that has no type attribute
82
- def untyped_title_for(titles)
83
- titles.each do |title|
84
- if title.parallelValue.present?
85
- untyped_title_for(title.parallelValue)
86
- else
87
- title.type.nil? || title.type == 'title'
88
- end
89
- end
90
- end
91
-
92
- # This handles 'main title', 'uniform' or 'translated'
93
- def other_title(titles)
94
- if strategy == :first
95
- titles.first
96
- else
97
- titles
98
- end
99
- end
100
-
101
- # rubocop:disable Metrics/BlockLength
102
- # rubocop:disable Metrics/CyclomaticComplexity
103
- # rubocop:disable Metrics/PerceivedComplexity
104
- # rubocop:disable Metrics/MethodLength
105
- # rubocop:disable Metrics/AbcSize
106
- # @param [Cocina::Models::Title] title with structured values
107
- # @return [String] the title value from combining the pieces of the structured_values by type and order
108
- # with desired punctuation per specs
109
- def title_from_structured_values(title)
110
- structured_title = ''
111
- part_name_number = ''
112
- # combine pieces of the cocina structuredValue into a single title
113
- title.structuredValue.each do |structured_value|
114
- # There can be a structuredValue inside a structuredValue. For example,
115
- # a uniform title where both the name and the title have internal StructuredValue
116
- return title_from_structured_values(structured_value) if structured_value.structuredValue.present?
117
-
118
- value = structured_value.value&.strip
119
- next unless value
120
-
121
- # additional types: name, uniform ...
122
- case structured_value.type&.downcase
123
- when 'nonsorting characters'
124
- non_sort_value = "#{value}#{non_sorting_padding(title, value)}"
125
- structured_title = if structured_title.present?
126
- "#{structured_title}#{non_sort_value}"
127
- else
128
- non_sort_value
129
- end
130
- when 'part name', 'part number'
131
- if part_name_number.blank?
132
- part_name_number = part_name_number(title.structuredValue)
133
- structured_title = if !add_punctuation?
134
- [structured_title, part_name_number].join(' ')
135
- elsif structured_title.present?
136
- "#{structured_title.sub(/[ .,]*$/, '')}. #{part_name_number}. "
137
- else
138
- "#{part_name_number}. "
139
- end
140
- end
141
- when 'main title', 'title'
142
- structured_title = "#{structured_title}#{value}"
143
- when 'subtitle'
144
- # subtitle is preceded by space colon space, unless it is at the beginning of the title string
145
- structured_title = if !add_punctuation?
146
- [structured_title, value].join(' ')
147
- elsif structured_title.present?
148
- "#{structured_title.sub(/[. :]+$/, '')} : #{value.sub(/^:/, '').strip}"
149
- else
150
- structured_title = value.sub(/^:/, '').strip
151
- end
152
- end
153
- end
154
- structured_title
155
- end
156
- # rubocop:enable Metrics/AbcSize
157
- # rubocop:enable Metrics/MethodLength
158
- # rubocop:enable Metrics/BlockLength
159
- # rubocop:enable Metrics/CyclomaticComplexity
160
- # rubocop:enable Metrics/PerceivedComplexity
161
-
162
- def remove_trailing_punctuation(title)
163
- title.sub(%r{[ .,;:/\\]+$}, '')
164
- end
165
-
166
- def non_sorting_padding(title, non_sorting_value)
167
- non_sort_note = title.note&.find { |note| note.type&.downcase == 'nonsorting character count' }
168
- if non_sort_note
169
- padding_count = [non_sort_note.value.to_i - non_sorting_value.length, 0].max
170
- ' ' * padding_count
171
- elsif ['\'', '-'].include?(non_sorting_value.last)
172
- ''
173
- else
174
- ' '
175
- end
176
- end
177
-
178
- # combine part name and part number:
179
- # respect order of occurrence
180
- # separated from each other by comma space
181
- def part_name_number(structured_values)
182
- title_from_part = ''
183
- structured_values.each do |structured_value|
184
- case structured_value.type&.downcase
185
- when 'part name', 'part number'
186
- value = structured_value.value&.strip
187
- next unless value
188
-
189
- title_from_part = append_part_to_title(title_from_part, value)
190
-
191
- end
192
- end
193
- title_from_part
194
- end
195
-
196
- def append_part_to_title(title_from_part, value)
197
- if !add_punctuation?
198
- [title_from_part, value].select(&:presence).join(' ')
199
- elsif title_from_part.strip.present?
200
- "#{title_from_part.sub(/[ .,]*$/, '')}, #{value}"
201
- else
202
- value
203
- end
204
- end
205
- end
206
- # rubocop:enable Metrics/ClassLength
207
- end
208
- end