cocina-models 0.73.6 → 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: 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