cocina_display 2.1.0 → 2.2.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.
@@ -1,177 +1,46 @@
1
1
  module CocinaDisplay
2
2
  module Subjects
3
- # A descriptive value that can be part of a Subject.
4
- class SubjectValue
5
- attr_reader :cocina
6
-
7
- # The type of the subject value, like "person", "title", or "time".
8
- # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#subject-part-types-for-structured-value
9
- attr_accessor :type
10
-
11
- # Create SubjectValues from Cocina structured data.
12
- # Pre-coordinated string values will be split into multiple SubjectValues.
13
- # @param cocina [Hash] The Cocina structured data for the subject.
14
- # @param type [String, nil] The type, coming from the parent Subject.
15
- # @return [Array<SubjectValue>]
16
- def self.from_cocina(cocina, type:)
17
- split_pre_coordinated_values(cocina, type: type).map do |value|
18
- SUBJECT_VALUE_TYPES.fetch(type, SubjectValue).new(value).tap do |obj|
19
- obj.type ||= type
20
- end
21
- end
22
- end
23
-
24
- # Split a pre-coordinated subject value joined with "--" into multiple values.
25
- # Ignores the "--" string for coordinate subject types, which use it differently.
26
- # @param cocina [Hash] The Cocina structured data for the subject.
27
- # @return [Array<Hash>] An array of Cocina hashes, one for each split value
28
- def self.split_pre_coordinated_values(cocina, type:)
29
- if cocina["value"].is_a?(String) && cocina["value"].include?("--") && !type&.include?("coordinates")
30
- cocina["value"].split("--").map { |v| cocina.merge("value" => v.strip) }
31
- else
32
- [cocina]
33
- end
34
- end
35
-
36
- # All subject value types that should not be further destructured.
3
+ # A subject in Cocina structured data in a single language/script.
4
+ class SubjectValue < Parallel::ParallelValue
5
+ # Array of display strings for each part of the subject.
6
+ # Used for search, where each value should be indexed separately.
37
7
  # @return [Array<String>]
38
- def self.atomic_types
39
- SUBJECT_VALUE_TYPES.keys - ["place"]
40
- end
41
-
42
- # Initialize a SubjectValue object with Cocina structured data.
43
- # @param cocina [Hash] The Cocina structured data for the subject value.
44
- def initialize(cocina)
45
- @cocina = cocina
46
- @type = cocina["type"]
8
+ def values
9
+ subject_parts.map(&:to_s).compact_blank
47
10
  end
48
11
 
49
- # The display string for the subject value.
50
- # Subclasses should override this method to provide specific formatting.
12
+ # The value to use for display.
13
+ # Genre values are capitalized; other subject values are not.
51
14
  # @return [String]
52
15
  def to_s
53
- cocina["value"]
16
+ (type == "genre") ? flat_value&.upcase_first : flat_value
54
17
  end
55
- end
56
-
57
- # A subject value representing a named entity.
58
- class NameSubjectValue < SubjectValue
59
- attr_reader :name
60
-
61
- # Initialize a NameSubjectValue object with Cocina structured data.
62
- # @param cocina [Hash] The Cocina structured data for the subject.
63
- def initialize(cocina)
64
- super
65
- @name = Contributors::Name.new(cocina)
66
- end
67
-
68
- # Use the contributor name formatting rules for display.
69
- # @return [String] The formatted name string, including life dates
70
- # @see CocinaDisplay::Contributor::Name#to_s
71
- def to_s
72
- name.to_s(with_date: true)
73
- end
74
- end
75
-
76
- # A subject value representing an entity with a title.
77
- class TitleSubjectValue < SubjectValue
78
- attr_reader :title
79
18
 
80
- # Initialize a TitleSubjectValue object with Cocina structured data.
81
- # @param cocina [Hash] The Cocina structured data for the subject.
82
- def initialize(cocina)
83
- super
84
- @title = Title.new(cocina)
85
- end
86
-
87
- # Construct a title string to use for display.
88
- # @see CocinaDisplay::Title#to_s
89
- # @return [String, nil]
90
- def to_s
91
- title.to_s
92
- end
93
- end
94
-
95
- # A subject value representing a date and/or time.
96
- class TemporalSubjectValue < SubjectValue
97
- attr_reader :date
98
-
99
- def initialize(cocina)
100
- super
101
- @date = Dates::Date.from_cocina(cocina)
102
- end
103
-
104
- # @return [String] The formatted date/time string for display
105
- def to_s
106
- date.to_s
107
- end
108
- end
109
-
110
- # A subject value representing a named place.
111
- class PlaceSubjectValue < SubjectValue
112
- # A URI identifying the place, if available.
113
- # @return [String, nil]
114
- def uri
115
- cocina["uri"]
116
- end
117
-
118
- # True if the place has a geonames.org URI.
119
- # @return [Boolean]
120
- def geonames?
121
- uri&.include?("sws.geonames.org")
122
- end
123
-
124
- # Unique identifier for the place in geonames.org.
125
- # @return [String, nil]
126
- def geonames_id
127
- uri&.split("/")&.last if geonames?
19
+ # A string representation of the entire subject, concatenated for display.
20
+ # @return [String]
21
+ def flat_value
22
+ Utils.compact_and_join(values, delimiter: delimiter)
128
23
  end
