cocina_display 0.3.0 → 0.4.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: 6ef43e2d573c99c10d6db87974ff32091ff7e7d312bf2e812ef1043f513f291c
4
- data.tar.gz: f04b35117201aaebff5e8d041d2776525dcd18a46bc408baf9d8649376d90618
3
+ metadata.gz: 0724f307088c9cdd9348af463e3c659811eef65d0f1f04e094b4c664a36cfed1
4
+ data.tar.gz: 4b49472d07a085455ca72204e6ebde3cfc27c634f8ba9cd5b2c2a47d62d19aad
5
5
  SHA512:
6
- metadata.gz: c36dd129d129ce44c7c516b670295b59f68732d0270664dda63ce119fe37ec60e68c6fd615e37ada5ecef06777db7be30cd708d0f0c461c18c97085e98eb553f
7
- data.tar.gz: cddd56237670d908a6be7951e24fe62a0fbd3076fd0eff96693c7debc03fa9ab8e01157a330923938143ec2067a4d97d4ead339f44fd2b00d0bcf99ea92b6af0
6
+ metadata.gz: edc1e8977792d21fd6fef292f486c11eab4011dc63c854af1217aced6930ea55538e158abc261491c08b1f8492c374c52102b44931849b2350e1dacf54310d41
7
+ data.tar.gz: 664691764d4a4ff7da024af64ce9dd2d0841d8c014ee9439c3ebebe4b3a4c2e585e7f608f2ff943a04586dfaad8fc4ec7cfa714fade929ca9b0278ec740bff8b
data/README.md CHANGED
@@ -40,7 +40,7 @@ There is also a helper method to fetch the Cocina JSON for a given DRUID and imm
40
40
  The `CocinaRecord` class provides some methods to access common fields, as well as an underlying hash representation parsed from the JSON.
41
41
 
