cocina-models 0.73.6 → 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: 010b22a3ad2f8bfe8f1c9fc020538adf0b89b2c77dc0ad0219c2321dfec5f6da
4
- data.tar.gz: c51eae2641ca506f08abaedbd99e8385b291b4ac144f6bdffacb9cc5c5379576
3
+ metadata.gz: 5ec67ece77b9025a117182d0213342e180e7b87dcd003a44e3d9dffeb03d08e1
4
+ data.tar.gz: 3ee4eee501728b92c356760c660514789082da70c41c990fc3822f5833a7f47d
5
5
  SHA512:
6
- metadata.gz: 0b65ccf18b933dc8287371765ff66f1d17b13f3d206a960b6afcdaf337819fd3d1ddebb4146458c0eaacdc77064817f41aae5fe86edf2a58ab07de24ba5902a7
7
- data.tar.gz: bb93e938a05ce9a3370f245074a72e5c0ad009e6bd81c1330db7bc595578caaeb93f068550ec0b92060b9c1d299ff6250bb598bee37b43f1f685405881bb7f24
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
@@ -17,6 +17,8 @@ access.note:
17
17
  description: License describing allowed uses of the resource.
18
18
  - value: use and reproduction
19
19
  description: Information related to allowed uses of the resource in other contexts.
20
+ - value: access constraints
21
+ status: TEMP
20
22
  access.physicalLocation:
21
23
  - value: discovery
22
24
  description: Location where a user may find the resource.
@@ -176,7 +178,7 @@ event.date:
176
178
  description: The delivery of the resource to an external audience.
177
179
  - value: generation
178
180
  description: The creation of a resource by an automatic or natural process.
179
- - value: Hijiri calendar
181
+ - value: Hijri calendar
180
182
  - value: Islamic
181
183
  - value: Julian
182
184
  - value: letter
@@ -285,6 +287,9 @@ geographic.subject.structuredValue:
285
287
  - value: south
286
288
  - value: west
287
289
  identifier:
290
+ - value: accession
291
+ status: deprecated
292
+ use: accession number
288
293
  - value: accession number
289
294
  - value: alternate case number
290
295
  - value: anchor
@@ -296,8 +301,11 @@ identifier:
296
301
  code: arxiv
297
302
  - value: case identifier
298
303
  - value: case number
304
+ - value: CCP
305
+ - value: CLC
299
306
  - value: CSt
300
307
  - value: CStRLIN
308
+ - value: CTC
301
309
  - value: Data Provider Digital Object Identifier
302
310
  - value: document number
303
311
  - value: DOI
@@ -356,9 +364,17 @@ identifier:
356
364
  code: urn
357
365
  - value: videorecording identifier
358
366
  code: videorecording-identifier
359
- - value: West Mat \#
367
+ - value: 'West Mat #'
360
368
  - value: Wikidata
361
369
  code: wikidata
370
+ - value: Bodley 342
371
+ status: TEMP
372
+ - value: vintage
373
+ status: TEMP
374
+ - value: accesion
375
+ status: TEMP
376
+ - value: Suri UUID
377
+ status: TEMP
362
378
  note:
363
379
  - value: abstract
364
380
  - value: access
@@ -480,6 +496,8 @@ note:
480
496
  - value: version
481
497
  - value: version identification
482
498
  - value: writing
499
+ - value: note
500
+ status: TEMP
483
501
  note.groupedValue:
484
502
  - value: caption
485
503
  - value: date
@@ -534,6 +552,8 @@ subject:
534
552
  - value: time
535
553
  - value: title
536
554
  - value: topic
555
+ - value: surname
556
+ status: TEMP
537
557
  subject.note:
538
558
  - value: affiliation
539
559
  - value: description
@@ -604,6 +624,10 @@ title:
604
624
  description: Title transliterated from non-Latin script to Latin script.
605
625
  - value: uniform
606
626
  description: Form of title in Library of Congress title authority.
627
+ - value: main
628
+ status: TEMP
629
+ - value: other title
630
+ status: TEMP
607
631
  title.note:
608
632
  - value: associated name
609
633
  description: A name linked to the title, such as for a name-title heading.
@@ -15,6 +15,7 @@ _Path: access.note_
15
15
  * display label: Display label for the purl.
16
16
  * license: License describing allowed uses of the resource.
17
17
  * use and reproduction: Information related to allowed uses of the resource in other contexts.
18
+ * access constraints
18
19
 
19
20
  ## Access physicallocation types
20
21
  _Path: access.physicalLocation_
@@ -119,7 +120,7 @@ _Path: event.date_
119
120
  * development: The creation of a print from a photographic negative or other source medium.
120
121
  * distribution: The delivery of the resource to an external audience.
121
122
  * generation: The creation of a resource by an automatic or natural process.
122
- * Hijiri calendar
123
+ * Hijri calendar
123
124
  * Islamic
124
125
  * Julian
125
126
  * letter: Athanasius
@@ -208,6 +209,7 @@ _Path: geographic.subject.structuredValue_
208
209
 
209
210
  # Identifier types
210
211
  _Path: identifier_
212
+ * accession
211
213
  * accession number
212
214
  * alternate case number
213
215
  * anchor
@@ -216,8 +218,11 @@ _Path: identifier_
216
218
  * arXiv