129
- end
130
-
131
- # A subject value containing geographic coordinates, like a point or box.
132
- class CoordinatesSubjectValue < SubjectValue
133
- attr_reader :coordinates
134
24
 
135
- def initialize(cocina)
136
- super
137
- @coordinates = Geospatial::Coordinates.from_cocina(cocina)
25
+ # Delimiter used to join the individual parts of the subject for display.
26
+ # @return [String]
27
+ def delimiter
28
+ " > "
138
29
  end
139
30
 
140
- # The normalized DMS string for the coordinates.
141
- # Falls back to the raw value if parsing fails.
142
- # @return [String, nil]
143
- def to_s
144
- coordinates&.to_s || super
31
+ # Individual SubjectParts composing this subject.
32
+ # Can be multiple if the Cocina featured structuredValues.
33
+ # All SubjectParts inherit the type of their parent Subject.
34
+ # @return [Array<SubjectPart>]
35
+ def subject_parts
36
+ @subject_parts ||= if SubjectPart.atomic_types.include?(type)
37
+ SubjectPart.from_cocina(cocina, type: type)
38
+ else
39
+ Utils.flatten_nested_values(cocina, atomic_types: SubjectPart.atomic_types).flat_map do |value|
40
+ SubjectPart.from_cocina(value, type: value["type"] || type)
41
+ end
42
+ end
145
43
  end
146
44
  end
147
45
  end
148
46
  end