42
42
  ```ruby
43
- > record.title
43
+ > record.main_title
44
44
  => "Bugatti Type 51A. Road & Track Salon January 1957"
45
45
  > record.content_type
46
46
  => "image"
@@ -7,13 +7,18 @@ require "active_support"
7
7
  require "active_support/core_ext/object/blank"
8
8
  require "active_support/core_ext/hash/conversions"
9
9
 
10
- require_relative "title_builder"
11
10
  require_relative "concerns/events"
11
+ require_relative "concerns/contributors"
12
+ require_relative "concerns/identifiers"
13
+ require_relative "concerns/titles"
12
14
 
13
15
  module CocinaDisplay
14
16
  # Public Cocina metadata for an SDR object, as fetched from PURL.
15
17
  class CocinaRecord
16
18
  include CocinaDisplay::Concerns::Events
19
+ include CocinaDisplay::Concerns::Contributors
20
+ include CocinaDisplay::Concerns::Identifiers
21
+ include CocinaDisplay::Concerns::Titles
17
22
 
18
23
  # Fetch a public Cocina document from PURL and create a CocinaRecord.
19
24
  # @note This is intended to be used in development or testing only.
@@ -45,60 +50,6 @@ module CocinaDisplay
45
50
  Janeway.enum_for(path_expression, cocina_doc)
46
51
  end
47
52
 
48
- # The DRUID for the object, with the +druid:+ prefix.
49
- # @return [String]
50
- # @example
51
- # record.druid #=> "druid:bb099mt5053"
52
- def druid
53
- cocina_doc["externalIdentifier"]
54
- end
55
-
56
- # The DRUID for the object, without the +druid:+ prefix.
57
- # @return [String]
58
- # @example
59
- # record.bare_druid #=> "bb099mt5053"
60
- def bare_druid
61
- druid.delete_prefix("druid:")
62
- end
63
-
64
- # The DOI for the object, if there is one – just the identifier part.
65
- # @return [String, nil]
66
- # @example
67
- # record.doi #=> "10.25740/ppax-bf07"
68
- def doi
69
- doi_id = path("$.identification.doi").first ||
70
- path("$.description.identifier[?match(@.type, 'doi|DOI')].value").first ||
71
- path("$.description.identifier[?search(@.uri, 'doi.org')].uri").first
72
-
73
- URI(doi_id).path.delete_prefix("/") if doi_id.present?
74
- end
75
-
76
- # The DOI as a URL, if there is one. Any valid DOI should resolve via doi.org.
77
- # @return [String, nil]
78
- # @example
79
- # record.doi_url #=> "https://doi.org/10.25740/ppax-bf07"
80
- def doi_url
81
- URI.join("https://doi.org", doi).to_s if doi.present?
82
- end
83
-
84
- # The HRID of the item in FOLIO, if defined.
85
- # @note This doesn't imply the object is available in Searchworks at this ID.
86
- # @return [String, nil]
87
- # @example
88
- # record.folio_hrid #=> "a12845814"
89
- def folio_hrid
90
- path("$.identification.catalogLinks[?(@.catalog == 'folio')].catalogRecordId").first
91
- end
92
-
93
- # The FOLIO HRID if defined, otherwise the bare DRUID.
94
- # @note This doesn't imply the object is available in Searchworks at this ID.
95
- # @see folio_hrid
96
- # @see bare_druid
97
- # @return [String]
98
- def searchworks_id
99
- folio_hrid || bare_druid
100
- end
101
-
102
53
  # Timestamp when the Cocina was created.
103
54
  # @note This is for the metadata itself, not the object.
104
55
  # @return [Time]
@@ -128,28 +79,6 @@ module CocinaDisplay
128
79
  content_type == "collection"
129
80
  end
130
81
 
131
- # The main title for the object.
132
- # @note If you need more formatting control, consider using {CocinaDisplay::TitleBuilder} directly.
133
- # @return [String]
134
- # @example
135
- # record.title #=> "Bugatti Type 51A. Road & Track Salon January 1957"
136
- def title
137
- CocinaDisplay::TitleBuilder.build(
138
- cocina_doc.dig("description", "title"),
139
- catalog_links: cocina_doc.dig("identification", "catalogLinks")
140
- )
141
- end
142
-
143
- # Alternative or translated titles for the object. Does not include the main title.
144
- # @return [Array<String>]
145
- # @example
146
- # record.additional_titles #=> ["Alternate title 1", "Alternate title 2"]
147
- def additional_titles
148
- CocinaDisplay::TitleBuilder.additional_titles(
149
- cocina_doc.dig("description", "title")
150
- )
151
- end
152
-
153
82
  # Traverse nested FileSets and return an enumerator over their files.
154
83
  # Each file is a +Hash+.
155
84
  # @return [Enumerator] Enumerator over file hashes
@@ -0,0 +1,94 @@
1
+ require_relative "../contributor"
2
+
3
+ module CocinaDisplay
4
+ module Concerns
5
+ # Methods for finding and formatting names for contributors
6
+ module Contributors
7
+ # The main author's name, formatted for display.
8
+ # @param with_date [Boolean] Include life dates, if present
9
+ # @return [String]
10
+ # @return [nil] if no main author is found
11
+ # @example
12
+ # record.main_author #=> "Smith, John"
13
+ # @example with date
14
+ # record.main_author(with_date: true) #=> "Smith, John, 1970-2020"
15
+ def main_author(with_date: false)
16
+ main_author_contributor&.display_name(with_date: with_date)
17
+ end
18
+
19
+ # All author names except the main one, formatted for display.
20
+ # @param with_date [Boolean] Include life dates, if present
21
+ # @return [Array<String>]
22
+ def additional_authors(with_date: false)
23
+ additional_author_contributors.map { |c| c.display_name(with_date: with_date) }
24
+ end
25
+
26
+ # All names of authors who are people, formatted for display.
27
+ # @param with_date [Boolean] Include life dates, if present
28
+ # @return [Array<String>]
29
+ def person_authors(with_date: false)
30
+ authors.filter(&:person?).map { |c| c.display_name(with_date: with_date) }
31
+ end
32
+
33
+ # All names of non-person authors, formatted for display.
34
+ # This includes organizations, conferences, families, etc.
35
+ # @return [Array<String>]
36
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#contributor-types
37
+ def impersonal_authors
38
+ authors.reject(&:person?).map(&:display_name)
39
+ end
40
+
41
+ # All names of authors that are organizations, formatted for display.
42
+ # @return [Array<String>]
43
+ def organization_authors
44
+ authors.filter(&:organization?).map(&:display_name)
45
+ end
46
+
47
+ # All names of authors that are conferences, formatted for display.
48
+ # @return [Array<String>]
49
+ def conference_authors
50
+ authors.filter(&:conference?).map(&:display_name)
51
+ end
52
+
53
+ # A string value for sorting by author that sorts missing values last.
54
+ # Ignores punctuation and leading/trailing spaces.
55
+ # @return [String]
56
+ def sort_author
57
+ (main_author_contributor&.display_name || "\u{10FFFF}").gsub(/[[:punct:]]*/, "").strip
58
+ end
59
+
60
+ private
61
+
62
+ # All contributors for the object, including authors, editors, etc.
63
+ # @return [Array<Contributor>]
64
+ def contributors
65
+ @contributors ||= path("$.description.contributor[*]").map { |c| Contributor.new(c) }
66
+ end
67
+
68
+ # All contributors with a "creator" or "author" role.
69
+ # @return [Array<Contributor>]
70
+ # @see Contributor#author?
71
+ def authors
72
+ contributors.filter(&:author?)
73
+ end
74
+
75
+ # Contributor object representing the primary author.
76
+ # Selected according to the following rules:
77
+ # 1. If there is a primary author or creator, use that.
78
+ # 2. If there are no primary authors or creators, use the first one.
79
+ # 3. If there are none at all, use the first contributor without any role.
80
+ # @return [Contributor]
81
+ # @return [nil] if no suitable contributor is found
82
+ def main_author_contributor
83
+ authors.find(&:primary?).presence || authors.first || contributors.find { |c| !c.role? }.presence
84
+ end
85
+
86
+ # All author/creator contributors except the main one.
87
+ # @return [Array<Contributor>]
88
+ def additional_author_contributors
89
+ return [] if authors.empty? || authors.one? || !authors.include?(main_author_contributor)
90
+ authors - [main_author_contributor]
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,60 @@
1
+ module CocinaDisplay
2
+ module Concerns
3
+ # Methods for extracting and formatting identifiers from Cocina records.
4
+ module Identifiers
5
+ # The DRUID for the object, with the +druid:+ prefix.
6
+ # @return [String]
7
+ # @example
8
+ # record.druid #=> "druid:bb099mt5053"
9
+ def druid
10
+ cocina_doc["externalIdentifier"]
11
+ end
12
+
13
+ # The DRUID for the object, without the +druid:+ prefix.
14
+ # @return [String]
15
+ # @example
16
+ # record.bare_druid #=> "bb099mt5053"
17
+ def bare_druid
18
+ druid.delete_prefix("druid:")
19
+ end
20
+
21
+ # The DOI for the object, if there is one – just the identifier part.
22
+ # @return [String, nil]
23
+ # @example
24
+ # record.doi #=> "10.25740/ppax-bf07"
25
+ def doi
26
+ doi_id = path("$.identification.doi").first ||
27
+ path("$.description.identifier[?match(@.type, 'doi|DOI')].value").first ||
28
+ path("$.description.identifier[?search(@.uri, 'doi.org')].uri").first
29
+
30
+ URI(doi_id).path.delete_prefix("/") if doi_id.present?
31
+ end
32
+
33
+ # The DOI as a URL, if there is one. Any valid DOI should resolve via doi.org.
34
+ # @return [String, nil]
35
+ # @example
36
+ # record.doi_url #=> "https://doi.org/10.25740/ppax-bf07"
37
+ def doi_url
38
+ URI.join("https://doi.org", doi).to_s if doi.present?
39
+ end
40
+
41
+ # The HRID of the item in FOLIO, if defined.
42
+ # @note This doesn't imply the object is available in Searchworks at this ID.
43
+ # @return [String, nil]
44
+ # @example
45
+ # record.folio_hrid #=> "a12845814"
46
+ def folio_hrid
47
+ path("$.identification.catalogLinks[?(@.catalog == 'folio')].catalogRecordId").first
48
+ end
49
+
50
+ # The FOLIO HRID if defined, otherwise the bare DRUID.
51
+ # @note This doesn't imply the object is available in Searchworks at this ID.
52
+ # @see folio_hrid
53
+ # @see bare_druid
54
+ # @return [String]
55
+ def searchworks_id
56
+ folio_hrid || bare_druid
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,64 @@
1
+ require_relative "../title_builder"
2
+
3
+ module CocinaDisplay
4
+ module Concerns
5
+ # Methods for finding and formatting titles.
6
+ module Titles
7
+ # The main title for the object, without subtitle, part name, etc.
8
+ # If there are multiple titles, uses the first.
9
+ # @see CocinaDisplay::TitleBuilder#main_title
10
+ # @note This corresponds to the "short title" in MODS XML, or MARC 245$a only.
11
+ # @return [String]
12
+ def main_title
13
+ CocinaDisplay::TitleBuilder.main_title(cocina_titles).first
14
+ end
15
+
16
+ # The full title for the object, including subtitle, part name, etc.
17
+ # If there are multiple titles, uses the first.
18
+ # @see CocinaDisplay::TitleBuilder#full_title
19
+ # @note This corresponds to the entire MARC 245 field.
20
+ # @return [String]
21
+ def full_title
22
+ CocinaDisplay::TitleBuilder.full_title(cocina_titles, catalog_links: catalog_links).first
23
+ end
24
+
25
+ # The full title, joined together with additional punctuation.
26
+ # If there are multiple titles, uses the first.
27
+ # @see CocinaDisplay::TitleBuilder#build
28
+ # @return [String]
29
+ def display_title
30
+ CocinaDisplay::TitleBuilder.build(cocina_titles, catalog_links: catalog_links)
31
+ end
32
+
33
+ # Any additional titles for the object excluding the main title.
34
+ # @return [Array<String>]
35
+ # @see CocinaDisplay::TitleBuilder#additional_titles
36
+ def additional_titles
37
+ CocinaDisplay::TitleBuilder.additional_titles(cocina_titles)
38
+ end
39
+
40
+ # A string value for sorting by title that sorts missing values last.
41
+ # Ignores punctuation, leading/trailing spaces, and non-sorting characters.
42
+ # @see CocinaDisplay::TitleBuilder#sort_title
43
+ # @return [String]
44
+ def sort_title
45
+ CocinaDisplay::TitleBuilder.sort_title(cocina_titles, catalog_links: catalog_links).first || "\u{10FFFF}"
46
+ end
47
+
48
+ private
49
+
50
+ # The titles from the Cocina document, as an array of hashes.
51
+ # @return [Array<Hash>]
52
+ def cocina_titles
53
+ @cocina_titles ||= Array(cocina_doc.dig("description", "title"))
54
+ end
55
+
56
+ # The catalog links from the Cocina document, as an array of hashes.
57
+ # These link to FOLIO and can include part labels used to construct titles.
58
+ # @return [Array<Hash>]
59
+ def catalog_links
60
+ @catalog_links ||= Array(cocina_doc.dig("identification", "catalogLinks"))
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/array/conversions"
6
+
7
+ require_relative "utils"
8
+
9
+ module CocinaDisplay
10
+ # A contributor to a work, such as an author or publisher.
11
+ class Contributor
12
+ attr_reader :cocina
13
+
14
+ # Initialize a Contributor object with Cocina structured data.
15
+ # @param cocina [Hash] The Cocina structured data for the contributor.
16
+ def initialize(cocina)
17
+ @cocina = cocina
18
+ end
19
+
20
+ # String representation of the contributor, including name and role.
21
+ # Used for debugging and logging.
22
+ # @return [String]
23
+ def to_s
24
+ Utils.compact_and_join([display_name, display_role], delimiter: ": ")
25
+ end
26
+
27
+ # Is this contributor a human?
28
+ # @return [Boolean]
29
+ def person?
30
+ cocina["type"] == "person"
31
+ end
32
+
33
+ # Is this contributor an organization?
34
+ # @return [Boolean]
35
+ def organization?
36
+ cocina["type"] == "organization"
37
+ end
38
+
39
+ # Is this contributor a conference?
40
+ # @return [Boolean]
41
+ def conference?
42
+ cocina["type"] == "conference"
43
+ end
44
+
45
+ # Is this contributor marked as primary?
46
+ # @return [Boolean]
47
+ def primary?
48
+ cocina["status"] == "primary"
49
+ end
50
+
51
+ # Does this contributor have a role that indicates they are an author?
52
+ # @return [Boolean]
53
+ def author?
54
+ roles.any? { |role| role["value"] =~ /(author|creator)/i }
55
+ end
56
+
57
+ # Does this contributor have any roles defined?
58
+ # @return [Boolean]
59
+ def role?
60
+ roles.any?
61
+ end
62
+
63
+ # The display name for the contributor as a string.
64
+ # Uses the first name if multiple names are present.
65
+ # @param with_date [Boolean] Include life dates, if present
66
+ # @return [String]
67
+ def display_name(with_date: false)
68
+ names.map { |name| name.display_str(with_date: with_date) }.first
69
+ end
70
+
71
+ # A string representation of the contributor's roles, formatted for display.
72
+ # If there are multiple roles, they are joined with commas.
73
+ # @return [String]
74
+ def display_role
75
+ roles.map { |role| role["value"] }.to_sentence
76
+ end
77
+
78
+ private
79
+
80
+ # All names in the Cocina as Name objects.
81
+ # @return [Array<Name>]
82
+ def names
83
+ @names ||= Array(cocina["name"]).map { |name| Name.new(name) }
84
+ end
85
+
86
+ # All roles in the Cocina structured data.
87
+ # @return [Array<Hash>]
88
+ def roles
89
+ Array(cocina["role"])
90
+ end
91
+
92
+ # A name associated with a contributor.
93
+ class Name
94
+ attr_reader :cocina
95
+
96
+ # Initialize a Name object with Cocina structured data.
97
+ # @param cocina [Hash] The Cocina structured data for the name.
98
+ def initialize(cocina)
99
+ @cocina = cocina
100
+ end
101
+
102
+ # The display string for the name, optionally including life dates.
103
+ # @param with_date [Boolean] Include life dates, if present
104
+ # @return [String]
105
+ # @example no dates
106
+ # name.display_name # => "King, Martin Luther, Jr."
107
+ # @example with dates
108
+ # name.display_name(with_date: true) # => "King, Martin Luther, Jr., 1929-1968"
109
+ def display_str(with_date: false)
110
+ if dates_str.present? && with_date
111
+ Utils.compact_and_join([full_name_str, dates_str], delimiter: ", ")
112
+ else
113
+ full_name_str
114
+ end
115
+ end
116
+
117
+ private
118
+
119
+ # The full name as a string.
120
+ # If any names were marked as "display", prefer those.
121
+ # Otherwise, combine all name components.
122
+ # @return [String]
123
+ def full_name_str
124
+ display_name_str.presence || Utils.compact_and_join(name_components, delimiter: ", ")
125
+ end
126
+
127
+ # Flattened form of any names explicitly marked as "display name".
128
+ # @return [String]
129
+ def display_name_str
130
+ Utils.compact_and_join(Array(name_values["display"]), delimiter: ", ")
131
+ end
132
+
133
+ # List of all name components.
134
+ # If any of forename, surname, or term of address are present, those are used.
135
+ # Otherwise, fall back to any names explicitly marked as "name" or untyped.
136
+ # @return [Array<String>]
137
+ def name_components
138
+ [surname_str, forename_ordinal_str, terms_of_address_str].compact_blank.presence || Array(name_values["name"])
139
+ end
140
+
141
+ # Flatten all forenames and ordinals into a single string.
142
+ # @return [String]
143
+ def forename_ordinal_str
144
+ Utils.compact_and_join(Array(name_values["forename"]) + Array(name_values["ordinal"]), delimiter: " ")
145
+ end
146
+
147
+ # Flatten all terms of address into a single string.
148
+ # @return [String]
149
+ def terms_of_address_str
150
+ Utils.compact_and_join(Array(name_values["term of address"]), delimiter: ", ")
151
+ end
152
+
153
+ # Flatten all surnames into a single string.
154
+ # @return [String]
155
+ def surname_str
156
+ Utils.compact_and_join(Array(name_values["surname"]), delimiter: " ")
157
+ end
158
+
159
+ # Flatten all life and activity dates into a single string.
160
+ # @return [String]
161
+ def dates_str
162
+ Utils.compact_and_join(Array(name_values["life dates"]) + Array(name_values["activity dates"]), delimiter: ", ")
163
+ end
164
+
165
+ # A hash mapping destructured name types to their values.
166
+ # Name values with no type are grouped under "name".
167
+ # @return [Hash<String, Array<String>>]
168
+ # @see https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md#contributor-name-part-types-for-structured-value
169
+ # @note Currently we do nothing with "alternative", "inverted full name", "pseudonym", and "transliteration" types.
170
+ def name_values
171
+ Utils.flatten_structured_values(cocina).each_with_object({}) do |node, hash|
172
+ type = node["type"] || "name"
173
+ hash[type] ||= []
174
+ hash[type] << node["value"]
175
+ end.compact_blank
176
+ end
177
+ end
178
+ end
179
+ end
@@ -5,9 +5,10 @@ require "active_support"
5
5
  require "active_support/core_ext/enumerable"
6
6
  require "active_support/core_ext/object/blank"
7
7
 
8
+ require_relative "utils"
9
+ require_relative "marc_country_codes"
8
10
  require_relative "dates/date"
9
11
  require_relative "dates/date_range"
10
- require_relative "marc_country_codes"
11
12
 
12
13
  module CocinaDisplay
13
14
  # Wrapper for Cocina events used to generate an imprint statement for display.
@@ -20,23 +21,6 @@ module CocinaDisplay
20
21
  cocina_dates.map { |cd| CocinaDisplay::Dates::Date.from_cocina(cd) }.filter(&:parsable?).compact
21
22
  end
22
23
 
23
- # Join non-empty values into a string using provided delimiter.
24
- # If values already end in delimiter (ignoring whitespace), join with a space instead.
25
- # @param values [Array<String>] The values to compact and join
26
- # @param delimiter [String] The delimiter to use for joining, default is space
27
- def self.compact_and_join(values, delimiter: " ")
28
- compacted_values = values.compact_blank.map(&:strip)
29
- return compacted_values.first if compacted_values.one?
30
-
31
- compacted_values.reduce(+"") do |result, value|
32
- result << if value.end_with?(delimiter.strip)
33
- value + " "
34
- else
35
- value + delimiter
36
- end
37
- end.delete_suffix(delimiter)
38
- end
39
-
40
24
  attr_reader :cocina, :dates
41
25
 
42
26
  # Initialize the imprint with Cocina event data.
@@ -49,9 +33,9 @@ module CocinaDisplay
49
33
  # The entire imprint statement formatted as a string for display.
50
34
  # @return [String]
51
35
  def display_str
52
- place_pub = self.class.compact_and_join([place_str, publisher_str], delimiter: " : ")
53
- edition_place_pub = self.class.compact_and_join([edition_str, place_pub], delimiter: " - ")
54
- self.class.compact_and_join([edition_place_pub, date_str], delimiter: ", ")
36
+ place_pub = Utils.compact_and_join([place_str, publisher_str], delimiter: " : ")
37
+ edition_place_pub = Utils.compact_and_join([edition_str, place_pub], delimiter: " - ")
38
+ Utils.compact_and_join([edition_place_pub, date_str], delimiter: ", ")
55
39
  end
56
40
 
57
41
  # Were any of the dates encoded?
@@ -65,25 +49,25 @@ module CocinaDisplay
65
49
  # The date portion of the imprint statement, comprising all unique dates.
66
50
  # @return [String]
67
51
  def date_str
68
- self.class.compact_and_join(unique_dates_for_display.map(&:qualified_value))
52
+ Utils.compact_and_join(unique_dates_for_display.map(&:qualified_value))
69
53
  end
70
54
 
71
55
  # The editions portion of the imprint statement, combining all edition notes.
72
56
  # @return [String]
73
57
  def edition_str
74
- self.class.compact_and_join(Janeway.enum_for("$.note[?@.type == 'edition'].value", cocina))
58
+ Utils.compact_and_join(Janeway.enum_for("$.note[?@.type == 'edition'].value", cocina))
75
59
  end
76
60
 
77
61
  # The place of publication, combining all location values.
78
62
  # @return [String]
79
63
  def place_str
80
- self.class.compact_and_join(locations_for_display, delimiter: " : ")
64
+ Utils.compact_and_join(locations_for_display, delimiter: " : ")
81
65
  end
82
66
 
83
67
  # The publisher information, combining all name values for publishers.
84
68
  # @return [String]
85
69
  def publisher_str
86
- self.class.compact_and_join(Janeway.enum_for("$.contributor[?@.role[?@.value == 'publisher']].name[*].value", cocina), delimiter: " : ")
70
+ Utils.compact_and_join(Janeway.enum_for("$.contributor[?@.role[?@.value == 'publisher']].name[*].value", cocina), delimiter: " : ")
87
71
  end
88
72
 
89
73
  # Get the place name for a location, decoding from MARC if necessary.
@@ -46,6 +46,16 @@ module CocinaDisplay
46
46
  [new(strategy: :all, add_punctuation: false).build(titles)].flatten - full_title(titles)
47
47
  end
48
48
 
49
+ # Like the full title, but with any non-sorting characters and punctuation removed.
50
+ # @param titles [Array<Hash>] The titles to consider.
51
+ # @param catalog_links [Array<Hash>] The folio catalog links to check for digital serials part labels.
52
+ # @return [Array<String>] The sort title value(s) for Solr - array due to possible parallelValue
53
+ def self.sort_title(titles, catalog_links: [])
54
+ part_label = catalog_links.find { |link| link["catalog"] == "folio" }&.fetch("partLabel", nil)
55
+ [new(strategy: :first, add_punctuation: false, only_one_parallel_value: false, part_label: part_label, sortable: true).build(titles)]
56
+ .flatten.compact.map { |title| title.gsub(/[[:punct:]]*/, "").strip }
57
+ end
58
+
49
59
  # @param strategy [Symbol] ":first" selects a single title value based on precedence of
50
60
  # primary, untyped, first occurrence. ":all" returns an array containing all the values.
51
61
  # @param add_punctuation [boolean] whether the title should be formatted with punctuation (think of a structured
@@ -54,11 +64,13 @@ module CocinaDisplay
54
64
  # of primary, untyped, first occurrence. When false, return an array containing all the parallel values.
55
65
  # Why? Think of e.g. title displayed in blacklight search results vs boosting values for ranking of search results
56
66
  # @param part_label [String] the partLabel to add for digital serials display
57
- def initialize(strategy:, add_punctuation:, only_one_parallel_value: true, part_label: nil)
67
+ # @param sortable [boolean] whether the title is intended for sorting, and should have non-sorting parts removed
68
+ def initialize(strategy:, add_punctuation:, only_one_parallel_value: true, part_label: nil, sortable: false)
58
69
  @strategy = strategy
59
70
  @add_punctuation = add_punctuation
60
71
  @only_one_parallel_value = only_one_parallel_value
61
72
  @part_label = part_label
73
+ @sortable = sortable
62
74
  end
63
75
 
64
76
  # @param [Array<Hash>] cocina_titles the titles to consider
@@ -161,6 +173,10 @@ module CocinaDisplay
161
173
  @only_one_parallel_value
162
174
  end
163
175
 
176
+ def sortable?
177
+ @sortable
178
+ end
179
+
164
180
  # @return [Hash, nil] title that has status=primary
165
181
  def primary_title(cocina_titles)
166
182
  primary_title = cocina_titles.find { |title| title["status"] == "primary" }
@@ -221,7 +237,7 @@ module CocinaDisplay
221
237
  # rubocop:disable Metrics/CyclomaticComplexity
222
238
  # rubocop:disable Metrics/MethodLength
223
239
  # rubocop:disable Metrics/PerceivedComplexity
224
- def rebuild_structured_value(cocina_title)
240
+ def rebuild_structured_value(cocina_title, sortable: false)
225
241
  result = ""
226
242
  part_name_number = ""
227
243
  cocina_title["structuredValue"].each do |structured_value| # rubocop:disable Metrics/BlockLength
@@ -235,8 +251,10 @@ module CocinaDisplay
235
251
  # additional types ignored here, e.g. name, uniform ...
236
252
  case structured_value["type"]&.downcase
237
253
  when "nonsorting characters"
238
- padding = non_sorting_padding(cocina_title, value)
239
- result = add_non_sorting_value(result, value, padding)
254
+ unless sortable?
255
+ padding = non_sorting_padding(cocina_title, value)
256
+ result = add_non_sorting_value(result, value, padding)
257
+ end
240
258
  when "part name", "part number"
241
259
  # even if there is a partLabel, use any existing structuredValue
242
260
  # part name/number that remains for non-digital serials purposes
@@ -286,7 +304,7 @@ module CocinaDisplay
286
304
  # rubocop:disable Metrics/PerceivedComplexity
287
305
  # rubocop:disable Metrics/AbcSize
288
306
  # rubocop:disable Metrics/CyclomaticComplexity
289
- def main_title_from_structured_values(cocina_title)
307
+ def main_title_from_structured_values(cocina_title, sortable: false)
290
308
  result = ""
291
309
  # combine pieces of the cocina structuredValue into a single title
292
310
  cocina_title["structuredValue"].each do |structured_value|
@@ -299,8 +317,10 @@ module CocinaDisplay
299
317
 
300
318
  case structured_value["type"]&.downcase
301
319
  when "nonsorting characters"
302
- padding = non_sorting_padding(cocina_title, value)
303
- result = add_non_sorting_value(result, value, padding)
320
+ unless sortable?
321
+ padding = non_sorting_padding(cocina_title, value)
322
+ result = add_non_sorting_value(result, value, padding)
323
+ end
304
324
  when "main title", "title"
305
325
  result = if ["'", "-"].include?(result.last)
306
326
  [result, value].join
@@ -0,0 +1,33 @@
1
+ module CocinaDisplay
2
+ # Helper methods for string formatting, etc.
3
+ module Utils
4
+ # Join non-empty values into a string using provided delimiter.
5
+ # If values already end in delimiter (ignoring whitespace), join with a space instead.
6
+ # @param values [Array<String>] The values to compact and join
7
+ # @param delimiter [String] The delimiter to use for joining, default is space
8
+ # @return [String] The compacted and joined string
9
+ def self.compact_and_join(values, delimiter: " ")
10
+ compacted_values = values.compact_blank.map(&:strip)
11
+ return compacted_values.first if compacted_values.one?
12
+
13
+ compacted_values.reduce(+"") do |result, value|
14
+ result << if value.end_with?(delimiter.strip)
15
+ value + " "
16
+ else
17
+ value + delimiter
18
+ end
19
+ end.delete_suffix(delimiter)
20
+ end
21
+
22
+ # Recursively flatten structured values in Cocina metadata.
23
+ # Returns a list of hashes representing the "leaf" nodes with values.
24
+ # @return [Array<Hash>] List of node hashes with "value" present
25
+ def self.flatten_structured_values(cocina, output = [])
26
+ return [cocina] if cocina["value"].present?
27
+ return cocina.flat_map { |node| flatten_structured_values(node, output) } if cocina.is_a?(Array)
28
+ return output unless (structured_values = Array(cocina["structuredValue"])).present?
29
+
30
+ structured_values.flat_map { |node| flatten_structured_values(node, output) }
31
+ end
32
+ end
33
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # :nodoc:
4
4
  module CocinaDisplay
5
- VERSION = "0.3.0" # :nodoc:
5
+ VERSION = "0.4.0" # :nodoc:
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina_display
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nick Budak
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-07-08 00:00:00.000000000 Z
11
+ date: 2025-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: janeway-jsonpath
@@ -176,12 +176,17 @@ files:
176
176
  - Rakefile
177
177
  - lib/cocina_display.rb
178
178
  - lib/cocina_display/cocina_record.rb
179
+ - lib/cocina_display/concerns/contributors.rb
179
180
  - lib/cocina_display/concerns/events.rb
181
+ - lib/cocina_display/concerns/identifiers.rb
182
+ - lib/cocina_display/concerns/titles.rb
183
+ - lib/cocina_display/contributor.rb
180
184
  - lib/cocina_display/dates/date.rb
181
185
  - lib/cocina_display/dates/date_range.rb
182
186
  - lib/cocina_display/imprint.rb
183
187
  - lib/cocina_display/marc_country_codes.rb
184
188
  - lib/cocina_display/title_builder.rb
189
+ - lib/cocina_display/utils.rb
185
190
  - lib/cocina_display/version.rb
186
191
  - sig/cocina_display.rbs
187
192
  homepage: https://sul-dlss.github.io/cocina_display/