217
219
  * case identifier
218
220
  * case number
221
+ * CCP
222
+ * CLC
219
223
  * CSt
220
224
  * CStRLIN
225
+ * CTC
221
226
  * Data Provider Digital Object Identifier
222
227
  * document number
223
228
  * DOI
@@ -256,6 +261,10 @@ _Path: identifier_
256
261
  * videorecording identifier
257
262
  * West Mat \#
258
263
  * Wikidata
264
+ * Bodley 342
265
+ * vintage
266
+ * accesion
267
+ * Suri UUID
259
268
 
260
269
  # Note types
261
270
  _Path: note_
@@ -363,6 +372,7 @@ _Path: note_
363
372
  * version
364
373
  * version identification
365
374
  * writing
375
+ * note
366
376
 
367
377
  ## Note types for grouped value (MODS legacy)
368
378
  _Path: note.groupedValue_
@@ -410,6 +420,7 @@ _Path: subject_
410
420
  * time
411
421
  * title
412
422
  * topic
423
+ * surname
413
424
 
414
425
  ## Subject note types
415
426
  _Path: subject.note_
@@ -481,6 +492,8 @@ _Path: title_
481
492
  * translated: Title translated into another language.
482
493
  * transliterated: Title transliterated from non-Latin script to Latin script.
483
494
  * uniform: Form of title in Library of Congress title authority.
495
+ * main
496
+ * other title
484
497
 
485
498
  ## Title note types
486
499
  _Path: title.note_
@@ -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
@@ -8,7 +8,7 @@ module Cocina
8
8
 
9
9
  class_methods do
10
10
  def new(attributes = default_attributes, safe = false, validate = true, &block)
11
- Validators::Validator.validate(self, attributes.with_indifferent_access) if validate
11
+ Validators::Validator.validate(self, attributes) if validate
12
12
  super(attributes, safe, &block)
13
13
  end
14
14
  end
@@ -16,7 +16,7 @@ module Cocina
16
16
  def new(*args)
17
17
  validate = args.first.delete(:validate) if args.present?
18
18
  new_model = super(*args)
19
- Validators::Validator.validate(new_model.class, new_model.to_h) if validate || validate.nil?
19
+ Validators::Validator.validate(new_model.class, new_model) if validate || validate.nil?
20
20
  new_model
21
21
  end
22
22
  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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Validators
6
+ # Validates that Purl matches the external identifier (druid)
7
+ class PurlValidator
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
15
+ end
16
+
17
+ def validate
18
+ return unless meets_preconditions?
19
+
20
+ return if identifier_from_druid == identifier_from_purl
21
+
22
+ raise ValidationError, "Purl mismatch: #{druid} purl does not match object druid."
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :clazz, :attributes
28
+
29
+ def meets_preconditions?
30
+ purl
31
+ end
32
+
33
+ def druid
34
+ @druid ||= attributes[:externalIdentifier]
35
+ end
36
+
37
+ def purl
38
+ @purl ||= attributes.dig(:description, :purl)
39
+ end
40
+
41
+ def identifier_from_druid
42
+ druid.delete_prefix('druid:')
43
+ end
44
+
45
+ def identifier_from_purl
46
+ purl.split('/').last
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -8,13 +8,39 @@ module Cocina
8
8
  VALIDATORS = [
9
9
  OpenApiValidator,
10
10
  DarkValidator,
11
+ PurlValidator,
11
12
  CatalogLinksValidator,
13
+ AssociatedNameValidator,
12
14
  DescriptionTypesValidator
13
15
  ].freeze
14
16
 
15
17
  def self.validate(clazz, attributes)
16
- VALIDATORS.each { |validator| validator.validate(clazz, attributes) }
18
+ # This gets rid of nested model objects.
19
+ # Once DSA is on Rails 6, this can be:
20
+ # attributes_hash = attributes.to_h.deep_transform_values do |value|
21
+ # value.class.name.starts_with?('Cocina::Models') ? value.to_h : value
22
+ # end.with_indifferent_access
23
+ # And add require 'active_support/core_ext/hash/deep_transform_values' to models file.
24
+
25
+ # In the meantime, copying code.
26
+ attributes_hash = deep_transform_values(attributes.to_h) do |value|
27
+ value.class.name.starts_with?('Cocina::Models') ? value.to_h : value
28
+ end.with_indifferent_access
29
+
30
+ VALIDATORS.each { |validator| validator.validate(clazz, attributes_hash) }
31
+ end
32
+
33
+ def self.deep_transform_values(object, &block)
34
+ case object
35
+ when Hash
36
+ object.transform_values { |value| deep_transform_values(value, &block) }
37
+ when Array
38
+ object.map { |e| deep_transform_values(e, &block) }
39
+ else
40
+ yield(object)
41
+ end
17
42
  end
43
+ private_class_method :deep_transform_values
18
44
  end
19
45
  end
20
46
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.73.6'
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.73.6
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,12 +387,13 @@ 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
393
395
  - lib/cocina/models/validators/open_api_validator.rb
396
+ - lib/cocina/models/validators/purl_validator.rb
394
397
  - lib/cocina/models/validators/validator.rb
395
398
  - lib/cocina/models/version.rb
396
399
  - lib/cocina/models/vocabulary.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