149
-
150
- # Map Cocina subject types to specific SubjectValue classes for rendering.
151
- # @see SubjectValue#type
152
- SUBJECT_VALUE_TYPES = {
153
- "person" => CocinaDisplay::Subjects::NameSubjectValue,
154
- "family" => CocinaDisplay::Subjects::NameSubjectValue,
155
- "organization" => CocinaDisplay::Subjects::NameSubjectValue,
156
- "conference" => CocinaDisplay::Subjects::NameSubjectValue,
157
- "event" => CocinaDisplay::Subjects::NameSubjectValue,
158
- "name" => CocinaDisplay::Subjects::NameSubjectValue,
159
- "title" => CocinaDisplay::Subjects::TitleSubjectValue,
160
- "time" => CocinaDisplay::Subjects::TemporalSubjectValue,
161
- "area" => CocinaDisplay::Subjects::PlaceSubjectValue,
162
- "city" => CocinaDisplay::Subjects::PlaceSubjectValue,
163
- "city section" => CocinaDisplay::Subjects::PlaceSubjectValue,
164
- "continent" => CocinaDisplay::Subjects::PlaceSubjectValue,
165
- "country" => CocinaDisplay::Subjects::PlaceSubjectValue,
166
- "county" => CocinaDisplay::Subjects::PlaceSubjectValue,
167
- "coverage" => CocinaDisplay::Subjects::PlaceSubjectValue,
168
- "extraterrestrial area" => CocinaDisplay::Subjects::PlaceSubjectValue,
169
- "island" => CocinaDisplay::Subjects::PlaceSubjectValue,
170
- "place" => CocinaDisplay::Subjects::PlaceSubjectValue,
171
- "region" => CocinaDisplay::Subjects::PlaceSubjectValue,
172
- "state" => CocinaDisplay::Subjects::PlaceSubjectValue,
173
- "territory" => CocinaDisplay::Subjects::PlaceSubjectValue,
174
- "point coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue,
175
- "map coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue,
176
- "bounding box coordinates" => CocinaDisplay::Subjects::CoordinatesSubjectValue
177
- }.freeze
@@ -0,0 +1,49 @@
1
+ module CocinaDisplay
2
+ module Titles
3
+ # A Title represented by one or more {TitleValue}s in various languages/scripts.
4
+ class Title < Parallel::Parallel
5
+ # Part data for digital serials, coming from elsewhere in the Cocina.
6
+ attr_reader :part_label, :part_numbers
7
+
8
+ # Common display methods reference the main title value. For parallel
9
+ # values, see #translated_value and #transliterated_value.
10
+ delegate :short_title, :full_title, :display_title, :sort_title, to: :main_value
11
+
12
+ # Create a new Title object.
13
+ # @param cocina [Hash]
14
+ # @param part_label [String, nil] part label for digital serials
15
+ # @param part_numbers [Array<String>] part numbers for related resources
16
+ def initialize(cocina, part_label: nil, part_numbers: nil)
17
+ super(cocina)
18
+ @part_label = part_label
19
+ @part_numbers = part_numbers
20
+ end
21
+
22
+ # Label used when displaying the title.
23
+ # @return [String]
24
+ def label
25
+ display_label || type_label
26
+ end
27
+
28
+ # The string representation of the title, for display.
29
+ # @return [String, nil]
30
+ def to_s
31
+ display_title
32
+ end
33
+
34
+ private
35
+
36
+ # Type-specific label for the title, falling back to a generic "Title".
37
+ # @return [String]
38
+ def type_label
39
+ I18n.t(type&.parameterize&.underscore, scope: "cocina_display.field_label.title", default: :title)
40
+ end
41
+
42
+ # The class to use for parallel values.
43
+ # @return [Class]
44
+ def parallel_value_class
45
+ TitleValue
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,181 @@
1
+ module CocinaDisplay
2
+ module Titles
3
+ # A Title associated with an item in a single language.
4
+ class TitleValue < Parallel::ParallelValue
5
+ # Part types for structured titles.
6
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#title-part-types-for-structured-value
7
+ PART_TYPES = ["main title", "nonsorting characters", "part name", "part number", "subtitle"].freeze
8
+
9
+ # Inherit part data from the parent title.
10
+ delegate :part_label, :part_numbers, to: :parent, allow_nil: true
11
+
12
+ # Custom label used when displaying the title, if any.
13
+ # @return [String, nil]
14
+ def label
15
+ display_label || type_label
16
+ end
17
+
18
+ # The string representation of the title, for display.
19
+ # @see #display_title
20
+ # @return [String, nil]
21
+ def to_s
22
+ display_title
23
+ end
24
+
25
+ # The short form of the title, without subtitle, part name, etc.
26
+ # @note This corresponds to the "short title" in MODS XML, or MARC 245$a only.
27
+ # @return [String, nil]
28
+ # @example "M. de Courville"
29
+ def short_title
30
+ short_title_str.presence || cocina["value"]
31
+ end
32
+
33
+ # The long form of the title, including subtitle, part name, etc.
34
+ # @note This corresponds to the entire MARC 245 field.
35
+ # @return [String, nil]
36
+ # @example "M. de Courville : [estampe]"
37
+ def full_title
38
+ full_title_str.presence || cocina["value"]
39
+ end
40
+
41
+ # The long form of the title, without trailing punctuation.
42
+ # @note This corresponds to the entire MARC 245 field.
43
+ # @return [String, nil]
44
+ def display_title
45
+ display_title_str.presence || cocina["value"]
46
+ end
47
+
48
+ # A string value for sorting by title.
49
+ # Ignores punctuation, leading/trailing spaces, and non-sorting characters.
50
+ # If no title is present, returns a high Unicode value so it sorts last.
51
+ # @return [String]
52
+ def sort_title
53
+ return "\u{10FFFF}" unless full_title
54
+
55
+ full_title[nonsorting_chars_str.length..]
56
+ .unicode_normalize(:nfd) # Prevent accents being stripped
57
+ .gsub(/[[:punct:]]*/, "")
58
+ .gsub(/\W{2,}/, " ") # Collapse whitespace after removing punctuation
59
+ .strip
60
+ end
61
+
62
+ private
63
+
64
+ # Generate the short title by joining main title and nonsorting characters.
65
+ # @return [String, nil]
66
+ def short_title_str
67
+ nonsorting_chars_str + main_title_str # pre-formatted padding
68
+ end
69
+
70
+ # Generate the full title by joining all title components with punctuation.
71
+ # @return [String, nil]
72
+ def full_title_str
73
+ title_str = main_subtitle_str
74
+ title_str = Utils.compact_and_join([main_subtitle_str, parts_str], delimiter: ". ") unless main_subtitle_str.end_with?(parts_str)
75
+ title_str = nonsorting_chars_str + title_str # pre-formatted padding
76
+ title_str = Utils.compact_and_join([names_str, title_str], delimiter: ". ") if names_str.present?
77
+ title_str += "." unless title_str&.match?(/[[:punct:]]\z/)
78
+ title_str.presence
79
+ end
80
+
81
+ # Generate the display title by stripping trailing punctuation from the full title.
82
+ # @return [String, nil]
83
+ def display_title_str
84
+ full_title_str&.sub(/[.,;:\/\\]+\z/, "")
85
+ end
86
+
87
+ # The main title and subtitle, joined together with a colon.
88
+ # @return [String, nil]
89
+ def main_subtitle_str
90
+ Utils.compact_and_join([main_title_str, subtitle_str], delimiter: " : ")
91
+ end
92
+
93
+ # All nonsorting characters joined together with padding applied.
94
+ # Handles languages that do not separate nonsorting characters with spaces.
95
+ # @return [String, nil]
96
+ def nonsorting_chars_str
97
+ pad_nonsorting(Utils.compact_and_join(Array(title_components["nonsorting characters"])))
98
+ end
99
+
100
+ # The main title component(s), joined together.
101
+ # @return [String, nil]
102
+ def main_title_str
103
+ Utils.compact_and_join(Array(title_components["main title"]))
104
+ end
105
+
106
+ # The subtitle components, joined together.
107
+ # @return [String, nil]
108
+ def subtitle_str
109
+ Utils.compact_and_join(Array(title_components["subtitle"]))
110
+ end
111
+
112
+ # The part name, number, and label components, joined together with commas.
113
+ # @return [String, nil]
114
+ def parts_str
115
+ Utils.compact_and_join(
116
+ Array(title_components["part number"] || part_numbers) +
117
+ Array(title_components["part name"]) +
118
+ [part_label],
119
+ delimiter: ", "
120
+ )
121
+ end
122
+
123
+ # The associated names, joined together with periods.
124
+ # @note Only present for uniform titles.
125
+ # @return [String, nil]
126
+ def names_str
127
+ Utils.compact_and_join(names, delimiter: ". ")
128
+ end
129
+
130
+ # Destructured title components, organized by type.
131
+ # Unstructured titles and components with no type are grouped under "main title".
132
+ # @return [Hash<String, Array<String>>]
133
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#title-part-types-for-structured-value
134
+ def title_components
135
+ Utils.flatten_nested_values(cocina).each_with_object({}) do |node, hash|
136
+ type = PART_TYPES.find { |t| t == node["type"] } || "main title"
137
+ hash[type] ||= []
138
+ hash[type] << node["value"]
139
+ end.compact_blank
140
+ end
141
+
142
+ # Uniform titles can have associated person names.
143
+ # @return [String, nil]
144
+ def names
145
+ Janeway.enum_for("$.note[?(@.type=='associated name')]", cocina).map do |name|
146
+ Contributors::Name.new(name).to_s(with_date: true)
147
+ end
148
+ end
149
+
150
+ # Number of nonsorting characters to ignore at the start of the title.
151
+ # @return [Integer, nil]
152
+ def nonsorting_char_count
153
+ Janeway.enum_for("$.note[?(@.type=='nonsorting character count')].value", cocina).first&.to_i
154
+ end
155
+
156
+ # Add or remove padding from nonsorting portion of the title.
157
+ # @param value [String]
158
+ # @return [String]
159
+ def pad_nonsorting(value)
160
+ case value.strip
161
+ when /.*-$/, /.*'$/, "ה" # Arabic, French, Hebrew prefixes use no padding
162
+ value.strip
163
+ when "" # No nonsorting characters; return empty string
164
+ ""
165
+ else # Pad to nonsorting char count if set, otherwise add a single space
166
+ if nonsorting_char_count.present?
167
+ value.ljust(nonsorting_char_count, " ")
168
+ else
169
+ value + " "
170
+ end
171
+ end
172
+ end
173
+
174
+ # Type-specific label for the title, falling back to a generic "Title".
175
+ # @return [String]
176
+ def type_label
177
+ I18n.t(type&.parameterize&.underscore, scope: "cocina_display.field_label.title", default: :title)
178
+ end
179
+ end
180
+ end
181
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "2.1.0" # :nodoc:
5
+ VERSION = "2.2.0" # :nodoc:
6
6
  end
