cocina-models 0.74.1 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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