@@ -4,6 +4,8 @@
4
4
  # blank values to create a more compact representation and write to stdout.
5
5
 
6
6
  require "json"
7
+ require "active_support"
8
+ require "active_support/core_ext"
7
9
 
8
10
  require_relative "../lib/cocina_display/utils"
9
11
 
@@ -28,7 +28,7 @@ RELEASE_TARGET = "Searchworks"
28
28
 
29
29
  # Modify this expression to match the JSON path you want to search, or just
30
30
  # modify the `examine_record` method directly.
31
- PATH_EXPR = "$..[?length(@.groupedValue) > 0]"
31
+ PATH_EXPR = "$..[? @.type == 'parallel' ]"
32
32
 
33
33
  # Modify this method as needed to change what you're looking for in each record.
34
34
  # It takes a CocinaRecord object and should return an array of [path, result] pairs.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: janeway-jsonpath
@@ -261,6 +261,7 @@ files:
261
261
  - lib/cocina_display/contributors/affiliation.rb
262
262
  - lib/cocina_display/contributors/contributor.rb
263
263
  - lib/cocina_display/contributors/name.rb
264
+ - lib/cocina_display/contributors/name_value.rb
264
265
  - lib/cocina_display/contributors/role.rb
265
266
  - lib/cocina_display/dates/date.rb
266
267
  - lib/cocina_display/dates/date_range.rb
@@ -278,15 +279,21 @@ files:
278
279
  - lib/cocina_display/geospatial.rb
279
280
  - lib/cocina_display/identifier.rb
280
281
  - lib/cocina_display/json_backed_record.rb
281
- - lib/cocina_display/language.rb
282
+ - lib/cocina_display/languages/language.rb
283
+ - lib/cocina_display/languages/script.rb
282
284
  - lib/cocina_display/license.rb
283
- - lib/cocina_display/note.rb
285
+ - lib/cocina_display/notes/note.rb
286
+ - lib/cocina_display/notes/note_value.rb
287
+ - lib/cocina_display/parallel/parallel.rb
288
+ - lib/cocina_display/parallel/parallel_value.rb
284
289
  - lib/cocina_display/related_resource.rb
285
290
  - lib/cocina_display/structural/file.rb
286
291
  - lib/cocina_display/structural/file_set.rb
287
292
  - lib/cocina_display/subjects/subject.rb
293
+ - lib/cocina_display/subjects/subject_part.rb
288
294
  - lib/cocina_display/subjects/subject_value.rb
289
- - lib/cocina_display/title.rb
295
+ - lib/cocina_display/titles/title.rb
296
+ - lib/cocina_display/titles/title_value.rb
290
297
  - lib/cocina_display/utils.rb
291
298
  - lib/cocina_display/version.rb
292
299
  - script/deep_compact.rb
@@ -312,7 +319,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
312
319
  - !ruby/object:Gem::Version
313
320
  version: '0'
314
321
  requirements: []
315
- rubygems_version: 3.6.2
322
+ rubygems_version: 4.0.7
316
323
  specification_version: 4
317
324
  summary: Helpers for rendering Cocina metadata
318
325
  test_files: []
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module CocinaDisplay
4
- # A language associated with part or all of a Cocina object.
5
- class Language
6
- SEARCHWORKS_LANGUAGES_FILE_PATH = CocinaDisplay.root / "config" / "searchworks_languages.yml"
7
-
8
- attr_reader :cocina
9
-
10
- # A hash of language codes to language names recognized by Searchworks.
11
- # @return [Hash{String => String}]
12
- def self.searchworks_languages
13
- @searchworks_languages ||= YAML.safe_load_file(SEARCHWORKS_LANGUAGES_FILE_PATH)
14
- end
15
-
16
- # Create a Language object from Cocina structured data.
17
- # @param cocina [Hash]
18
- def initialize(cocina)
19
- @cocina = cocina
20
- end
21
-
22
- # The language name for display.
23
- # @return [String, nil]
24
- def to_s
25
- cocina["value"] || decoded_value
26
- end
27
-
28
- # The language code, e.g. an ISO 639 code like "eng" or "spa".
29
- # @return [String, nil]
30
- def code
31
- cocina["code"]
32
- end
33
-
34
- # Decoded name of the language based on the code, if present.
35
- # @return [String, nil]
36
- def decoded_value
37
- Language.searchworks_languages[code] if searchworks_language?
38
- end
39
-
40
- # Display label for this field.
41
- # @return [String]
42
- def label
43
- cocina["displayLabel"].presence || I18n.t("cocina_display.field_label.language")
44
- end
45
-
46
- # True if the language is recognized by Searchworks.
47
- # @see CocinaDisplay::Language.searchworks_languages
48
- # @return [Boolean]
49
- def searchworks_language?
50
- Language.searchworks_languages.value?(cocina["value"]) || Language.searchworks_languages.key?(code)
51
- end
52
- end
53
- end